release: 2026-03-31 (202건 커밋) #144
@ -3,26 +3,6 @@
|
||||
## 개요
|
||||
|
||||
WING-OPS UI 디자인 시스템의 비주얼 레퍼런스 카탈로그.
|
||||
Google Stitch MCP로 생성된 스크린을 기반으로 일관된 UI 구현을 유도한다.
|
||||
|
||||
## Stitch 프로젝트
|
||||
|
||||
- **프로젝트명**: WING-OPS Design System v1
|
||||
- **프로젝트 ID**: `5453076280291618640`
|
||||
|
||||
## 스크린 목록
|
||||
|
||||
| # | 스크린 | Screen ID | 용도 |
|
||||
|---|--------|-----------|------|
|
||||
| 1 | Design Tokens | `ce520225d85c4c38b2024e93ec6a4fb2` | 색상, 타이포그래피, 간격, 라운딩 토큰 |
|
||||
| 2 | Component Catalog (Buttons/Badges) | `42fa9cf1a3d341a7972a1bc10ba00a8c` | 버튼 variant, 뱃지, 아이콘 버튼 |
|
||||
| 3 | Form Components | `7331ad8a598f4cc59f62a14226c1d023` | 입력, 선택, 날짜, 토글, 폼 레이아웃 |
|
||||
| 4 | Table & List Patterns | `5967382c70f9422ba3a0f4da79922ecf` | 데이터 테이블, 사이드바 리스트, 페이지네이션 |
|
||||
| 5 | Modal Catalog | `440be91f8db7423cbb5cc89e6dd6f9ca` | 모달 3사이즈, 확인 다이얼로그, 폼 모달 |
|
||||
| 6 | Operational Shell (Layout) | `86fd57c9f3c749d288f6270838a9387d` | TopBar, SubMenu, 3컬럼 레이아웃 |
|
||||
| 7 | Container & Navigation | `201c2c0c47b74fcfb3427d029319fa9d` | 카드, 섹션, 탭바, KV 행, 헤더바 |
|
||||
|
||||
---
|
||||
|
||||
## Foundations
|
||||
|
||||
@ -87,8 +67,22 @@ UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단
|
||||
| 토큰 | Dark | Light | 용도 |
|
||||
|------|------|-------|------|
|
||||
| `text-1` | `#edf0f7` | `#0f172a` | 기본 텍스트, 아이콘 기본 |
|
||||
| `text-2` | `#b0b8cc` | `#475569` | 보조 텍스트 |
|
||||
| `text-3` | `#8690a6` | `#94a3b8` | 비활성, 플레이스홀더 |
|
||||
| `text-2` | `#c0c8dc` | `#475569` | 보조 텍스트 |
|
||||
| `text-3` | `#9ba3b8` | `#94a3b8` | 비활성, 플레이스홀더 |
|
||||
|
||||
> **TODO: 텍스트 토큰 시맨틱 리네이밍**
|
||||
>
|
||||
> Tailwind config의 `colors.text` 키를 `colors.color`로 변경하여 클래스명을 직관적으로 개선한다.
|
||||
>
|
||||
> | 현재 | 변경 후 | 용도 |
|
||||
> |------|---------|------|
|
||||
> | `text-text-1` | `text-color` (DEFAULT) | 기본 텍스트 |
|
||||
> | `text-text-2` | `text-color-sub` | 보조 텍스트 |
|
||||
> | `text-text-3` | `text-color-disabled` | 비활성, 플레이스홀더 |
|
||||
>
|
||||
> - 영향 범위: 96개 파일, 2,685회 참조
|
||||
> - CSS 변수(`--t1/2/3`)도 `--color-default/sub/disabled`로 동기화
|
||||
> - 기계적 find-and-replace로 처리 가능
|
||||
|
||||
**Background**
|
||||
|
||||
@ -132,11 +126,11 @@ UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단
|
||||
|
||||
| 이름 | className | Font Stack | 용도 |
|
||||
|------|-----------|------------|------|
|
||||
| Noto Sans KR | `font-korean` | `'Noto Sans KR', sans-serif` | 기본 UI 텍스트, 한국어 콘텐츠 전반 |
|
||||
| JetBrains Mono | `font-mono` | `'JetBrains Mono', monospace` | 좌표, 수치, 코드, 토큰 이름 |
|
||||
| Outfit | `font-sans` | `'Outfit', 'Noto Sans KR', sans-serif` | 영문 헤딩, 브랜드 타이틀 |
|
||||
| PretendardGOV | `font-korean` | `'PretendardGOV', sans-serif` | 기본 UI 텍스트, 한국어/영문 콘텐츠 전반 |
|
||||
| PretendardGOV | `font-mono` | `'PretendardGOV', sans-serif` | 좌표, 수치, 데이터 값 |
|
||||
| PretendardGOV | `font-sans` | `'PretendardGOV', sans-serif` | body 기본 폰트 |
|
||||
|
||||
> Body 기본 스택: `font-family: 'Outfit', 'Noto Sans KR', sans-serif`
|
||||
> Body 기본 스택: `font-family: 'PretendardGOV', sans-serif`
|
||||
|
||||
#### Typography Tokens (`.wing-*` 클래스)
|
||||
|
||||
|
||||
@ -4,6 +4,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 다크/라이트 테마 전환 기능 (TopBar 퀵메뉴에서 토글)
|
||||
- themeStore (Zustand) 테마 상태 관리 + localStorage 영속화
|
||||
|
||||
### 변경
|
||||
- 디자인 시스템 토큰 시맨틱 네이밍 전환 (하드코딩 색상 → CSS 변수)
|
||||
- PretendardGOV 폰트 적용
|
||||
- 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응
|
||||
|
||||
## [2026-03-30]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -8,6 +8,12 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<title>frontend</title>
|
||||
<script>
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
localStorage.getItem('wing-theme') || 'dark'
|
||||
);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
frontend/public/fonts/PretendardGOV-Black.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Black.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Bold.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Bold.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-ExtraBold.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-ExtraBold.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-ExtraLight.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-ExtraLight.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Light.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Light.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Medium.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Medium.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Regular.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Regular.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-SemiBold.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-SemiBold.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Thin.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Thin.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOVVariable.ttf
Normal file
BIN
frontend/public/fonts/PretendardGOVVariable.ttf
Normal file
Binary file not shown.
@ -61,9 +61,9 @@ function App() {
|
||||
<div style={{
|
||||
width: '100vw', height: '100vh', display: 'flex',
|
||||
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: '#001028', gap: 16,
|
||||
background: 'var(--bg-base)', gap: 16,
|
||||
}}>
|
||||
<img src="/wing_logo_text_white.svg" alt="WING" style={{ height: 28, opacity: 0.8 }} />
|
||||
<img src="/wing_logo_text_white.svg" alt="WING" className="wing-logo" style={{ height: 28, opacity: 0.8 }} />
|
||||
<div style={{
|
||||
width: 32, height: 32, border: '3px solid rgba(6,182,212,0.2)',
|
||||
borderTop: '3px solid rgba(6,182,212,0.8)', borderRadius: '50%',
|
||||
@ -105,7 +105,7 @@ function App() {
|
||||
case 'monitor':
|
||||
return null
|
||||
default:
|
||||
return <div className="flex items-center justify-center h-full text-text-3">준비 중입니다...</div>
|
||||
return <div className="flex items-center justify-center h-full text-fg-disabled">준비 중입니다...</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex overflow-hidden relative bg-[#001028]">
|
||||
<div className="w-screen h-screen flex overflow-hidden relative bg-bg-base">
|
||||
{/* Background image */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
@ -82,7 +82,7 @@ export function LoginPage() {
|
||||
<img
|
||||
src="/wing_logo_text_white.svg"
|
||||
alt="WING 해양환경 위기대응 통합시스템"
|
||||
className="h-7 mx-auto block"
|
||||
className="h-7 mx-auto block wing-logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -98,11 +98,11 @@ export function LoginPage() {
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* User ID */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1.5" style={{ letterSpacing: '0.3px' }}>
|
||||
<label className="block text-[10px] font-semibold text-fg-disabled mb-1.5" style={{ letterSpacing: '0.3px' }}>
|
||||
아이디
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute text-sm text-text-3 pointer-events-none" style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<span className="absolute text-sm text-fg-disabled pointer-events-none" style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</span>
|
||||
<input
|
||||
@ -112,7 +112,7 @@ export function LoginPage() {
|
||||
placeholder="사용자 아이디 입력"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="w-full bg-bg-2 border border-border rounded-md text-[13px] outline-none"
|
||||
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
|
||||
style={{
|
||||
padding: '11px 14px 11px 38px',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
@ -122,7 +122,7 @@ export function LoginPage() {
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--bd)'
|
||||
e.currentTarget.style.borderColor = 'var(--stroke-default)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
@ -131,11 +131,11 @@ export function LoginPage() {
|
||||
|
||||
{/* Password */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1.5" style={{ letterSpacing: '0.3px' }}>
|
||||
<label className="block text-[10px] font-semibold text-fg-disabled mb-1.5" style={{ letterSpacing: '0.3px' }}>
|
||||
비밀번호
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute text-sm text-text-3 pointer-events-none" style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<span className="absolute text-sm text-fg-disabled pointer-events-none" style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</span>
|
||||
<input
|
||||
@ -144,7 +144,7 @@ export function LoginPage() {
|
||||
onChange={(e) => { setPassword(e.target.value); clearError() }}
|
||||
placeholder="비밀번호 입력"
|
||||
autoComplete="current-password"
|
||||
className="w-full bg-bg-2 border border-border rounded-md text-[13px] outline-none"
|
||||
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
|
||||
style={{
|
||||
padding: '11px 14px 11px 38px',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
@ -154,7 +154,7 @@ export function LoginPage() {
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--bd)'
|
||||
e.currentTarget.style.borderColor = 'var(--stroke-default)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
@ -163,16 +163,16 @@ export function LoginPage() {
|
||||
|
||||
{/* Remember + Forgot */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-text-3 cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-disabled cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="accent-[var(--cyan)]"
|
||||
className="accent-[var(--color-accent)]"
|
||||
/>
|
||||
아이디 저장
|
||||
</label>
|
||||
<button type="button" className="text-[11px] text-primary-cyan cursor-pointer bg-transparent border-none"
|
||||
<button type="button" className="text-[11px] text-color-accent cursor-pointer bg-transparent border-none"
|
||||
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||
>
|
||||
@ -209,7 +209,7 @@ export function LoginPage() {
|
||||
)}
|
||||
|
||||
{/* Login button */}
|
||||
<button type="submit" disabled={isLoading} className="w-full text-primary-cyan text-sm font-bold rounded-md border"
|
||||
<button type="submit" disabled={isLoading} className="w-full text-color-accent text-sm font-bold rounded-md border"
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: isLoading
|
||||
@ -237,7 +237,7 @@ export function LoginPage() {
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span style={{
|
||||
width: 14, height: 14, border: '2px solid rgba(6,182,212,0.3)',
|
||||
borderTop: '2px solid var(--cyan)', borderRadius: '50%',
|
||||
borderTop: '2px solid var(--color-accent)', borderRadius: '50%',
|
||||
animation: 'loginSpin 0.8s linear infinite', display: 'inline-block',
|
||||
}} />
|
||||
인증 중...
|
||||
@ -249,7 +249,7 @@ export function LoginPage() {
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-6">
|
||||
<div className="flex-1 bg-border h-px" />
|
||||
<span className="text-[9px] text-text-3">또는</span>
|
||||
<span className="text-[9px] text-fg-disabled">또는</span>
|
||||
<div className="flex-1 bg-border h-px" />
|
||||
</div>
|
||||
|
||||
@ -267,10 +267,10 @@ export function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="w-full rounded-md bg-bg-3 border border-border text-text-2 text-[11px] font-semibold cursor-pointer flex items-center justify-center gap-1.5 px-[10px] py-[10px]"
|
||||
<button type="button" className="w-full rounded-md bg-bg-card border border-stroke text-fg-sub text-[11px] font-semibold cursor-pointer flex items-center justify-center gap-1.5 px-[10px] py-[10px]"
|
||||
style={{ transition: 'background 0.15s' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bgH)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg3)'}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-surface-hover)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
공무원 인증서
|
||||
@ -283,7 +283,7 @@ export function LoginPage() {
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.08)',
|
||||
}}>
|
||||
<div className="text-[9px] font-bold text-primary-cyan mb-1.5">
|
||||
<div className="text-[9px] font-bold text-color-accent mb-1.5">
|
||||
데모 계정
|
||||
</div>
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
@ -298,10 +298,10 @@ export function LoginPage() {
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(6,182,212,0.06)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<span className="text-[9px] text-text-2 font-mono">
|
||||
<span className="text-[9px] text-fg-sub font-mono">
|
||||
{acc.id} / {acc.password}
|
||||
</span>
|
||||
<span className="text-[8px] text-text-3">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
{acc.label}
|
||||
</span>
|
||||
</div>
|
||||
@ -312,7 +312,7 @@ export function LoginPage() {
|
||||
</div>{/* end form card */}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-[9px] text-text-3 mt-6 leading-[1.6]">
|
||||
<div className="text-center text-[9px] text-fg-disabled mt-6 leading-[1.6]">
|
||||
<div>WING V2.0 | 해양경찰청 기동방제과 위기대응 통합시스템</div>
|
||||
<div className="mt-0.5" style={{ color: 'rgba(134,144,166,0.6)' }}>
|
||||
© 2026 Korea Coast Guard. All rights reserved.
|
||||
|
||||
@ -26,8 +26,8 @@ export function LayerTree({ layers, enabledLayers, onToggleLayer, layerColors =
|
||||
|
||||
return (
|
||||
<div className="px-1">
|
||||
<div className="flex items-center justify-between px-2 pt-1 pb-2 mb-1 border-b border-border">
|
||||
<span className="text-[10px] font-semibold text-text-3">
|
||||
<div className="flex items-center justify-between px-2 pt-1 pb-2 mb-1 border-b border-stroke">
|
||||
<span className="text-[10px] font-semibold text-fg-disabled">
|
||||
전체 레이어
|
||||
</span>
|
||||
<div
|
||||
|
||||
@ -11,7 +11,7 @@ interface MainLayoutProps {
|
||||
|
||||
export function MainLayout({ children, activeMainTab, onMainTabChange }: MainLayoutProps) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-bg-0 text-text-1 overflow-hidden">
|
||||
<div className="h-screen w-screen flex flex-col bg-bg-base text-fg overflow-hidden">
|
||||
{/* Top Navigation - Level 1 */}
|
||||
<TopBar activeTab={activeMainTab} onTabChange={onMainTabChange} />
|
||||
|
||||
|
||||
@ -14,19 +14,19 @@ export function SubMenuBar({ activeMainTab }: SubMenuBarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border bg-bg-1 shrink-0">
|
||||
<div className="border-b border-stroke bg-bg-surface shrink-0">
|
||||
<div className="flex px-5">
|
||||
{subMenuConfig.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSubTab(item.id)}
|
||||
className={`
|
||||
px-4 py-2.5 text-[13px] font-bold transition-all duration-200
|
||||
px-4 py-2.5 text-[0.8125rem] font-bold transition-all duration-200
|
||||
font-korean tracking-tight
|
||||
${
|
||||
activeSubTab === item.id
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.12)] border-b-2 border-primary-cyan'
|
||||
: 'text-text-2 hover:text-text-1 hover:bg-[rgba(255,255,255,0.06)]'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.12)] border-b-2 border-color-accent'
|
||||
: 'text-fg-sub hover:text-fg hover:bg-[rgba(255,255,255,0.06)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@ -3,6 +3,7 @@ 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 UserManualPopup from '../ui/UserManualPopup'
|
||||
|
||||
interface TopBarProps {
|
||||
@ -17,6 +18,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
const { hasPermission, user, logout } = useAuthStore()
|
||||
const { menuConfig, isLoaded } = useMenuStore()
|
||||
const { mapToggles, toggleMap, mapTypes, measureMode, setMeasureMode } = useMapStore()
|
||||
const { theme, toggleTheme } = useThemeStore()
|
||||
|
||||
const MAP_TABS = new Set<string>(['prediction', 'hns', 'scat', 'incidents'])
|
||||
const isMapTab = MAP_TABS.has(activeTab)
|
||||
@ -44,7 +46,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
}, [showQuickMenu])
|
||||
|
||||
return (
|
||||
<div className="h-[52px] bg-bg-1 border-b border-border flex items-center justify-between px-5 relative z-[100]">
|
||||
<div className="h-[52px] bg-bg-surface border-b border-stroke flex items-center justify-between px-5 relative z-[100]">
|
||||
{/* Left Section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
@ -53,7 +55,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
className="flex items-center hover:opacity-80 transition-opacity cursor-pointer"
|
||||
title="홈으로 이동"
|
||||
>
|
||||
<img src="/wing_logo_white.svg" alt="WING 해양환경 위기대응" className="h-3.5" />
|
||||
<img src="/wing_logo_white.svg" alt="WING 해양환경 위기대응" className="h-3.5 wing-logo" />
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
@ -77,34 +79,34 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
onClick={handleClick}
|
||||
title={tab.label}
|
||||
className={`
|
||||
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
|
||||
px-2.5 xl:px-4 py-2 rounded-sm text-[0.8125rem] transition-all duration-200
|
||||
font-korean tracking-[0.2px]
|
||||
${isIncident ? 'font-extrabold border-l border-l-[rgba(99,102,241,0.2)] ml-1' : 'font-semibold'}
|
||||
${isMonitor ? 'border-l border-l-[rgba(239,68,68,0.25)] ml-1 flex items-center gap-1.5' : ''}
|
||||
${
|
||||
isMonitor
|
||||
? 'text-[#f87171] hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]'
|
||||
? 'text-color-danger hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]'
|
||||
: activeTab === tab.id
|
||||
? isIncident
|
||||
? 'text-[#a5b4fc] bg-[rgba(99,102,241,0.18)] shadow-[0_0_8px_rgba(99,102,241,0.3)]'
|
||||
: 'text-[#22d3ee] bg-[rgba(6,182,212,0.15)] shadow-[0_0_8px_rgba(6,182,212,0.3)]'
|
||||
: 'text-color-accent bg-[rgba(6,182,212,0.15)] shadow-[0_0_8px_rgba(6,182,212,0.3)]'
|
||||
: isIncident
|
||||
? 'text-[#818cf8] hover:text-[#a5b4fc] hover:bg-[rgba(99,102,241,0.1)]'
|
||||
: 'text-[#c8d6e5] hover:text-white hover:bg-[rgba(255,255,255,0.08)]'
|
||||
: 'text-fg-sub hover:text-fg hover:bg-[var(--hover-overlay)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isMonitor ? (
|
||||
<>
|
||||
<span className="hidden xl:flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#f87171] animate-pulse inline-block" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-color-danger animate-pulse inline-block" />
|
||||
{tab.label}
|
||||
</span>
|
||||
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
|
||||
<span className="xl:hidden text-[1rem] leading-none">{tab.icon}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
|
||||
<span className="xl:hidden text-[1rem] leading-none">{tab.icon}</span>
|
||||
<span className="hidden xl:inline">{tab.label}</span>
|
||||
</>
|
||||
)}
|
||||
@ -117,13 +119,13 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.2)] rounded-sm text-xs font-medium text-status-red animate-pulse">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
|
||||
{/* <div className="flex items-center gap-2 px-3 py-1.5 bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.2)] rounded-sm text-xs font-medium text-color-danger animate-pulse">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-color-danger animate-pulse" />
|
||||
사고 진행중
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Icon Buttons */}
|
||||
<button className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all">
|
||||
<button className="w-9 h-9 rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all">
|
||||
🔔
|
||||
</button>
|
||||
{hasPermission('admin') && (
|
||||
@ -131,19 +133,19 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
onClick={() => onTabChange('admin')}
|
||||
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
|
||||
activeTab === 'admin'
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
|
||||
? 'border-color-accent bg-[rgba(6,182,212,0.15)] text-color-accent'
|
||||
: 'border-stroke bg-bg-card text-fg-sub hover:bg-bg-surface-hover hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
)}
|
||||
{user && (
|
||||
<div className="flex items-center gap-2 pl-2 border-l border-border">
|
||||
<span className="text-[11px] text-text-2 font-korean">{user.name}</span>
|
||||
<div className="flex items-center gap-2 pl-2 border-l border-stroke">
|
||||
<span className="text-[0.6875rem] text-fg-sub font-korean">{user.name}</span>
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-bg-hover hover:text-text-1 transition-all font-korean"
|
||||
className="px-2 py-1 text-[0.625rem] font-semibold text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover hover:text-fg transition-all font-korean"
|
||||
title="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
@ -157,79 +159,99 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
onClick={() => setShowQuickMenu(!showQuickMenu)}
|
||||
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
|
||||
showQuickMenu
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
|
||||
? 'border-color-accent bg-[rgba(6,182,212,0.15)] text-color-accent'
|
||||
: 'border-stroke bg-bg-card text-fg-sub hover:bg-bg-surface-hover hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="2" y1="4" x2="14" y2="4" /><line x1="2" y1="8" x2="14" y2="8" /><line x1="2" y1="12" x2="14" y2="12" /></svg>
|
||||
</button>
|
||||
|
||||
{showQuickMenu && (
|
||||
<div className="absolute top-[44px] right-0 w-[220px] bg-[rgba(18,25,41,0.97)] backdrop-blur-xl border border-border rounded-lg shadow-2xl z-[200] py-2 font-korean">
|
||||
<div className="absolute top-[44px] right-0 w-[220px] bg-[var(--dropdown-bg)] backdrop-blur-xl border border-stroke rounded-lg shadow-2xl z-[200] py-2 font-korean">
|
||||
{/* 거리·면적 계산 */}
|
||||
{/* <div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
||||
{/* <div className="px-3 py-1.5 flex items-center gap-2 text-[0.6875rem] font-bold text-fg-disabled">
|
||||
<span>📐</span> 거리·면적 계산
|
||||
</div> */}
|
||||
<button
|
||||
onClick={() => handleToggleMeasure('distance')}
|
||||
disabled={!isMapTab}
|
||||
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[12px] transition-all ${
|
||||
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] transition-all ${
|
||||
!isMapTab
|
||||
? 'text-text-3 opacity-40 cursor-not-allowed'
|
||||
? 'text-fg-disabled opacity-40 cursor-not-allowed'
|
||||
: measureMode === 'distance'
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.1)]'
|
||||
: 'text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.1)]'
|
||||
: 'text-fg hover:bg-[var(--hover-overlay)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[13px]">↗</span> 거리 재기
|
||||
{measureMode === 'distance' && <span className="ml-auto text-[10px] text-primary-cyan">활성</span>}
|
||||
<span className="text-[0.8125rem]">↗</span> 거리 재기
|
||||
{measureMode === 'distance' && <span className="ml-auto text-[0.625rem] text-color-accent">활성</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleMeasure('area')}
|
||||
disabled={!isMapTab}
|
||||
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[12px] transition-all ${
|
||||
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] transition-all ${
|
||||
!isMapTab
|
||||
? 'text-text-3 opacity-40 cursor-not-allowed'
|
||||
? 'text-fg-disabled opacity-40 cursor-not-allowed'
|
||||
: measureMode === 'area'
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.1)]'
|
||||
: 'text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.1)]'
|
||||
: 'text-fg hover:bg-[var(--hover-overlay)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[13px]">⭕</span> 면적 재기
|
||||
{measureMode === 'area' && <span className="ml-auto text-[10px] text-primary-cyan">활성</span>}
|
||||
<span className="text-[0.8125rem]">⭕</span> 면적 재기
|
||||
{measureMode === 'area' && <span className="ml-auto text-[0.625rem] text-color-accent">활성</span>}
|
||||
</button>
|
||||
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<div className="my-1.5 border-t border-stroke" />
|
||||
|
||||
{/* 출력 */}
|
||||
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
||||
<div className="px-3 py-1.5 flex items-center gap-2 text-[0.6875rem] font-bold text-fg-disabled">
|
||||
<span>🖨</span> 출력
|
||||
</div>
|
||||
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all">
|
||||
<span className="text-[13px]">📸</span> 화면 캡쳐 다운로드
|
||||
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] text-fg hover:bg-[var(--hover-overlay)] transition-all">
|
||||
<span className="text-[0.8125rem]">📸</span> 화면 캡쳐 다운로드
|
||||
</button>
|
||||
<button onClick={() => window.print()} className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all">
|
||||
<span className="text-[13px]">🖨</span> 인쇄
|
||||
<button onClick={() => window.print()} className="w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] text-fg hover:bg-[var(--hover-overlay)] transition-all">
|
||||
<span className="text-[0.8125rem]">🖨</span> 인쇄
|
||||
</button>
|
||||
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<div className="my-1.5 border-t border-stroke" />
|
||||
|
||||
{/* 지도 유형 */}
|
||||
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
||||
<div className="px-3 py-1.5 flex items-center gap-2 text-[0.6875rem] font-bold text-fg-disabled">
|
||||
<span>🗺</span> 지도 유형
|
||||
</div>
|
||||
{mapTypes.map(item => (
|
||||
<button key={item.mapKey} onClick={() => toggleMap(item.mapKey)} className="w-full px-3 py-2 flex items-center justify-between text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] transition-all">
|
||||
<button key={item.mapKey} onClick={() => toggleMap(item.mapKey)} className="w-full px-3 py-2 flex items-center justify-between text-[0.75rem] text-fg-sub hover:bg-[var(--hover-overlay)] transition-all">
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="text-[13px]">🗺</span> {item.mapNm}
|
||||
<span className="text-[0.8125rem]">🗺</span> {item.mapNm}
|
||||
</span>
|
||||
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}>
|
||||
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}>
|
||||
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey] ? 'left-[16px]' : 'left-[2px]'}`} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<div className="my-1.5 border-t border-stroke" />
|
||||
|
||||
{/* 테마 전환 */}
|
||||
<button
|
||||
onClick={() => { toggleTheme(); setShowQuickMenu(false); }}
|
||||
className="w-full px-3 py-2 flex items-center justify-between text-[0.75rem] text-fg hover:bg-[var(--hover-overlay)] transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="text-[0.8125rem]">{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}</span>
|
||||
{theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||
</span>
|
||||
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${
|
||||
theme === 'light' ? 'bg-color-accent' : 'bg-bg-card border border-stroke'
|
||||
}`}>
|
||||
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${
|
||||
theme === 'light' ? 'left-[16px]' : 'left-[2px]'
|
||||
}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="my-1.5 border-t border-stroke" />
|
||||
|
||||
{/* 매뉴얼 */}
|
||||
<button
|
||||
@ -237,9 +259,9 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
setShowManual(true)
|
||||
setShowQuickMenu(false)
|
||||
}}
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all"
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] text-fg hover:bg-[var(--hover-overlay)] transition-all"
|
||||
>
|
||||
<span className="text-[13px]">📖</span> 사용자 매뉴얼
|
||||
<span className="text-[0.8125rem]">📖</span> 사용자 매뉴얼
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -125,7 +125,7 @@ export function BacktrackReplayBar({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full bg-primary-purple"
|
||||
className="w-2 h-2 rounded-full bg-color-tertiary"
|
||||
style={{ boxShadow: '0 0 8px rgba(168,85,247,0.5)' }}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
@ -150,7 +150,7 @@ export function BacktrackReplayBar({
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-status-red cursor-pointer font-bold"
|
||||
className="text-color-danger cursor-pointer font-bold"
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '6px', fontSize: '10px',
|
||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
@ -168,9 +168,9 @@ export function BacktrackReplayBar({
|
||||
onClick={handlePlayClick}
|
||||
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
|
||||
style={{
|
||||
background: isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.15)',
|
||||
border: `2px solid ${isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.4)'}`,
|
||||
color: isPlaying ? '#fff' : 'var(--purple)',
|
||||
background: isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.15)',
|
||||
border: `2px solid ${isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.4)'}`,
|
||||
color: isPlaying ? '#fff' : 'var(--color-tertiary)',
|
||||
}}
|
||||
>
|
||||
{isPlaying ? '⏸' : isFinished ? '↺' : '▶'}
|
||||
@ -193,7 +193,7 @@ export function BacktrackReplayBar({
|
||||
className="absolute top-0 left-0 h-full"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
background: 'linear-gradient(90deg, var(--purple), var(--cyan))',
|
||||
background: 'linear-gradient(90deg, var(--color-tertiary), var(--color-accent))',
|
||||
borderRadius: '2px', transition: 'width 0.05s',
|
||||
}}
|
||||
/>
|
||||
@ -220,7 +220,7 @@ export function BacktrackReplayBar({
|
||||
style={{
|
||||
left: `${progress}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
border: '3px solid var(--purple)',
|
||||
border: '3px solid var(--color-tertiary)',
|
||||
boxShadow: '0 0 8px rgba(168,85,247,0.4)',
|
||||
zIndex: 2, transition: 'left 0.05s',
|
||||
}}
|
||||
@ -229,19 +229,19 @@ export function BacktrackReplayBar({
|
||||
|
||||
{/* Time labels */}
|
||||
<div className="flex justify-between text-[9px] font-mono">
|
||||
<span className="text-text-3">{startLabel}</span>
|
||||
<span className="font-semibold text-primary-purple">{currentTimeLabel}</span>
|
||||
<span className="text-text-3">{endLabel}</span>
|
||||
<span className="text-fg-disabled">{startLabel}</span>
|
||||
<span className="font-semibold text-color-tertiary">{currentTimeLabel}</span>
|
||||
<span className="text-fg-disabled">{endLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend row */}
|
||||
<div className="flex items-center gap-[14px] pt-1 border-t border-border flex-wrap">
|
||||
<div className="flex items-center gap-[14px] pt-1 border-t border-stroke flex-wrap">
|
||||
{replayShips.map((ship) => (
|
||||
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
||||
<span className="text-[9px] text-text-2 font-mono">
|
||||
<span className="text-[9px] text-fg-sub font-mono">
|
||||
{ship.vesselName}
|
||||
</span>
|
||||
</div>
|
||||
@ -249,7 +249,7 @@ export function BacktrackReplayBar({
|
||||
{hasBackwardParticles && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: '#a855f7', opacity: 0.8 }} />
|
||||
<span className="text-[9px] text-text-2 font-mono">역방향 예측</span>
|
||||
<span className="text-[9px] text-fg-sub font-mono">역방향 예측</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1063,7 +1063,7 @@ export function MapView({
|
||||
latitude: info.coordinate[1],
|
||||
content: (
|
||||
<div className="text-xs leading-relaxed" style={{ minWidth: 180 }}>
|
||||
<strong className="text-status-orange">{dispersionResult.substance} 대기확산 면적</strong>
|
||||
<strong className="text-color-warning">{dispersionResult.substance} 대기확산 면적</strong>
|
||||
<table style={{ width: '100%', marginTop: 4, borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{zoneAreas.map(z => (
|
||||
@ -1405,7 +1405,7 @@ export function MapView({
|
||||
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
|
||||
<div
|
||||
title={`사고 지점\n${incidentCoord.lat.toFixed(4)}°N, ${incidentCoord.lon.toFixed(4)}°E`}
|
||||
className="w-6 h-6 bg-primary-cyan border-2 border-white"
|
||||
className="w-6 h-6 bg-color-accent border-2 border-white"
|
||||
style={{
|
||||
borderRadius: '50% 50% 50% 0',
|
||||
transform: 'rotate(-45deg)',
|
||||
@ -1510,19 +1510,19 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => map?.zoomIn()}
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.zoomOut()}
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-[10px]"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:text-fg transition-all text-[10px]"
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
@ -1545,49 +1545,49 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], select
|
||||
|
||||
if (dispersionResult && incidentCoord) {
|
||||
return (
|
||||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg min-w-[200px] z-[20]">
|
||||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-stroke rounded-lg min-w-[200px] z-[20]">
|
||||
{/* 헤더 + 최소화 버튼 */}
|
||||
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
|
||||
<span className="text-[10px] font-bold text-text-3 uppercase tracking-wider">범례</span>
|
||||
<span className="text-[10px] text-text-3 hover:text-text-1 transition-colors">{minimized ? '▶' : '▼'}</span>
|
||||
<span className="text-[10px] font-bold text-fg-disabled uppercase tracking-wider">범례</span>
|
||||
<span className="text-[10px] text-fg-disabled hover:text-fg transition-colors">{minimized ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
{!minimized && (
|
||||
<div className="px-3.5 pb-3.5">
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<div className="text-base">📍</div>
|
||||
<div>
|
||||
<h4 className="text-[11px] font-bold text-primary-orange">사고 위치</h4>
|
||||
<div className="text-[8px] text-text-3 font-mono">
|
||||
<h4 className="text-[11px] font-bold text-color-warning">사고 위치</h4>
|
||||
<div className="text-[8px] text-fg-disabled font-mono">
|
||||
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-2 mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
|
||||
<div className="text-[9px] text-fg-sub mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
|
||||
<div className="flex justify-between mb-[3px]">
|
||||
<span className="text-text-3">물질</span>
|
||||
<span className="font-semibold text-status-orange">{dispersionResult.substance}</span>
|
||||
<span className="text-fg-disabled">물질</span>
|
||||
<span className="font-semibold text-color-warning">{dispersionResult.substance}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-[3px]">
|
||||
<span className="text-text-3">풍향</span>
|
||||
<span className="text-fg-disabled">풍향</span>
|
||||
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">확산 구역</span>
|
||||
<span className="font-semibold text-primary-cyan">{dispersionResult.zones.length}개</span>
|
||||
<span className="text-fg-disabled">확산 구역</span>
|
||||
<span className="font-semibold text-color-accent">{dispersionResult.zones.length}개</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-[9px] font-bold text-text-3 mb-2">위험 구역</h5>
|
||||
<h5 className="text-[9px] font-bold text-fg-disabled mb-2">위험 구역</h5>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(239,68,68,0.7)' }} />
|
||||
<span>치명적 위험 구역 (AEGL-3)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(249,115,22,0.7)' }} />
|
||||
<span>높은 위험 구역 (AEGL-2)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
|
||||
<span>중간 위험 구역 (AEGL-1)</span>
|
||||
</div>
|
||||
@ -1595,7 +1595,7 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], select
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-2 rounded" style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}>
|
||||
<div className="text-xs">🧭</div>
|
||||
<span className="text-[9px] text-text-3">풍향 (방사형)</span>
|
||||
<span className="text-[9px] text-fg-disabled">풍향 (방사형)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -1605,34 +1605,34 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], select
|
||||
|
||||
if (oilTrajectory.length > 0) {
|
||||
return (
|
||||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md z-[20]" style={{ minWidth: 155 }}>
|
||||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-md z-[20]" style={{ minWidth: 155 }}>
|
||||
{/* 헤더 + 접기/펼치기 */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 cursor-pointer select-none"
|
||||
onClick={() => setMinimized(!minimized)}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-text-2 font-korean">범례</span>
|
||||
<span className="text-[9px] text-text-3 hover:text-text-1 transition-colors ml-3">{minimized ? '▶' : '▼'}</span>
|
||||
<span className="text-[10px] font-bold text-fg-sub font-korean">범례</span>
|
||||
<span className="text-[9px] text-fg-disabled hover:text-fg transition-colors ml-3">{minimized ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{!minimized && (
|
||||
<div className="px-3 pb-2.5 flex flex-col gap-[5px]">
|
||||
{/* 모델별 색상 */}
|
||||
{Array.from(selectedModels).map(model => (
|
||||
<div key={model} className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div key={model} className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-[14px] h-[3px] rounded-sm" style={{ background: MODEL_COLORS[model] }} />
|
||||
<span className="font-korean">{model}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 앙상블 */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-[14px] h-[3px] rounded-sm" style={{ background: '#a855f7' }} />
|
||||
<span className="font-korean">앙상블</span>
|
||||
</div>
|
||||
|
||||
{/* 오일펜스 라인 */}
|
||||
<div className="h-px bg-border my-0.5" />
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="flex gap-px">
|
||||
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
|
||||
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
|
||||
@ -1643,19 +1643,19 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], select
|
||||
|
||||
{/* 도달시간별 선종 */}
|
||||
<div className="h-px bg-border my-0.5" />
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-[14px] h-[3px] rounded-sm bg-[#ef4444]" />
|
||||
<span className="font-korean">위험 (<6h)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-[14px] h-[3px] rounded-sm bg-[#f97316]" />
|
||||
<span className="font-korean">경고 (6~12h)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-[14px] h-[3px] rounded-sm bg-[#eab308]" />
|
||||
<span className="font-korean">주의 (12~24h)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||||
<div className="w-[14px] h-[3px] rounded-sm bg-[#22c55e]" />
|
||||
<span className="font-korean">안전</span>
|
||||
</div>
|
||||
@ -1830,18 +1830,18 @@ function BacktrackReplayBar({ replayFrame, totalFrames, ships }: { replayFrame:
|
||||
style={{
|
||||
bottom: 80, left: '50%', transform: 'translateX(-50%)',
|
||||
background: 'rgba(10,14,26,0.92)', backdropFilter: 'blur(12px)',
|
||||
border: '1px solid var(--bdL)', borderRadius: '10px',
|
||||
border: '1px solid var(--stroke-light)', borderRadius: '10px',
|
||||
padding: '12px 18px', zIndex: 50,
|
||||
minWidth: '340px',
|
||||
}}
|
||||
>
|
||||
<div className="text-sm text-primary-purple font-mono font-bold">
|
||||
<div className="text-sm text-color-tertiary font-mono font-bold">
|
||||
{progress.toFixed(0)}%
|
||||
</div>
|
||||
<div className="flex-1 h-1 bg-border relative rounded-[2px]">
|
||||
<div
|
||||
className="h-full rounded-[2px]"
|
||||
style={{ width: `${progress}%`, background: 'linear-gradient(90deg, var(--purple), var(--cyan))', transition: 'width 0.05s' }}
|
||||
style={{ width: `${progress}%`, background: 'linear-gradient(90deg, var(--color-tertiary), var(--color-accent))', transition: 'width 0.05s' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@ -39,7 +39,7 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
|
||||
>
|
||||
<span>{displayText}</span>
|
||||
<span
|
||||
className="text-[8px] text-text-3"
|
||||
className="text-[8px] text-fg-disabled"
|
||||
style={{
|
||||
transition: 'transform 0.2s',
|
||||
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)'
|
||||
@ -51,10 +51,10 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute left-0 right-0 bg-bg-0 border border-border overflow-y-auto z-[1000]"
|
||||
className="absolute left-0 right-0 bg-bg-base border border-stroke overflow-y-auto z-[1000]"
|
||||
style={{
|
||||
top: 'calc(100% + 2px)',
|
||||
borderRadius: 'var(--rS)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
maxHeight: '200px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
animation: 'fadeSlideDown 0.15s ease-out'
|
||||
@ -70,10 +70,10 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
|
||||
className="text-[11px] cursor-pointer"
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
color: option.value === String(value) ? 'var(--cyan)' : 'var(--t2)',
|
||||
color: option.value === String(value) ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||||
background: option.value === String(value) ? 'rgba(6,182,212,0.1)' : 'transparent',
|
||||
transition: '0.1s',
|
||||
borderLeft: option.value === String(value) ? '2px solid var(--cyan)' : '2px solid transparent'
|
||||
borderLeft: option.value === String(value) ? '2px solid var(--color-accent)' : '2px solid transparent'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (option.value !== String(value)) {
|
||||
|
||||
26
frontend/src/common/store/themeStore.ts
Normal file
26
frontend/src/common/store/themeStore.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
interface ThemeState {
|
||||
theme: ThemeMode;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||||
theme: (localStorage.getItem('wing-theme') as ThemeMode) || 'dark',
|
||||
|
||||
toggleTheme: () => {
|
||||
const next = get().theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('wing-theme', next);
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
set({ theme: next });
|
||||
},
|
||||
|
||||
setTheme: (mode) => {
|
||||
localStorage.setItem('wing-theme', mode);
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
set({ theme: mode });
|
||||
},
|
||||
}));
|
||||
@ -1,28 +1,65 @@
|
||||
/* ── PretendardGOV @font-face ── */
|
||||
@font-face { font-family: 'PretendardGOV'; font-weight: 400; font-style: normal; font-display: swap; src: url('/fonts/PretendardGOV-Regular.otf') format('opentype'); }
|
||||
@font-face { font-family: 'PretendardGOV'; font-weight: 500; font-style: normal; font-display: swap; src: url('/fonts/PretendardGOV-Medium.otf') format('opentype'); }
|
||||
@font-face { font-family: 'PretendardGOV'; font-weight: 600; font-style: normal; font-display: swap; src: url('/fonts/PretendardGOV-SemiBold.otf') format('opentype'); }
|
||||
@font-face { font-family: 'PretendardGOV'; font-weight: 700; font-style: normal; font-display: swap; src: url('/fonts/PretendardGOV-Bold.otf') format('opentype'); }
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg0: #0a0e1a;
|
||||
--bg1: #0f1524;
|
||||
--bg2: #121929;
|
||||
--bg3: #1a2236;
|
||||
--bgH: #1e2844;
|
||||
--bd: #1e2a42;
|
||||
--bdL: #2a3a5c;
|
||||
--t1: #edf0f7;
|
||||
--t2: #b0b8cc;
|
||||
--t3: #8690a6;
|
||||
--blue: #3b82f6;
|
||||
--cyan: #06b6d4;
|
||||
--red: #ef4444;
|
||||
--orange: #f97316;
|
||||
--yellow: #eab308;
|
||||
--green: #22c55e;
|
||||
--purple: #a855f7;
|
||||
--boom: #f59e0b;
|
||||
--boomH: #fbbf24;
|
||||
--fK: Noto Sans KR, sans-serif;
|
||||
--fM: JetBrains Mono, monospace;
|
||||
--rS: 6px;
|
||||
--rM: 8px;
|
||||
/* bg — Background */
|
||||
--bg-base: #0a0e1a;
|
||||
--bg-surface: #0f1524;
|
||||
--bg-elevated: #121929;
|
||||
--bg-card: #1a2236;
|
||||
--bg-surface-hover: #1e2844;
|
||||
/* stroke — Border */
|
||||
--stroke-default: #1e2a42;
|
||||
--stroke-light: #2a3a5c;
|
||||
/* fg — Foreground */
|
||||
--fg-default: #edf0f7;
|
||||
--fg-sub: #c0c8dc;
|
||||
--fg-disabled: #9ba3b8;
|
||||
/* color — Palette */
|
||||
--color-info: #3b82f6;
|
||||
--color-accent: #06b6d4;
|
||||
--color-danger: #ef4444;
|
||||
--color-warning: #f97316;
|
||||
--color-caution: #eab308;
|
||||
--color-success: #22c55e;
|
||||
--color-tertiary: #a855f7;
|
||||
--color-boom: #f59e0b;
|
||||
--color-boom-hover: #fbbf24;
|
||||
/* font */
|
||||
--font-korean: 'PretendardGOV', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard, Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
|
||||
--font-mono: 'PretendardGOV', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard, Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
|
||||
/* radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
|
||||
/* typography — font-size */
|
||||
--font-size-display-1: 3.75rem;
|
||||
--font-size-display-2: 2.5rem;
|
||||
--font-size-display-3: 2.25rem;
|
||||
--font-size-heading-1: 2rem;
|
||||
--font-size-heading-2: 1.5rem;
|
||||
--font-size-heading-3: 1.375rem;
|
||||
--font-size-title-1: 1.125rem;
|
||||
--font-size-title-2: 1rem;
|
||||
--font-size-body-1: 0.875rem;
|
||||
--font-size-body-2: 0.8125rem;
|
||||
--font-size-label-1: 0.75rem;
|
||||
--font-size-label-2: 0.6875rem;
|
||||
--font-size-caption: 0.6875rem;
|
||||
/* typography — font-weight */
|
||||
--font-weight-thin: 300;
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 700;
|
||||
/* typography — line-height */
|
||||
--line-height-tight: 1.3;
|
||||
--line-height-snug: 1.4;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.6;
|
||||
|
||||
/* === Design Token System === */
|
||||
|
||||
@ -101,6 +138,25 @@
|
||||
--purple-800: #6b21a8;
|
||||
--purple-900: #581c87;
|
||||
--purple-1000: #3b0764;
|
||||
/* hover overlay */
|
||||
--hover-overlay: rgba(255, 255, 255, 0.06);
|
||||
--dropdown-bg: rgba(18, 25, 41, 0.97);
|
||||
}
|
||||
|
||||
/* ── Light theme overrides ── */
|
||||
[data-theme="light"] {
|
||||
--bg-base: #f8fafc;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #f1f5f9;
|
||||
--bg-card: #ffffff;
|
||||
--bg-surface-hover: #e2e8f0;
|
||||
--stroke-default: #cbd5e1;
|
||||
--stroke-light: #e2e8f0;
|
||||
--fg-default: #0f172a;
|
||||
--fg-sub: #475569;
|
||||
--fg-disabled: #94a3b8;
|
||||
--hover-overlay: rgba(0, 0, 0, 0.04);
|
||||
--dropdown-bg: rgba(255, 255, 255, 0.97);
|
||||
}
|
||||
|
||||
* {
|
||||
@ -112,12 +168,15 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', 'Noto Sans KR', sans-serif;
|
||||
background: var(--bg0);
|
||||
color: var(--t1);
|
||||
font-family: 'PretendardGOV', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard, Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
|
||||
background: var(--bg-base);
|
||||
color: var(--fg-default);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Date input calendar icon — white for dark theme */
|
||||
@ -129,4 +188,14 @@
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Light theme: calendar icon reset */
|
||||
[data-theme="light"] input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Light theme: invert white logos */
|
||||
[data-theme="light"] .wing-logo {
|
||||
filter: brightness(0) saturate(100%);
|
||||
}
|
||||
}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -10,34 +10,34 @@
|
||||
.wing-panel-scroll {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bdL) transparent;
|
||||
scrollbar-color: var(--stroke-light) transparent;
|
||||
}
|
||||
|
||||
.wing-header-bar {
|
||||
@apply flex items-center justify-between shrink-0 px-5 border-b border-border;
|
||||
@apply flex items-center justify-between shrink-0 px-5 border-b border-stroke;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.wing-sidebar {
|
||||
@apply flex flex-col border-r border-border;
|
||||
background: var(--bg1);
|
||||
@apply flex flex-col border-r border-stroke;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* ── Card / Section ── */
|
||||
.wing-card {
|
||||
@apply rounded-md p-4 border border-border;
|
||||
background: var(--bg3);
|
||||
@apply rounded-md p-4 border border-stroke;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.wing-card-sm {
|
||||
@apply rounded-sm p-3 border border-border;
|
||||
background: var(--bg3);
|
||||
@apply rounded-sm p-3 border border-stroke;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.wing-section {
|
||||
@apply rounded-md p-4 mb-3 border border-border;
|
||||
background: var(--bg3);
|
||||
@apply rounded-md p-4 mb-3 border border-stroke;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.wing-section-header {
|
||||
@ -46,7 +46,7 @@
|
||||
|
||||
.wing-section-desc {
|
||||
@apply text-[10px] font-korean leading-relaxed;
|
||||
color: var(--t3);
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
@ -56,7 +56,7 @@
|
||||
|
||||
.wing-subtitle {
|
||||
@apply text-[10px] font-korean mt-0.5;
|
||||
color: var(--t3);
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
.wing-label {
|
||||
@ -69,7 +69,7 @@
|
||||
|
||||
.wing-meta {
|
||||
@apply text-[9px] font-korean;
|
||||
color: var(--t3);
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
/* ── Icon Badge ── */
|
||||
@ -93,7 +93,7 @@
|
||||
}
|
||||
|
||||
.wing-btn-primary {
|
||||
background: linear-gradient(135deg, var(--cyan), var(--blue));
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-info));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@ -102,51 +102,51 @@
|
||||
}
|
||||
|
||||
.wing-btn-secondary {
|
||||
@apply border border-border;
|
||||
background: var(--bg3);
|
||||
color: var(--t2);
|
||||
@apply border border-stroke;
|
||||
background: var(--bg-card);
|
||||
color: var(--fg-sub);
|
||||
}
|
||||
|
||||
.wing-btn-secondary:hover {
|
||||
background: var(--bgH);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.wing-btn-outline {
|
||||
@apply bg-transparent border border-border;
|
||||
color: var(--t2);
|
||||
@apply bg-transparent border border-stroke;
|
||||
color: var(--fg-sub);
|
||||
}
|
||||
|
||||
.wing-btn-outline:hover {
|
||||
background: var(--bgH);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.wing-btn-pdf {
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
color: var(--blue);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.wing-btn-danger {
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--red);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Input ── */
|
||||
.wing-input {
|
||||
@apply w-full rounded-sm text-[11px] font-korean outline-none;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bd);
|
||||
color: var(--t1);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--stroke-default);
|
||||
color: var(--fg-default);
|
||||
}
|
||||
|
||||
.wing-input:focus {
|
||||
border-color: var(--cyan);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.wing-input::placeholder {
|
||||
color: var(--t3);
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
@ -158,44 +158,44 @@
|
||||
.wing-th {
|
||||
@apply text-left font-semibold;
|
||||
padding: 8px 10px;
|
||||
color: var(--t3);
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
color: var(--fg-disabled);
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--stroke-default);
|
||||
}
|
||||
|
||||
.wing-td {
|
||||
padding: 8px 10px;
|
||||
color: var(--t2);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
color: var(--fg-sub);
|
||||
border-bottom: 1px solid var(--stroke-default);
|
||||
}
|
||||
|
||||
.wing-tr-hover:hover {
|
||||
background: var(--bgH);
|
||||
background: var(--bg-surface-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Tab Bar ── */
|
||||
.wing-tab-bar {
|
||||
@apply flex gap-0.5 rounded-lg p-1 border border-border;
|
||||
background: var(--bg3);
|
||||
@apply flex gap-0.5 rounded-lg p-1 border border-stroke;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.wing-tab {
|
||||
@apply flex-1 py-2 px-1 text-xs font-semibold rounded-md text-center cursor-pointer font-korean;
|
||||
transition: all 0.15s;
|
||||
color: var(--t3);
|
||||
color: var(--fg-disabled);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.wing-tab:hover {
|
||||
color: var(--t2);
|
||||
color: var(--fg-sub);
|
||||
}
|
||||
|
||||
.wing-tab.active {
|
||||
border-color: rgba(6, 182, 212, 0.3);
|
||||
background: rgba(6, 182, 212, 0.08);
|
||||
color: var(--cyan);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
@ -208,13 +208,13 @@
|
||||
|
||||
.wing-modal {
|
||||
@apply rounded-xl overflow-hidden;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--stroke-default);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.wing-modal-header {
|
||||
@apply flex items-center justify-between px-5 border-b border-border;
|
||||
@apply flex items-center justify-between px-5 border-b border-stroke;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
@ -223,7 +223,7 @@
|
||||
.wing-divider {
|
||||
@apply w-full;
|
||||
height: 1px;
|
||||
background: var(--bd);
|
||||
background: var(--stroke-default);
|
||||
}
|
||||
|
||||
.wing-kv-row {
|
||||
@ -233,7 +233,7 @@
|
||||
|
||||
.wing-kv-label {
|
||||
@apply text-[10px] font-korean;
|
||||
color: var(--t3);
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
.wing-kv-value {
|
||||
|
||||
@ -441,7 +441,7 @@ interface ColorPaletteContentProps {
|
||||
export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
||||
const t = theme;
|
||||
const isDark = t.mode === 'dark';
|
||||
const [activeColorTab, setActiveColorTab] = useState<'usage' | 'token'>('usage');
|
||||
const [activeColorTab, setActiveColorTab] = useState<'rule' | 'definition' | 'token'>('rule');
|
||||
|
||||
const sectionCardBg = isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5';
|
||||
const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : '#e5e7eb';
|
||||
@ -477,8 +477,9 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
||||
|
||||
{/* 탭 전환 */}
|
||||
<div className="flex gap-1 mb-10" style={{ borderBottom: `1px solid ${dividerColor}` }}>
|
||||
{(['usage', 'token'] as const).map((tab) => {
|
||||
{(['rule', 'usage', 'token'] as const).map((tab) => {
|
||||
const isActive = activeColorTab === tab;
|
||||
const label = tab === 'rule' ? 'Rule' : tab === 'usage' ? 'Definition' : 'Token';
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
@ -489,12 +490,284 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
||||
borderBottom: isActive ? `2px solid ${t.textAccent}` : '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
{tab === 'usage' ? 'Usage' : 'Token'}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Rule 탭 콘텐츠 ── */}
|
||||
{activeColorTab === 'rule' && (
|
||||
<div className="mb-16">
|
||||
{/* 네이밍 공식 */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
|
||||
네이밍 공식
|
||||
</h2>
|
||||
<div
|
||||
className="rounded-lg px-6 py-5 font-mono text-lg font-bold mb-4"
|
||||
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5', color: t.textAccent }}
|
||||
>
|
||||
--{'{property}'}-{'{role}'}[-{'{variant}'}]
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed" style={{ color: t.textSecondary }}>
|
||||
모든 컬러 토큰은 <strong style={{ color: t.textPrimary }}>Property-Role-Variant</strong> 3계층 구조를 따릅니다.
|
||||
Property는 색상이 적용되는 CSS 속성, Role은 의미 기반 역할, Variant는 상태 수식어입니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Property */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-bold mb-4" style={{ color: t.textPrimary }}>
|
||||
Property (기본 속성)
|
||||
</h2>
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
|
||||
{[
|
||||
{ prop: 'fg', desc: '텍스트, 아이콘 등 UI의 전경 요소에 사용하는 색상입니다.', css: 'color', example: '--fg-default, --fg-sub' },
|
||||
{ prop: 'bg', desc: '전체 화면 또는 UI의 배경에 사용하는 색상입니다.', css: 'background', example: '--bg-surface, --bg-card' },
|
||||
{ prop: 'stroke', desc: '경계를 구분하는 선 또는 UI 요소의 윤곽선에 사용하는 색상입니다.', css: 'border, outline', example: '--stroke-default, --stroke-light' },
|
||||
].map((row, i) => (
|
||||
<div
|
||||
key={row.prop}
|
||||
className="flex items-center gap-6 px-5 py-4"
|
||||
style={{
|
||||
borderTop: i > 0 ? `1px solid ${dividerColor}` : undefined,
|
||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm font-bold shrink-0 w-16" style={{ color: t.textAccent }}>{row.prop}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm" style={{ color: t.textSecondary }}>{row.desc}</div>
|
||||
</div>
|
||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{row.example}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role & Variant */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-bold mb-4" style={{ color: t.textPrimary }}>
|
||||
Role (역할) & Variant (변형)
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Role */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
|
||||
<div className="px-5 py-3 text-xs font-bold uppercase tracking-wider" style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
|
||||
Role
|
||||
</div>
|
||||
{[
|
||||
{ name: 'default', desc: '기본 상태' },
|
||||
{ name: 'sub', desc: '보조, 2차 계층' },
|
||||
{ name: 'disabled', desc: '비활성, 플레이스홀더' },
|
||||
{ name: 'base', desc: '최하단 배경' },
|
||||
{ name: 'surface', desc: '패널, 컨테이너' },
|
||||
{ name: 'elevated', desc: '부각된 영역' },
|
||||
{ name: 'card', desc: '카드, 플로팅' },
|
||||
{ name: 'overlay', desc: '오버레이, 딤' },
|
||||
].map((row) => (
|
||||
<div
|
||||
key={row.name}
|
||||
className="flex items-center justify-between px-5 py-2.5"
|
||||
style={{
|
||||
borderTop: `1px solid ${dividerColor}`,
|
||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm font-semibold" style={{ color: t.textPrimary }}>{row.name}</span>
|
||||
<span className="text-xs" style={{ color: t.textMuted }}>{row.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Variant */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
|
||||
<div className="px-5 py-3 text-xs font-bold uppercase tracking-wider" style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
|
||||
Variant (선택)
|
||||
</div>
|
||||
{[
|
||||
{ name: 'hover', desc: '호버 상태' },
|
||||
{ name: 'active', desc: '활성/선택 상태' },
|
||||
{ name: 'muted', desc: '약한 강도' },
|
||||
{ name: 'light', desc: '밝은 변형' },
|
||||
{ name: 'subtle', desc: '미묘한 변형' },
|
||||
].map((row) => (
|
||||
<div
|
||||
key={row.name}
|
||||
className="flex items-center justify-between px-5 py-2.5"
|
||||
style={{
|
||||
borderTop: `1px solid ${dividerColor}`,
|
||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm font-semibold" style={{ color: t.textPrimary }}>{row.name}</span>
|
||||
<span className="text-xs" style={{ color: t.textMuted }}>{row.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Semantic 토큰 매핑 */}
|
||||
<div
|
||||
className="mb-12 pt-12 border-t border-solid"
|
||||
style={{ borderColor: dividerColor }}
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||
Semantic Tokens
|
||||
</h2>
|
||||
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
|
||||
용도에 따라 의미를 부여한 토큰. 테마 전환 시 값이 변경됩니다.
|
||||
</p>
|
||||
|
||||
{/* bg, fg, stroke 테이블 */}
|
||||
{[
|
||||
{
|
||||
title: 'bg — Background',
|
||||
tokens: [
|
||||
{ legacy: '--bg0', name: '--bg-base', value: '#0a0e1a', desc: '페이지 최하단 배경' },
|
||||
{ legacy: '--bg1', name: '--bg-surface', value: '#0f1524', desc: '패널, 사이드바' },
|
||||
{ legacy: '--bg2', name: '--bg-elevated', value: '#121929', desc: '테이블 헤더, 섹션' },
|
||||
{ legacy: '--bg3', name: '--bg-card', value: '#1a2236', desc: '카드, 플로팅 요소' },
|
||||
{ legacy: '--bgH', name: '--bg-surface-hover', value: '#1e2844', desc: '호버 인터랙션' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'fg — Foreground',
|
||||
tokens: [
|
||||
{ legacy: '--t1', name: '--fg-default', value: '#edf0f7', desc: '기본 텍스트' },
|
||||
{ legacy: '--t2', name: '--fg-sub', value: '#c0c8dc', desc: '보조 텍스트' },
|
||||
{ legacy: '--t3', name: '--fg-disabled', value: '#9ba3b8', desc: '비활성, 플레이스홀더' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'stroke — Border',
|
||||
tokens: [
|
||||
{ legacy: '--bd', name: '--stroke-default', value: '#1e2a42', desc: '기본 구분선' },
|
||||
{ legacy: '--bdL', name: '--stroke-light', value: '#2a3a5c', desc: '연한 구분선' },
|
||||
],
|
||||
},
|
||||
].map((group) => (
|
||||
<div key={group.title} className="mb-8">
|
||||
<h3 className="text-sm font-bold mb-3" style={{ color: t.textAccent }}>{group.title}</h3>
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="grid grid-cols-[80px_1fr_1fr_80px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
||||
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}
|
||||
>
|
||||
<span>Legacy</span><span>New</span><span>설명</span><span>Value</span><span>Preview</span>
|
||||
</div>
|
||||
{group.tokens.map((tk) => (
|
||||
<div
|
||||
key={tk.name}
|
||||
className="grid grid-cols-[80px_1fr_1fr_80px_1fr] gap-2 items-center px-4 py-3"
|
||||
style={{ borderTop: `1px solid ${dividerColor}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}
|
||||
>
|
||||
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>{tk.legacy}</span>
|
||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textPrimary }}>{tk.name}</span>
|
||||
<span className="text-xs" style={{ color: t.textMuted }}>{tk.desc}</span>
|
||||
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>{tk.value}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: tk.value, border: `1px solid ${dividerColor}`, flexShrink: 0 }} />
|
||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{hexToRgb(tk.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Palette 토큰 매핑 */}
|
||||
<div
|
||||
className="mb-12 pt-12 border-t border-solid"
|
||||
style={{ borderColor: dividerColor }}
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||
Palette Tokens
|
||||
</h2>
|
||||
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
|
||||
fg · bg · stroke 모든 맥락에서 사용되는 색상 원본. Property 접두사 없이 <code className="font-mono" style={{ color: t.textAccent }}>--color-*</code> 로 참조합니다.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
|
||||
<div
|
||||
className="grid grid-cols-[80px_1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
||||
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}
|
||||
>
|
||||
<span>Legacy</span><span>New</span><span>Value</span><span>Preview</span><span>설명</span>
|
||||
</div>
|
||||
{[
|
||||
{ legacy: '--cyan', name: '--color-accent', value: '#06b6d4', desc: '주요 강조' },
|
||||
{ legacy: '--blue', name: '--color-info', value: '#3b82f6', desc: '정보' },
|
||||
{ legacy: '--red', name: '--color-danger', value: '#ef4444', desc: '위험' },
|
||||
{ legacy: '--orange', name: '--color-warning', value: '#f97316', desc: '주의' },
|
||||
{ legacy: '--yellow', name: '--color-caution', value: '#eab308', desc: '경고' },
|
||||
{ legacy: '--green', name: '--color-success', value: '#22c55e', desc: '성공' },
|
||||
{ legacy: '--purple', name: '--color-tertiary', value: '#a855f7', desc: '3차 강조' },
|
||||
{ legacy: '--boom', name: '--color-boom', value: '#f59e0b', desc: '오일붐 (도메인)' },
|
||||
{ legacy: '--boomH', name: '--color-boom-hover', value: '#fbbf24', desc: '오일붐 호버' },
|
||||
].map((tk) => (
|
||||
<div
|
||||
key={tk.name}
|
||||
className="grid grid-cols-[80px_1fr_80px_1fr_1fr] gap-2 items-center px-4 py-3"
|
||||
style={{ borderTop: `1px solid ${dividerColor}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}
|
||||
>
|
||||
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>{tk.legacy}</span>
|
||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textPrimary }}>{tk.name}</span>
|
||||
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>{tk.value}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: tk.value, border: `1px solid ${dividerColor}`, flexShrink: 0 }} />
|
||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{hexToRgb(tk.value)}</span>
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: t.textMuted }}>{tk.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Non-color 토큰 */}
|
||||
<div
|
||||
className="mb-12 pt-12 border-t border-solid"
|
||||
style={{ borderColor: dividerColor }}
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||
Non-color Tokens
|
||||
</h2>
|
||||
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
|
||||
타이포그래피, 라운딩 등 색상 외 토큰.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
|
||||
<div
|
||||
className="grid grid-cols-[80px_1fr_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
||||
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}
|
||||
>
|
||||
<span>Legacy</span><span>New</span><span>Category</span><span>설명</span>
|
||||
</div>
|
||||
{[
|
||||
{ legacy: '--fK', name: '--font-korean', category: 'Typography', desc: '한국어 UI 폰트 스택' },
|
||||
{ legacy: '--fM', name: '--font-mono', category: 'Typography', desc: '수치/데이터 폰트 스택' },
|
||||
{ legacy: '--rS', name: '--radius-sm', category: 'Radius', desc: '작은 라운딩 (6px)' },
|
||||
{ legacy: '--rM', name: '--radius-md', category: 'Radius', desc: '중간 라운딩 (8px)' },
|
||||
].map((tk) => (
|
||||
<div
|
||||
key={tk.name}
|
||||
className="grid grid-cols-[80px_1fr_1fr_1fr] gap-2 items-center px-4 py-3"
|
||||
style={{ borderTop: `1px solid ${dividerColor}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}
|
||||
>
|
||||
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>{tk.legacy}</span>
|
||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textPrimary }}>{tk.name}</span>
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded" style={{ color: t.textAccent, backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)' }}>{tk.category}</span>
|
||||
<span className="text-xs" style={{ color: t.textMuted }}>{tk.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Usage 탭 콘텐츠 ── */}
|
||||
{activeColorTab === 'usage' && (
|
||||
<>
|
||||
@ -685,6 +958,17 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
||||
{/* ── Token 탭 콘텐츠 ── */}
|
||||
{activeColorTab === 'token' && (
|
||||
<div className="mb-16">
|
||||
<div className="mb-6">
|
||||
<p
|
||||
className="font-mono text-sm uppercase tracking-widest mb-1"
|
||||
style={{ color: t.textAccent }}
|
||||
>
|
||||
Primitive
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: t.textMuted }}>
|
||||
UI 전반에서 사용하는 기본 색조 팔레트. 테마와 무관하게 고정된 값입니다.
|
||||
</p>
|
||||
</div>
|
||||
{COLOR_TOKEN_GROUPS.map((group, groupIdx) => (
|
||||
<div key={group.title}>
|
||||
<h2
|
||||
@ -741,6 +1025,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -97,7 +97,7 @@ const TYPO_ROWS: TypoRow[] = [
|
||||
<span className="font-mono text-[11px]" style={{ color: t.typoCoordText }}>35° 06' 12" N</span>
|
||||
</div>
|
||||
),
|
||||
properties: 'JetBrains Mono / 11px',
|
||||
properties: 'PretendardGOV / 11px',
|
||||
isData: true,
|
||||
},
|
||||
];
|
||||
@ -339,13 +339,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
className="rounded-sm py-0.5 px-2 font-korean text-[10px] font-bold"
|
||||
style={{ backgroundColor: t.fontBadgePrimaryBg, color: t.fontBadgePrimaryText }}
|
||||
>
|
||||
Noto Sans KR
|
||||
</span>
|
||||
<span
|
||||
className="rounded-sm border border-solid py-0.5 px-2 font-korean text-[10px] font-bold"
|
||||
style={{ borderColor: t.fontBadgeSecondaryBorder, color: t.fontBadgeSecondaryText }}
|
||||
>
|
||||
JetBrains Mono
|
||||
PretendardGOV
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ const Z_LAYERS: ZLayer[] = [
|
||||
{ name: 'TopBar', zIndex: 30, description: '상단 네비게이션 바', color: '#3b82f6' },
|
||||
{ name: 'Sidebar', zIndex: 20, description: '사이드바, 패널', color: '#06b6d4' },
|
||||
{ name: 'Content', zIndex: 10, description: '메인 콘텐츠 영역', color: '#22c55e' },
|
||||
{ name: 'Base', zIndex: 0, description: '기본 레이어, 배경', color: '#8690a6' },
|
||||
{ name: 'Base', zIndex: 0, description: '기본 레이어, 배경', color: '#9ba3b8' },
|
||||
];
|
||||
|
||||
// ---------- App Shell Classes ----------
|
||||
@ -93,7 +93,7 @@ const SHELL_CLASSES: ShellClass[] = [
|
||||
{ className: '.wing-panel', role: '탭 콘텐츠 패널', styles: 'flex flex-col h-full overflow-hidden' },
|
||||
{ className: '.wing-panel-scroll', role: '패널 내 스크롤 영역', styles: 'flex-1 overflow-y-auto' },
|
||||
{ className: '.wing-header-bar', role: '패널 헤더', styles: 'flex items-center justify-between shrink-0 px-5 border-b' },
|
||||
{ className: '.wing-sidebar', role: '사이드바', styles: 'flex flex-col border-r border-border' },
|
||||
{ className: '.wing-sidebar', role: '사이드바', styles: 'flex flex-col border-r border-stroke' },
|
||||
];
|
||||
|
||||
// ---------- Props ----------
|
||||
@ -212,7 +212,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
||||
<span
|
||||
className="font-mono text-[9px] rounded px-1.5 py-0.5"
|
||||
style={{
|
||||
color: bp.inUse ? (isDark ? '#22c55e' : '#047857') : (isDark ? '#8690a6' : '#94a3b8'),
|
||||
color: bp.inUse ? (isDark ? '#22c55e' : '#047857') : (isDark ? '#9ba3b8' : '#94a3b8'),
|
||||
backgroundColor: bp.inUse
|
||||
? (isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)')
|
||||
: (isDark ? 'rgba(134,144,166,0.10)' : 'rgba(148,163,184,0.08)'),
|
||||
|
||||
@ -12,136 +12,19 @@ interface FontFamily {
|
||||
sampleText: string;
|
||||
}
|
||||
|
||||
interface TypographyToken {
|
||||
className: string;
|
||||
size: string;
|
||||
font: string;
|
||||
weight: string;
|
||||
usage: string;
|
||||
sampleText: string;
|
||||
sampleStyle: React.CSSProperties;
|
||||
}
|
||||
|
||||
// ---------- Font Family 데이터 ----------
|
||||
|
||||
const FONT_FAMILIES: FontFamily[] = [
|
||||
{
|
||||
name: 'Noto Sans KR',
|
||||
className: 'font-korean',
|
||||
stack: "'Noto Sans KR', sans-serif",
|
||||
usage: '기본 UI 텍스트, 레이블, 설명 등 한국어 콘텐츠 전반에 사용됩니다. 프로젝트에서 가장 많이 사용되는 폰트입니다.',
|
||||
sampleText: '해양 방제 운영 지원 시스템 WING-OPS',
|
||||
},
|
||||
{
|
||||
name: 'JetBrains Mono',
|
||||
className: 'font-mono',
|
||||
stack: "'JetBrains Mono', monospace",
|
||||
usage: '좌표, 수치 데이터, 코드, 토큰 이름 등 고정폭이 필요한 콘텐츠에 사용됩니다.',
|
||||
sampleText: '126.978° E, 37.566° N — #0a0e1a',
|
||||
},
|
||||
{
|
||||
name: 'Outfit',
|
||||
className: 'font-sans',
|
||||
stack: "'Outfit', 'Noto Sans KR', sans-serif",
|
||||
usage: '영문 헤딩과 브랜드 타이틀에 사용됩니다. body 기본 폰트 스택에 포함되어 있습니다.',
|
||||
sampleText: 'WING-OPS Design System v1.0',
|
||||
name: 'PretendardGOV',
|
||||
className: 'font-sans / font-korean / font-mono',
|
||||
stack: "'PretendardGOV', sans-serif",
|
||||
usage: '프로젝트 전체 통합 폰트. 한국어/영문 UI 텍스트, 수치 데이터, 좌표 표시 등 모든 콘텐츠에 사용됩니다.',
|
||||
sampleText: '해양 방제 운영 지원 시스템 WING-OPS 35.1284° N',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------- Typography Token 데이터 ----------
|
||||
|
||||
const TYPOGRAPHY_TOKENS: TypographyToken[] = [
|
||||
{
|
||||
className: '.wing-title',
|
||||
size: '15px',
|
||||
font: 'font-korean',
|
||||
weight: 'Bold (700)',
|
||||
usage: '패널 제목',
|
||||
sampleText: '확산 예측 시뮬레이션',
|
||||
sampleStyle: { fontSize: '15px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
{
|
||||
className: '.wing-section-header',
|
||||
size: '13px',
|
||||
font: 'font-korean',
|
||||
weight: 'Bold (700)',
|
||||
usage: '섹션 헤더',
|
||||
sampleText: '기본 정보 입력',
|
||||
sampleStyle: { fontSize: '13px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
{
|
||||
className: '.wing-label',
|
||||
size: '11px',
|
||||
font: 'font-korean',
|
||||
weight: 'Semibold (600)',
|
||||
usage: '필드 레이블',
|
||||
sampleText: '유출량 (kL)',
|
||||
sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
{
|
||||
className: '.wing-btn',
|
||||
size: '11px',
|
||||
font: 'font-korean',
|
||||
weight: 'Semibold (600)',
|
||||
usage: '버튼 텍스트',
|
||||
sampleText: '시뮬레이션 실행',
|
||||
sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
{
|
||||
className: '.wing-value',
|
||||
size: '11px',
|
||||
font: 'font-mono',
|
||||
weight: 'Semibold (600)',
|
||||
usage: '수치 / 데이터 값',
|
||||
sampleText: '35.1284° N, 129.0598° E',
|
||||
sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'JetBrains Mono', monospace" },
|
||||
},
|
||||
{
|
||||
className: '.wing-input',
|
||||
size: '11px',
|
||||
font: 'font-korean',
|
||||
weight: 'Normal (400)',
|
||||
usage: '입력 필드',
|
||||
sampleText: '서해 대산항 인근 해역',
|
||||
sampleStyle: { fontSize: '11px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
{
|
||||
className: '.wing-section-desc',
|
||||
size: '10px',
|
||||
font: 'font-korean',
|
||||
weight: 'Normal (400)',
|
||||
usage: '섹션 설명',
|
||||
sampleText: '예측 결과는 기상 조건에 따라 달라질 수 있습니다.',
|
||||
sampleStyle: { fontSize: '10px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
{
|
||||
className: '.wing-subtitle',
|
||||
size: '10px',
|
||||
font: 'font-korean',
|
||||
weight: 'Normal (400)',
|
||||
usage: '보조 설명',
|
||||
sampleText: '최근 업데이트: 2026-03-24 09:00 KST',
|
||||
sampleStyle: { fontSize: '10px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
{
|
||||
className: '.wing-meta',
|
||||
size: '9px',
|
||||
font: 'font-korean',
|
||||
weight: 'Normal (400)',
|
||||
usage: '메타 정보',
|
||||
sampleText: 'v2.1 | 해양환경공단',
|
||||
sampleStyle: { fontSize: '9px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
{
|
||||
className: '.wing-badge',
|
||||
size: '9px',
|
||||
font: 'font-korean',
|
||||
weight: 'Bold (700)',
|
||||
usage: '뱃지 / 태그',
|
||||
sampleText: '진행중',
|
||||
sampleStyle: { fontSize: '9px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||
},
|
||||
];
|
||||
|
||||
// ---------- Props ----------
|
||||
|
||||
@ -154,7 +37,6 @@ interface TypographyContentProps {
|
||||
export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
||||
const t = theme;
|
||||
const isDark = t.mode === 'dark';
|
||||
|
||||
return (
|
||||
<div className="pt-24 px-8 pb-16 flex flex-col gap-16 items-start justify-start max-w-[1440px]">
|
||||
|
||||
@ -190,271 +72,242 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
<li>폰트 크기, 폰트 두께, 폰트 패밀리를 각각 토큰으로 정의합니다.</li>
|
||||
<li>컴포넌트 클래스(<code style={{ color: t.textAccent, fontSize: '12px' }}>.wing-*</code>)로 조합하여 일관된 텍스트 스타일을 적용합니다.</li>
|
||||
<li>시스템 폰트를 기반으로 다양한 환경에서 일관된 사용자 경험을 보장합니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 섹션 2: 글꼴 (Font Family) ── */}
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2
|
||||
className="font-sans text-2xl leading-8 font-bold"
|
||||
style={{ color: t.textPrimary }}
|
||||
>
|
||||
글꼴
|
||||
</h2>
|
||||
<p
|
||||
className="font-korean text-sm leading-5"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
사용자의 디바이스 환경을 고려하여, 시스템 폰트와 웹 폰트를 조합하여 사용합니다. 한국어 UI에 최적화된 폰트 스택으로 다양한 기기에서 일관된 가독성을 보장합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* body 기본 폰트 스택 코드 블록 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid px-5 py-4 overflow-x-auto"
|
||||
style={{
|
||||
backgroundColor: isDark ? '#0f1524' : '#f1f5f9',
|
||||
borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0',
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className="font-mono text-sm leading-6"
|
||||
style={{ color: isDark ? '#b0b8cc' : '#475569' }}
|
||||
>
|
||||
<span style={{ color: t.textAccent }}>font-family</span>
|
||||
{`: 'Outfit', 'Noto Sans KR', sans-serif;`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 폰트 카드 3종 */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{FONT_FAMILIES.map((font) => (
|
||||
<div
|
||||
key={font.name}
|
||||
className="rounded-lg border border-solid overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: t.cardBg,
|
||||
borderColor: t.cardBorder,
|
||||
boxShadow: t.cardShadow,
|
||||
}}
|
||||
>
|
||||
{/* 카드 헤더 */}
|
||||
<div
|
||||
className="flex flex-row items-center gap-4 px-5 py-4 border-b border-solid"
|
||||
style={{ borderColor: isDark ? 'rgba(66,71,84,0.15)' : '#e2e8f0' }}
|
||||
{/* ── 글꼴 (Font Family) ── */}
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2
|
||||
className="font-sans text-2xl leading-8 font-bold"
|
||||
style={{ color: t.textPrimary }}
|
||||
>
|
||||
<span
|
||||
className="font-sans text-lg font-bold"
|
||||
style={{ color: t.textPrimary }}
|
||||
>
|
||||
{font.name}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[11px] rounded border border-solid px-2 py-0.5"
|
||||
style={{
|
||||
color: t.textAccent,
|
||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
||||
borderColor: isDark ? 'rgba(6,182,212,0.20)' : 'rgba(6,182,212,0.25)',
|
||||
}}
|
||||
>
|
||||
{font.className}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 */}
|
||||
<div className="px-5 py-5 flex flex-col gap-4">
|
||||
{/* 폰트 스택 */}
|
||||
<div
|
||||
className="font-mono text-xs leading-5 rounded px-3 py-2"
|
||||
style={{
|
||||
color: isDark ? '#8690a6' : '#64748b',
|
||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
{font.stack}
|
||||
</div>
|
||||
|
||||
{/* 용도 설명 */}
|
||||
<p
|
||||
className="font-korean text-xs leading-5"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
{font.usage}
|
||||
</p>
|
||||
|
||||
{/* 샘플 렌더 */}
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
{/* Regular */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className="font-mono text-[9px] uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
Regular
|
||||
</span>
|
||||
<span
|
||||
className={`${font.className} text-xl leading-7`}
|
||||
style={{ color: t.textPrimary, fontWeight: 400 }}
|
||||
>
|
||||
{font.sampleText}
|
||||
</span>
|
||||
</div>
|
||||
{/* Bold */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className="font-mono text-[9px] uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
Bold
|
||||
</span>
|
||||
<span
|
||||
className={`${font.className} text-xl leading-7 font-bold`}
|
||||
style={{ color: t.textPrimary }}
|
||||
>
|
||||
{font.sampleText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
글꼴
|
||||
</h2>
|
||||
<p
|
||||
className="font-korean text-sm leading-5"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
사용자의 디바이스 환경을 고려하여, 시스템 폰트와 웹 폰트를 조합하여 사용합니다. 한국어 UI에 최적화된 폰트 스택으로 다양한 기기에서 일관된 가독성을 보장합니다.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 섹션 3: 타이포그래피 토큰 ── */}
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2
|
||||
className="font-sans text-2xl leading-8 font-bold"
|
||||
style={{ color: t.textPrimary }}
|
||||
>
|
||||
타이포그래피 토큰
|
||||
</h2>
|
||||
<ul
|
||||
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
<li>Tailwind @apply 기반 컴포넌트 클래스로 정의됩니다 (<code style={{ color: t.textAccent, fontSize: '12px' }}>wing.css</code>).</li>
|
||||
<li>크기는 접근성을 위해 px 단위로 명시적으로 지정합니다.</li>
|
||||
<li>실제 UI에서는 클래스명을 직접 사용하거나, 동일한 속성 조합으로 적용합니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 토큰 테이블 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid overflow-hidden w-full"
|
||||
style={{
|
||||
backgroundColor: t.tableContainerBg,
|
||||
borderColor: t.cardBorder,
|
||||
boxShadow: t.cardShadow,
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: '160px 70px 110px 130px 120px 1fr',
|
||||
backgroundColor: t.tableHeaderBg,
|
||||
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
{(['Class', 'Size', 'Font', 'Weight', '용도', 'Sample'] as const).map((col) => (
|
||||
<div key={col} className="py-3 px-4">
|
||||
<span
|
||||
className="font-mono text-[10px] font-medium uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행 */}
|
||||
{TYPOGRAPHY_TOKENS.map((token, rowIdx) => (
|
||||
{/* body 기본 폰트 스택 코드 블록 */}
|
||||
<div
|
||||
key={token.className}
|
||||
className="grid items-center"
|
||||
className="rounded-lg border border-solid px-5 py-4 overflow-x-auto"
|
||||
style={{
|
||||
gridTemplateColumns: '160px 70px 110px 130px 120px 1fr',
|
||||
borderTop: rowIdx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||
backgroundColor: isDark ? '#0f1524' : '#f1f5f9',
|
||||
borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0',
|
||||
}}
|
||||
>
|
||||
{/* Class */}
|
||||
<div className="py-3 px-4">
|
||||
<span
|
||||
className="font-mono rounded border border-solid px-2 py-0.5"
|
||||
<pre
|
||||
className="font-mono text-sm leading-6"
|
||||
style={{ color: isDark ? '#c0c8dc' : '#475569' }}
|
||||
>
|
||||
<span style={{ color: t.textAccent }}>font-family</span>
|
||||
{`: 'PretendardGOV', -apple-system, BlinkMacSystemFont,\n 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard,\n Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic',\n 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',\n sans-serif;`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 폰트 카드 */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{FONT_FAMILIES.map((font) => (
|
||||
<div
|
||||
key={font.name}
|
||||
className="rounded-lg border border-solid overflow-hidden"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
lineHeight: '17px',
|
||||
color: t.textAccent,
|
||||
backgroundColor: t.cardBg,
|
||||
borderColor: t.cardBorder,
|
||||
boxShadow: t.cardShadow,
|
||||
}}
|
||||
>
|
||||
{token.className}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Size */}
|
||||
<div className="py-3 px-4">
|
||||
<span
|
||||
className="font-mono text-[11px]"
|
||||
style={{ color: t.textPrimary }}
|
||||
>
|
||||
{token.size}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Font */}
|
||||
<div className="py-3 px-4">
|
||||
<span
|
||||
className="font-mono text-[11px]"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
{token.font}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Weight */}
|
||||
<div className="py-3 px-4">
|
||||
<span
|
||||
className="font-mono text-[11px]"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
{token.weight}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 용도 */}
|
||||
<div className="py-3 px-4">
|
||||
<span
|
||||
className="font-korean text-xs"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
{token.usage}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sample */}
|
||||
<div className="py-3 px-4">
|
||||
<span
|
||||
style={{
|
||||
...token.sampleStyle,
|
||||
color: t.textPrimary,
|
||||
}}
|
||||
>
|
||||
{token.sampleText}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-row items-center gap-4 px-5 py-4 border-b border-solid"
|
||||
style={{ borderColor: isDark ? 'rgba(66,71,84,0.15)' : '#e2e8f0' }}
|
||||
>
|
||||
<span className="font-sans text-lg font-bold" style={{ color: t.textPrimary }}>{font.name}</span>
|
||||
<span
|
||||
className="font-mono text-[11px] rounded border border-solid px-2 py-0.5"
|
||||
style={{
|
||||
color: t.textAccent,
|
||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
||||
borderColor: isDark ? 'rgba(6,182,212,0.20)' : 'rgba(6,182,212,0.25)',
|
||||
}}
|
||||
>
|
||||
{font.className}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-5 py-5 flex flex-col gap-4">
|
||||
<div
|
||||
className="font-mono text-xs leading-5 rounded px-3 py-2"
|
||||
style={{
|
||||
color: isDark ? '#9ba3b8' : '#64748b',
|
||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
{font.stack}
|
||||
</div>
|
||||
<p className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>{font.usage}</p>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[9px] uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Regular</span>
|
||||
<span className="text-xl leading-7" style={{ color: t.textPrimary, fontWeight: 400 }}>{font.sampleText}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[9px] uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Bold</span>
|
||||
<span className="text-xl leading-7 font-bold" style={{ color: t.textPrimary }}>{font.sampleText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-12">
|
||||
{/* Font Size */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>폰트 크기 토큰</h2>
|
||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
||||
최소 크기 0.6875rem(11px). 모든 크기는 rem 단위로 반응형 스케일링을 지원합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}>
|
||||
<div className="grid grid-cols-[1fr_100px_80px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
||||
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
|
||||
<span>Token</span><span>rem</span><span>px</span><span>용도</span>
|
||||
</div>
|
||||
{[
|
||||
{ token: '--font-size-display-1', rem: '3.75rem', px: '60px', desc: '최상위 헤드라인' },
|
||||
{ token: '--font-size-display-2', rem: '2.5rem', px: '40px', desc: '메인 타이틀' },
|
||||
{ token: '--font-size-display-3', rem: '2.25rem', px: '36px', desc: '강조형 헤드라인' },
|
||||
{ token: '--font-size-heading-1', rem: '2rem', px: '32px', desc: '페이지 상위 제목' },
|
||||
{ token: '--font-size-heading-2', rem: '1.5rem', px: '24px', desc: '하위 제목' },
|
||||
{ token: '--font-size-heading-3', rem: '1.375rem', px: '22px', desc: '소제목, 그룹 제목' },
|
||||
{ token: '--font-size-title-1', rem: '1.125rem', px: '18px', desc: '컴포넌트 제목' },
|
||||
{ token: '--font-size-title-2', rem: '1rem', px: '16px', desc: '패널 제목' },
|
||||
{ token: '--font-size-body-1', rem: '0.875rem', px: '14px', desc: '기본 본문' },
|
||||
{ token: '--font-size-body-2', rem: '0.8125rem', px: '13px', desc: '보조 본문' },
|
||||
{ token: '--font-size-label-1', rem: '0.75rem', px: '12px', desc: '레이블, 버튼' },
|
||||
{ token: '--font-size-label-2', rem: '0.6875rem', px: '11px', desc: '소형 레이블, 데이터' },
|
||||
{ token: '--font-size-caption', rem: '0.6875rem', px: '11px', desc: '캡션, 메타 정보' },
|
||||
].map((row) => (
|
||||
<div key={row.token} className="grid grid-cols-[1fr_100px_80px_1fr] gap-2 items-center px-4 py-3"
|
||||
style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}>
|
||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>{row.token}</span>
|
||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>{row.rem}</span>
|
||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{row.px}</span>
|
||||
<span className="text-xs" style={{ color: t.textSecondary }}>{row.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Weight */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>폰트 두께 토큰</h2>
|
||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
||||
기본 두께 Regular(400), 강조 Bold(700). Medium(500)은 레이블과 소제목, Thin(300)은 장식적 대형 텍스트에 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}>
|
||||
<div className="grid grid-cols-[1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
||||
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
|
||||
<span>Token</span><span>Value</span><span>Name</span><span>Preview</span>
|
||||
</div>
|
||||
{[
|
||||
{ token: '--font-weight-thin', value: '300', name: 'Thin', preview: '해양 방제 운영 WING-OPS' },
|
||||
{ token: '--font-weight-regular', value: '400', name: 'Regular', preview: '해양 방제 운영 WING-OPS' },
|
||||
{ token: '--font-weight-medium', value: '500', name: 'Medium', preview: '해양 방제 운영 WING-OPS' },
|
||||
{ token: '--font-weight-bold', value: '700', name: 'Bold', preview: '해양 방제 운영 WING-OPS' },
|
||||
].map((row) => (
|
||||
<div key={row.token} className="grid grid-cols-[1fr_80px_1fr_1fr] gap-2 items-center px-4 py-3"
|
||||
style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}>
|
||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>{row.token}</span>
|
||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>{row.value}</span>
|
||||
<span className="text-xs" style={{ color: t.textSecondary }}>{row.name}</span>
|
||||
<span className="font-korean text-sm" style={{ color: t.textPrimary, fontWeight: Number(row.value) }}>{row.preview}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line Height */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>줄 높이 토큰</h2>
|
||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
||||
대형 텍스트는 타이트하게(1.3), 본문은 여유롭게(1.6). 가독성과 공간 효율의 균형을 맞춥니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}>
|
||||
<div className="grid grid-cols-[1fr_80px_100px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
||||
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
|
||||
<span>Token</span><span>Value</span><span>Percentage</span><span>적용</span>
|
||||
</div>
|
||||
{[
|
||||
{ token: '--line-height-tight', value: '1.3', pct: '130%', desc: 'Display (대형 텍스트)' },
|
||||
{ token: '--line-height-snug', value: '1.4', pct: '140%', desc: 'Heading' },
|
||||
{ token: '--line-height-normal', value: '1.5', pct: '150%', desc: 'Title, Label, Caption' },
|
||||
{ token: '--line-height-relaxed', value: '1.6', pct: '160%', desc: 'Body (본문)' },
|
||||
].map((row) => (
|
||||
<div key={row.token} className="grid grid-cols-[1fr_80px_100px_1fr] gap-2 items-center px-4 py-3"
|
||||
style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}>
|
||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>{row.token}</span>
|
||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>{row.value}</span>
|
||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{row.pct}</span>
|
||||
<span className="text-xs" style={{ color: t.textSecondary }}>{row.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type Scale */}
|
||||
<div className="flex flex-col gap-4" style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}`, paddingTop: '3rem' }}>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>타입 스케일</h2>
|
||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
||||
Display, Heading, Title, Body, Label, Caption으로 정의한 6단계 위계에 맞게 조합하여 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{[
|
||||
{ scale: 'Display 1', size: '3.75rem', weight: 700, lh: 1.3, role: '최상위 헤드라인', sample: '해양 방제 운영 지원' },
|
||||
{ scale: 'Display 2', size: '2.5rem', weight: 700, lh: 1.3, role: '메인 타이틀', sample: '확산 예측 시뮬레이션' },
|
||||
{ scale: 'Display 3', size: '2.25rem', weight: 500, lh: 1.4, role: '강조형 헤드라인', sample: '오염 종합 상황판' },
|
||||
{ scale: 'Heading 1', size: '2rem', weight: 700, lh: 1.4, role: '페이지 상위 제목', sample: '사고 현황 분석' },
|
||||
{ scale: 'Heading 2', size: '1.5rem', weight: 700, lh: 1.4, role: '하위 제목', sample: '유출유 풍화 상태' },
|
||||
{ scale: 'Heading 3', size: '1.375rem', weight: 500, lh: 1.4, role: '소제목, 그룹 제목', sample: '기상 데이터 요약' },
|
||||
{ scale: 'Title 1', size: '1.125rem', weight: 700, lh: 1.5, role: '컴포넌트 제목', sample: '확산 예측 시뮬레이션' },
|
||||
{ scale: 'Title 2', size: '1rem', weight: 500, lh: 1.5, role: '패널 제목', sample: '기본 정보 입력' },
|
||||
{ scale: 'Body 1', size: '0.875rem', weight: 400, lh: 1.6, role: '기본 본문', sample: '예측 결과는 기상 조건에 따라 달라질 수 있습니다.' },
|
||||
{ scale: 'Body 2', size: '0.8125rem', weight: 400, lh: 1.6, role: '보조 본문', sample: '최근 업데이트: 2026-03-24 09:00 KST' },
|
||||
{ scale: 'Label 1', size: '0.75rem', weight: 500, lh: 1.5, role: '레이블, 버튼', sample: '시뮬레이션 실행' },
|
||||
{ scale: 'Label 2', size: '0.6875rem', weight: 500, lh: 1.5, role: '소형 레이블', sample: '유출량 (kL)' },
|
||||
{ scale: 'Caption', size: '0.6875rem', weight: 400, lh: 1.5, role: '캡션, 메타', sample: 'v2.1 | 해양환경공단' },
|
||||
].map((row) => (
|
||||
<div key={row.scale} className="rounded-lg px-5 py-4 flex items-center gap-6"
|
||||
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa', border: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}` }}>
|
||||
<div className="shrink-0 w-[100px]">
|
||||
<div className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>{row.scale}</div>
|
||||
<div className="font-mono text-[10px] mt-0.5" style={{ color: t.textMuted }}>{row.size} · {row.weight} · {row.lh}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="font-korean" style={{ fontSize: row.size, fontWeight: row.weight, lineHeight: row.lh, color: t.textPrimary }}>
|
||||
{row.sample}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs font-korean" style={{ color: t.textMuted }}>{row.role}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -51,21 +51,21 @@ const buttonRows: ButtonRow[] = [
|
||||
label: '세컨더리 (솔리드)',
|
||||
defaultBtn: (
|
||||
<div className='bg-[#1a2236] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||
<div className='text-[#b0b8cc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
<div className='text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
hoverBtn: (
|
||||
<div className='bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||
<div className='text-[#b0b8cc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
<div className='text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
닫기
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
disabledBtn: (
|
||||
<div className='bg-[rgba(26,34,54,0.50)] rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||
<div className='text-[rgba(176,184,204,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
<div className='text-[rgba(192,200,220,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
@ -75,21 +75,21 @@ const buttonRows: ButtonRow[] = [
|
||||
label: '아웃라인 (고스트)',
|
||||
defaultBtn: (
|
||||
<div className='rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||
<div className='text-[#b0b8cc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
<div className='text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
더보기
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
hoverBtn: (
|
||||
<div className='bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||
<div className='text-[#b0b8cc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
<div className='text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
필터
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
disabledBtn: (
|
||||
<div className='rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||
<div className='text-[rgba(176,184,204,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
<div className='text-[rgba(192,200,220,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||
더보기
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -146,7 +146,7 @@ export const CardSection = () => {
|
||||
|
||||
{/* 대응팀 배치 아웃라인 버튼 */}
|
||||
<div className='rounded-md border border-[#1e2a42] pt-1 pr-3 pb-1 pl-3 flex flex-col gap-0 items-center justify-center shrink-0 relative'>
|
||||
<div className='text-[#b0b8cc] text-center font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-center'>
|
||||
<div className='text-[#c0c8dc] text-center font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-center'>
|
||||
대응팀 배치
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -33,7 +33,7 @@ const statusBadges: StatusBadge[] = [
|
||||
{ label: '주의', color: '#eab308', bg: 'rgba(234,179,8,0.10)' },
|
||||
{ label: '위험', color: '#ef4444', bg: 'rgba(239,68,68,0.10)' },
|
||||
{ label: '진행중', color: '#3b82f6', bg: 'rgba(59,130,246,0.10)' },
|
||||
{ label: '완료', color: '#8690a6', bg: 'rgba(134,144,166,0.10)' },
|
||||
{ label: '완료', color: '#9ba3b8', bg: 'rgba(155,163,184,0.10)' },
|
||||
];
|
||||
|
||||
const dataTags: DataTag[] = [
|
||||
|
||||
@ -253,8 +253,8 @@ export const DARK_THEME: DesignTheme = {
|
||||
|
||||
textTokens: [
|
||||
{ token: 'text-1', sampleText: '주요 텍스트 Primary Text', sampleClass: 'text-[#edf0f7] font-korean text-[15px] font-bold', desc: 'Headings, active values, and primary labels.', descColor: 'rgba(237,240,247,0.60)' },
|
||||
{ token: 'text-2', sampleText: '보조 텍스트 Secondary Text', sampleClass: 'text-[#b0b8cc] font-korean text-[15px] font-medium', desc: 'Supporting labels and secondary information.', descColor: 'rgba(176,184,204,0.60)' },
|
||||
{ token: 'text-3', sampleText: '비활성 텍스트 Muted Text', sampleClass: 'text-[#8690a6] font-korean text-[15px]', desc: 'Disabled states, placeholders, and captions.', descColor: 'rgba(134,144,166,0.60)' },
|
||||
{ token: 'text-2', sampleText: '보조 텍스트 Secondary Text', sampleClass: 'text-[#c0c8dc] font-korean text-[15px] font-medium', desc: 'Supporting labels and secondary information.', descColor: 'rgba(192,200,220,0.60)' },
|
||||
{ token: 'text-3', sampleText: '비활성 텍스트 Muted Text', sampleClass: 'text-[#9ba3b8] font-korean text-[15px]', desc: 'Disabled states, placeholders, and captions.', descColor: 'rgba(155,163,184,0.60)' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -6,8 +6,8 @@ interface AdminPlaceholderProps {
|
||||
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<div className="text-4xl opacity-20">🚧</div>
|
||||
<div className="text-sm font-korean text-text-2 font-semibold">{label}</div>
|
||||
<div className="text-[11px] font-korean text-text-3">해당 기능은 준비 중입니다.</div>
|
||||
<div className="text-sm font-korean text-fg-sub font-semibold">{label}</div>
|
||||
<div className="text-[11px] font-korean text-fg-disabled">해당 기능은 준비 중입니다.</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
style={{
|
||||
paddingLeft: `${12 + depth * 14}px`,
|
||||
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
|
||||
color: isActive ? 'var(--cyan)' : 'var(--t2)',
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
@ -68,12 +68,12 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||
style={{
|
||||
paddingLeft: `${12 + depth * 14}px`,
|
||||
color: hasActiveChild ? 'var(--cyan)' : 'var(--t2)',
|
||||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||||
fontWeight: hasActiveChild ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||
<span className="text-[9px] text-fg-disabled transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
@ -95,12 +95,12 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-bg-1 border-r border-border overflow-y-auto shrink-0"
|
||||
style={{ width: 240, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
|
||||
className="flex flex-col bg-bg-surface border-r border-stroke overflow-y-auto shrink-0"
|
||||
style={{ width: 240, scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-border bg-bg-2 shrink-0">
|
||||
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated shrink-0">
|
||||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
||||
<span>⚙️</span> 관리자 설정
|
||||
</div>
|
||||
</div>
|
||||
@ -119,12 +119,12 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-[11px] font-bold font-korean transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||
color: hasActiveChild ? 'var(--cyan)' : 'var(--t1)',
|
||||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
|
||||
}}
|
||||
>
|
||||
<span className="text-sm">{section.icon}</span>
|
||||
<span className="flex-1 text-left">{section.label}</span>
|
||||
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||
<span className="text-[9px] text-fg-disabled transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -55,7 +55,7 @@ export function AdminView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden bg-bg-0">
|
||||
<div className="flex flex-1 overflow-hidden bg-bg-base">
|
||||
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{renderContent()}
|
||||
|
||||
@ -7,8 +7,8 @@ const JURISDICTIONS = ['전체', '남해청', '서해청', '중부청', '동해
|
||||
|
||||
const PERM_ITEMS = [
|
||||
{ icon: '👑', role: '시스템관리자', desc: '전체 자산 업로드/삭제 가능', bg: 'rgba(245,158,11,0.15)', color: 'text-yellow-400' },
|
||||
{ icon: '🔧', role: '운영관리자', desc: '관할청 내 자산 업로드 가능', bg: 'rgba(6,182,212,0.15)', color: 'text-primary-cyan' },
|
||||
{ icon: '👁', role: '조회자', desc: '현황 조회만 가능', bg: 'rgba(148,163,184,0.15)', color: 'text-text-2' },
|
||||
{ icon: '🔧', role: '운영관리자', desc: '관할청 내 자산 업로드 가능', bg: 'rgba(6,182,212,0.15)', color: 'text-color-accent' },
|
||||
{ icon: '👁', role: '조회자', desc: '현황 조회만 가능', bg: 'rgba(148,163,184,0.15)', color: 'text-fg-sub' },
|
||||
{ icon: '🚫', role: '게스트', desc: '접근 불가', bg: 'rgba(239,68,68,0.1)', color: 'text-red-400' },
|
||||
];
|
||||
|
||||
@ -67,9 +67,9 @@ function AssetUploadPanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">자산 현행화</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">자산 데이터를 업로드하여 현행화합니다</p>
|
||||
<div className="px-6 py-4 border-b border-stroke flex-shrink-0">
|
||||
<h1 className="text-lg font-bold text-fg font-korean">자산 현행화</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">자산 데이터를 업로드하여 현행화합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
@ -77,9 +77,9 @@ function AssetUploadPanel() {
|
||||
<div className="flex gap-6 h-full">
|
||||
{/* 좌측: 파일 업로드 */}
|
||||
<div className="flex-1 max-w-[560px] space-y-4">
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">파일 업로드</h2>
|
||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">파일 업로드</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* 드롭존 */}
|
||||
@ -90,20 +90,20 @@ function AssetUploadPanel() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
|
||||
dragging
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.05)]'
|
||||
: 'border-border hover:border-primary-cyan/50 bg-bg-2'
|
||||
? 'border-color-accent bg-[rgba(6,182,212,0.05)]'
|
||||
: 'border-stroke hover:border-color-accent/50 bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
<div className="text-3xl mb-2 opacity-40">📁</div>
|
||||
{selectedFile ? (
|
||||
<div className="text-xs font-semibold text-primary-cyan font-korean mb-1">{selectedFile.name}</div>
|
||||
<div className="text-xs font-semibold text-color-accent font-korean mb-1">{selectedFile.name}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs font-semibold text-text-2 font-korean mb-1">파일을 드래그하거나 클릭하여 업로드</div>
|
||||
<div className="text-[10px] text-text-3 font-korean mb-3">엑셀(.xlsx), CSV 파일 지원 · 최대 10MB</div>
|
||||
<div className="text-xs font-semibold text-fg-sub font-korean mb-1">파일을 드래그하거나 클릭하여 업로드</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean mb-3">엑셀(.xlsx), CSV 파일 지원 · 최대 10MB</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-1.5 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0
|
||||
className="px-4 py-1.5 text-xs font-semibold rounded-md bg-color-accent text-bg-0
|
||||
hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
onClick={e => { e.stopPropagation(); fileInputRef.current?.click(); }}
|
||||
>
|
||||
@ -122,12 +122,12 @@ function AssetUploadPanel() {
|
||||
|
||||
{/* 자산 분류 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">자산 분류</label>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">자산 분류</label>
|
||||
<select
|
||||
value={assetCategory}
|
||||
onChange={e => setAssetCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md
|
||||
text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md
|
||||
text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
{ASSET_CATEGORIES.map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
@ -137,12 +137,12 @@ function AssetUploadPanel() {
|
||||
|
||||
{/* 대상 관할 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">대상 관할</label>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">대상 관할</label>
|
||||
<select
|
||||
value={jurisdiction}
|
||||
onChange={e => setJurisdiction(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md
|
||||
text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md
|
||||
text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
{JURISDICTIONS.map(j => (
|
||||
<option key={j} value={j}>{j}</option>
|
||||
@ -152,9 +152,9 @@ function AssetUploadPanel() {
|
||||
|
||||
{/* 업로드 방식 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">업로드 방식</label>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">업로드 방식</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-text-2 font-korean">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
|
||||
<input
|
||||
type="radio"
|
||||
checked={uploadMode === 'add'}
|
||||
@ -163,7 +163,7 @@ function AssetUploadPanel() {
|
||||
/>
|
||||
추가 (기존 + 신규)
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-text-2 font-korean">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
|
||||
<input
|
||||
type="radio"
|
||||
checked={uploadMode === 'replace'}
|
||||
@ -182,8 +182,8 @@ function AssetUploadPanel() {
|
||||
disabled={!selectedFile || uploaded}
|
||||
className={`w-full py-2.5 text-xs font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
|
||||
uploaded
|
||||
? 'bg-[rgba(34,197,94,0.15)] text-status-green border border-status-green/30'
|
||||
: 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
? 'bg-[rgba(34,197,94,0.15)] text-color-success border border-status-green/30'
|
||||
: 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
}`}
|
||||
>
|
||||
{uploaded ? '✅ 업로드 완료!' : '📤 업로드 실행'}
|
||||
@ -195,15 +195,15 @@ function AssetUploadPanel() {
|
||||
{/* 우측 */}
|
||||
<div className="w-[400px] space-y-4 flex-shrink-0">
|
||||
{/* 수정 권한 체계 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">수정 권한 체계</h2>
|
||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">수정 권한 체계</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
{PERM_ITEMS.map(p => (
|
||||
<div
|
||||
key={p.role}
|
||||
className="flex items-center gap-3 px-4 py-3 bg-bg-2 border border-border rounded-md"
|
||||
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0"
|
||||
@ -213,7 +213,7 @@ function AssetUploadPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
||||
<div className="text-[10px] text-text-3 font-korean mt-0.5">{p.desc}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean mt-0.5">{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -221,26 +221,26 @@ function AssetUploadPanel() {
|
||||
</div>
|
||||
|
||||
{/* 최근 업로드 이력 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">최근 업로드 이력</h2>
|
||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">최근 업로드 이력</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
{uploadHistory.length === 0 ? (
|
||||
<div className="text-[11px] text-text-3 font-korean text-center py-4">이력이 없습니다.</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean text-center py-4">이력이 없습니다.</div>
|
||||
) : uploadHistory.map(h => (
|
||||
<div
|
||||
key={h.logSn}
|
||||
className="flex justify-between items-center px-4 py-3 bg-bg-2 border border-border rounded-md"
|
||||
className="flex justify-between items-center px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-text-1 font-korean">{h.fileNm}</div>
|
||||
<div className="text-[10px] text-text-3 mt-0.5 font-korean">
|
||||
<div className="text-xs font-semibold text-fg font-korean">{h.fileNm}</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5 font-korean">
|
||||
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold
|
||||
bg-[rgba(34,197,94,0.15)] text-status-green flex-shrink-0">
|
||||
bg-[rgba(34,197,94,0.15)] text-color-success flex-shrink-0">
|
||||
완료
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -116,15 +116,15 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1">
|
||||
<h2 className="text-sm font-semibold text-text-1">게시판 관리</h2>
|
||||
<span className="text-xs text-text-3">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1">
|
||||
<h2 className="text-sm font-semibold text-fg">게시판 관리</h2>
|
||||
<span className="text-xs text-fg-disabled">
|
||||
총 {data?.totalCount ?? 0}건
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 탭 + 검색 */}
|
||||
<div className="flex items-center gap-3 px-5 py-2 border-b border-border-1">
|
||||
<div className="flex items-center gap-3 px-5 py-2 border-b border-stroke-1">
|
||||
<div className="flex gap-1">
|
||||
{CATEGORY_TABS.map(tab => (
|
||||
<button
|
||||
@ -133,7 +133,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
activeCategory === tab.code
|
||||
? 'bg-blue-500/20 text-blue-400 font-medium'
|
||||
: 'text-text-3 hover:text-text-2 hover:bg-bg-2'
|
||||
: 'text-fg-disabled hover:text-fg-sub hover:bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
@ -146,11 +146,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
placeholder="제목/작성자 검색"
|
||||
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1 placeholder:text-text-4 w-48"
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg placeholder:text-text-4 w-48"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
@ -158,7 +158,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
</div>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center gap-2 px-5 py-2 border-b border-border-1">
|
||||
<div className="flex items-center gap-2 px-5 py-2 border-b border-stroke-1">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={selected.size === 0 || deleting}
|
||||
@ -171,8 +171,8 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-bg-1 z-10">
|
||||
<tr className="border-b border-border-1 text-text-3">
|
||||
<thead className="sticky top-0 bg-bg-surface z-10">
|
||||
<tr className="border-b border-stroke-1 text-fg-disabled">
|
||||
<th className="w-8 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -192,11 +192,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-text-3">로딩 중...</td>
|
||||
<td colSpan={7} className="py-8 text-center text-fg-disabled">로딩 중...</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-text-3">게시글이 없습니다.</td>
|
||||
<td colSpan={7} className="py-8 text-center text-fg-disabled">게시글이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map(post => (
|
||||
@ -214,11 +214,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-1 py-2 border-t border-border-1">
|
||||
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
@ -231,7 +231,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`w-7 h-7 text-xs rounded ${
|
||||
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
|
||||
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-fg-disabled hover:bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
@ -241,7 +241,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
@ -260,7 +260,7 @@ interface PostRowProps {
|
||||
|
||||
function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||
return (
|
||||
<tr className="border-b border-border-1 hover:bg-bg-1/50 transition-colors">
|
||||
<tr className="border-b border-stroke-1 hover:bg-bg-surface/50 transition-colors">
|
||||
<td className="py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -269,7 +269,7 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-center text-text-3">{post.sn}</td>
|
||||
<td className="py-2 text-center text-fg-disabled">{post.sn}</td>
|
||||
<td className="py-2 text-center">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||
post.categoryCd === 'NOTICE' ? 'bg-red-500/15 text-red-400' :
|
||||
@ -279,15 +279,15 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pl-3 text-text-1 truncate max-w-[300px]">
|
||||
<td className="py-2 pl-3 text-fg truncate max-w-[300px]">
|
||||
{post.pinnedYn === 'Y' && (
|
||||
<span className="text-[10px] text-orange-400 mr-1">[고정]</span>
|
||||
)}
|
||||
{post.title}
|
||||
</td>
|
||||
<td className="py-2 text-center text-text-2">{post.authorName}</td>
|
||||
<td className="py-2 text-center text-text-3">{post.viewCnt}</td>
|
||||
<td className="py-2 text-center text-text-3">{formatDate(post.regDtm)}</td>
|
||||
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>
|
||||
<td className="py-2 text-center text-fg-disabled">{post.viewCnt}</td>
|
||||
<td className="py-2 text-center text-fg-disabled">{formatDate(post.regDtm)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@ -78,16 +78,16 @@ function CleanupEquipPanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">방제장비 현황</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {filtered.length}개 기관</p>
|
||||
<h1 className="text-lg font-bold text-fg font-korean">방제장비 현황</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {filtered.length}개 기관</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={regionFilter}
|
||||
onChange={handleFilterChange(setRegionFilter)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="전체">전체 관할청</option>
|
||||
<option value="남해">남해청</option>
|
||||
@ -99,7 +99,7 @@ function CleanupEquipPanel() {
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={handleFilterChange(setTypeFilter)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="전체">전체 유형</option>
|
||||
{typeOptions.map(t => (
|
||||
@ -109,7 +109,7 @@ function CleanupEquipPanel() {
|
||||
<select
|
||||
value={equipFilter}
|
||||
onChange={handleFilterChange(setEquipFilter)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="전체">전체 장비</option>
|
||||
<option value="방제선">방제선</option>
|
||||
@ -123,11 +123,11 @@ function CleanupEquipPanel() {
|
||||
placeholder="기관명, 주소 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
<button
|
||||
onClick={load}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-2 border border-border text-text-2 hover:border-primary-cyan hover:text-primary-cyan transition-all font-korean"
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
@ -137,36 +137,36 @@ function CleanupEquipPanel() {
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">
|
||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제선' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>방제선</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '유회수기' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>유회수기</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>이송펌프</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제차량' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>방제차량</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '살포장치' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>살포장치</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
||||
<tr className="border-b border-stroke bg-bg-surface">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">유형</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">관할청</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">기관명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">주소</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제선' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}>방제선</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '유회수기' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}>유회수기</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}>이송펌프</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제차량' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}>방제차량</th>
|
||||
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '살포장치' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}>살포장치</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean">총자산</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-xs text-text-3 font-korean">
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-xs text-fg-disabled font-korean">
|
||||
조회된 기관이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : paged.map((org, idx) => (
|
||||
<tr key={org.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono text-center">
|
||||
<tr key={org.id} className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
|
||||
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@ -174,31 +174,31 @@ function CleanupEquipPanel() {
|
||||
{org.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-korean">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
|
||||
{regionShort(org.jurisdiction)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-1 font-korean font-semibold">
|
||||
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
|
||||
{org.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
|
||||
{org.address}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제선' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||
{org.vessel > 0 ? org.vessel : <span className="text-text-3">—</span>}
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제선' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
|
||||
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '유회수기' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||
{org.skimmer > 0 ? org.skimmer : <span className="text-text-3">—</span>}
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '유회수기' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
|
||||
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '이송펌프' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||
{org.pump > 0 ? org.pump : <span className="text-text-3">—</span>}
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '이송펌프' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
|
||||
{org.pump > 0 ? org.pump : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제차량' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||
{org.vehicle > 0 ? org.vehicle : <span className="text-text-3">—</span>}
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제차량' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
|
||||
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '살포장치' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||
{org.sprayer > 0 ? org.sprayer : <span className="text-text-3">—</span>}
|
||||
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '살포장치' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
|
||||
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-color-accent">
|
||||
{org.totalAssets.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
@ -210,8 +210,8 @@ function CleanupEquipPanel() {
|
||||
|
||||
{/* 합계 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center gap-4 px-6 py-2 border-t border-border bg-bg-0/80">
|
||||
<span className="text-[10px] text-text-3 font-korean font-semibold mr-auto">
|
||||
<div className="flex items-center gap-4 px-6 py-2 border-t border-stroke bg-bg-base/80">
|
||||
<span className="text-[10px] text-fg-disabled font-korean font-semibold mr-auto">
|
||||
합계 ({filtered.length}개 기관)
|
||||
</span>
|
||||
{[
|
||||
@ -224,9 +224,9 @@ function CleanupEquipPanel() {
|
||||
].map((t) => {
|
||||
const isActive = t.label === equipFilter || t.label === '총자산';
|
||||
return (
|
||||
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-primary-cyan/10' : ''}`}>
|
||||
<span className={`text-[9px] font-korean ${isActive ? 'text-primary-cyan' : 'text-text-3'}`}>{t.label}</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${isActive ? 'text-primary-cyan' : 'text-text-1'}`}>
|
||||
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-color-accent/10' : ''}`}>
|
||||
<span className={`text-[9px] font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}>{t.label}</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}>
|
||||
{t.value.toLocaleString()}{t.unit}
|
||||
</span>
|
||||
</div>
|
||||
@ -237,15 +237,15 @@ function CleanupEquipPanel() {
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
||||
<span className="text-[11px] text-text-3 font-korean">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} / 전체 {filtered.length}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
@ -255,7 +255,7 @@ function CleanupEquipPanel() {
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
|
||||
style={p === safePage
|
||||
? { borderColor: 'var(--cyan)', color: 'var(--cyan)', background: 'rgba(6,182,212,0.1)' }
|
||||
? { borderColor: 'var(--color-accent)', color: 'var(--color-accent)', background: 'rgba(6,182,212,0.1)' }
|
||||
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
|
||||
}
|
||||
>
|
||||
@ -265,7 +265,7 @@ function CleanupEquipPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
||||
@ -179,7 +179,7 @@ function fetchHrCollectData(): Promise<HrCollectItem[]> {
|
||||
|
||||
function getCollectStatus(item: HrCollectItem): { label: string; color: string } {
|
||||
if (item.activeYn !== 'Y') {
|
||||
return { label: '비활성', color: 'text-t3 bg-bg-2' };
|
||||
return { label: '비활성', color: 'text-t3 bg-bg-elevated' };
|
||||
}
|
||||
if (item.etaClctList.length > 0) {
|
||||
return { label: '완료', color: 'text-emerald-400 bg-emerald-500/10' };
|
||||
@ -206,19 +206,19 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{HEADERS.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{HEADERS.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-2 rounded w-14" />
|
||||
<div className="h-3 bg-bg-elevated rounded w-14" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@ -226,7 +226,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
|
||||
: rows.map((row, idx) => {
|
||||
const status = getCollectStatus(row);
|
||||
return (
|
||||
<tr key={`${row.seq}`} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||
<tr key={`${row.seq}`} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.clctName}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.depth2}</td>
|
||||
@ -237,7 +237,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
|
||||
<td className="px-3 py-2 text-t2 font-mono">{row.jobName}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-medium ${
|
||||
row.activeYn === 'Y' ? 'text-emerald-400 bg-emerald-500/10' : 'text-t3 bg-bg-2'
|
||||
row.activeYn === 'Y' ? 'text-emerald-400 bg-emerald-500/10' : 'text-t3 bg-bg-elevated'
|
||||
}`}>
|
||||
{row.activeYn === 'Y' ? 'Y' : 'N'}
|
||||
</span>
|
||||
@ -287,7 +287,7 @@ export default function CollectHrPanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1 shrink-0">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-t1">인사정보 수집 현황</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
@ -298,7 +298,7 @@ export default function CollectHrPanel() {
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-2 hover:bg-bg-3 text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||
@ -314,7 +314,7 @@ export default function CollectHrPanel() {
|
||||
</div>
|
||||
|
||||
{/* 상태 표시줄 */}
|
||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-border-1 bg-bg-0">
|
||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
수집 완료 {completedCount}건
|
||||
|
||||
@ -143,14 +143,14 @@ const DispersingZonePanel = () => {
|
||||
const isExpanded = expandedZone === zone;
|
||||
|
||||
return (
|
||||
<div key={zone} className="border border-border rounded-lg overflow-hidden">
|
||||
<div key={zone} className="border border-stroke rounded-lg overflow-hidden">
|
||||
{/* 카드 헤더 */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-[rgba(255,255,255,0.03)] transition-colors"
|
||||
onClick={() => handleToggleExpand(zone)}
|
||||
>
|
||||
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
|
||||
<span className="flex-1 text-xs font-semibold text-text-1 font-korean">{info.label}</span>
|
||||
<span className="flex-1 text-xs font-semibold text-fg font-korean">{info.label}</span>
|
||||
{/* 토글 스위치 */}
|
||||
<button
|
||||
onClick={e => {
|
||||
@ -160,8 +160,8 @@ const DispersingZonePanel = () => {
|
||||
title={showLayer ? '레이어 숨기기' : '레이어 표시'}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 ${
|
||||
showLayer
|
||||
? 'bg-primary-cyan'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||
? 'bg-color-accent'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@ -171,20 +171,20 @@ const DispersingZonePanel = () => {
|
||||
/>
|
||||
</button>
|
||||
{/* 펼침 화살표 */}
|
||||
<span className="text-text-3 text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||
<span className="text-fg-disabled text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{/* 펼침 영역 */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<div className="border-t border-stroke px-3 py-3">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{info.rows.map(row => (
|
||||
<tr key={row.key} className="border-b border-border last:border-0">
|
||||
<td className="py-2 pr-2 text-[11px] text-text-3 font-korean whitespace-nowrap align-top w-24">
|
||||
<tr key={row.key} className="border-b border-stroke last:border-0">
|
||||
<td className="py-2 pr-2 text-[11px] text-fg-disabled font-korean whitespace-nowrap align-top w-24">
|
||||
{row.key}
|
||||
</td>
|
||||
<td className="py-2 text-[11px] text-text-2 font-korean leading-relaxed">
|
||||
<td className="py-2 text-[11px] text-fg-sub font-korean leading-relaxed">
|
||||
{row.value}
|
||||
</td>
|
||||
</tr>
|
||||
@ -214,24 +214,24 @@ const DispersingZonePanel = () => {
|
||||
</Map>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-4 left-4 bg-bg-1 border border-border rounded-lg px-3 py-2 flex flex-col gap-1.5">
|
||||
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-lg px-3 py-2 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
|
||||
<span className="text-[11px] text-text-2 font-korean">사용고려해역</span>
|
||||
<span className="text-[11px] text-fg-sub font-korean">사용고려해역</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
|
||||
<span className="text-[11px] text-text-2 font-korean">사용제한해역</span>
|
||||
<span className="text-[11px] text-fg-sub font-korean">사용제한해역</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div className="w-[280px] bg-bg-1 border-l border-border flex flex-col overflow-hidden shrink-0">
|
||||
<div className="w-[280px] bg-bg-surface border-l border-stroke flex flex-col overflow-hidden shrink-0">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-4 border-b border-border shrink-0">
|
||||
<h1 className="text-sm font-bold text-text-1 font-korean">유처리제 제한구역</h1>
|
||||
<p className="text-[11px] text-text-3 mt-0.5 font-korean">해양환경관리법 기준</p>
|
||||
<div className="px-4 py-4 border-b border-stroke shrink-0">
|
||||
<h1 className="text-sm font-bold text-fg font-korean">유처리제 제한구역</h1>
|
||||
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">해양환경관리법 기준</p>
|
||||
</div>
|
||||
|
||||
{/* 구역 카드 목록 */}
|
||||
|
||||
@ -166,18 +166,18 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none';
|
||||
const labelCls = 'block text-[11px] font-semibold text-text-2 font-korean mb-1.5';
|
||||
const inputCls = 'w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none';
|
||||
const labelCls = 'block text-[11px] font-semibold text-fg-sub font-korean mb-1.5';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">
|
||||
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-text-3 hover:text-text-1 transition-colors">✕</button>
|
||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">✕</button>
|
||||
</div>
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||
@ -188,7 +188,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
||||
<select
|
||||
value={form.upLayerCd}
|
||||
onChange={e => mode === 'create' ? handleParentChange(e.target.value) : handleField('upLayerCd', e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{options
|
||||
@ -210,7 +210,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
||||
onChange={e => handleField('layerCd', e.target.value)}
|
||||
readOnly={mode === 'edit'}
|
||||
placeholder="예: LAYER_001"
|
||||
className={`${inputCls} font-mono${mode === 'edit' ? ' bg-bg-0 text-text-3 cursor-not-allowed' : ''}`}
|
||||
className={`${inputCls} font-mono${mode === 'edit' ? ' bg-bg-base text-fg-disabled cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{/* 레이어명 */}
|
||||
@ -276,7 +276,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
||||
<select
|
||||
value={form.useYn}
|
||||
onChange={e => handleField('useYn', e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
||||
>
|
||||
<option value="Y">사용</option>
|
||||
<option value="N">미사용</option>
|
||||
@ -290,18 +290,18 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
||||
</div>
|
||||
)}
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border shrink-0">
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-stroke shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 text-xs bg-primary-cyan text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
|
||||
className="px-3 py-1.5 text-xs bg-color-accent text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
|
||||
>
|
||||
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
|
||||
</button>
|
||||
@ -408,15 +408,15 @@ const LayerPanel = () => {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="px-6 py-4 border-b border-stroke shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">레이어 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}개</p>
|
||||
<h1 className="text-lg font-bold text-fg font-korean">레이어 관리</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'create' })}
|
||||
className="px-3 py-1.5 text-xs font-semibold bg-primary-cyan text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
|
||||
className="px-3 py-1.5 text-xs font-semibold bg-color-accent text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
|
||||
>
|
||||
신규 등록
|
||||
</button>
|
||||
@ -428,12 +428,12 @@ const LayerPanel = () => {
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="레이어코드 / 레이어명 검색"
|
||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
<select
|
||||
value={filterUseYn}
|
||||
onChange={e => setFilterUseYn(e.target.value)}
|
||||
className="px-2 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-2 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="Y">사용</option>
|
||||
@ -441,7 +441,7 @@ const LayerPanel = () => {
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-3 py-1.5 text-xs border border-border text-text-2 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
@ -450,7 +450,7 @@ const LayerPanel = () => {
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-border shrink-0 font-korean">
|
||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@ -458,29 +458,29 @@ const LayerPanel = () => {
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-text-3 text-sm font-korean">
|
||||
<div className="flex items-center justify-center h-full text-fg-disabled text-sm font-korean">
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">레이어코드</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어전체명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-12 whitespace-nowrap">레벨</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">WMS레이어명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-16">정렬</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-28">등록일시</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-20">사용여부</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-28 whitespace-nowrap">액션</th>
|
||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">레이어코드</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">레이어명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">레이어전체명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">레벨</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">WMS레이어명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-16">정렬</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-28">등록일시</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-20">사용여부</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-28 whitespace-nowrap">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-12 text-center text-text-3 text-sm font-korean">
|
||||
<td colSpan={10} className="px-4 py-12 text-center text-fg-disabled text-sm font-korean">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -488,42 +488,42 @@ const LayerPanel = () => {
|
||||
items.map((item, idx) => (
|
||||
<tr
|
||||
key={item.layerCd}
|
||||
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
{/* 번호 */}
|
||||
<td className="px-4 py-3 text-xs text-text-3 font-mono">
|
||||
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
{/* 레이어코드 */}
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">
|
||||
{item.layerCd}
|
||||
</td>
|
||||
{/* 레이어명 */}
|
||||
<td className="px-4 py-3 text-xs text-text-1 font-korean">
|
||||
<td className="px-4 py-3 text-xs text-fg font-korean">
|
||||
{item.layerNm}
|
||||
</td>
|
||||
{/* 레이어전체명 */}
|
||||
<td className="px-4 py-3 text-xs text-text-2 font-korean max-w-[200px]">
|
||||
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
||||
<span className="block truncate" title={item.layerFullNm}>
|
||||
{item.layerFullNm}
|
||||
</span>
|
||||
</td>
|
||||
{/* 레벨 */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-[rgba(6,182,212,0.3)]">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-color-accent border border-[rgba(6,182,212,0.3)]">
|
||||
{item.layerLevel}
|
||||
</span>
|
||||
</td>
|
||||
{/* WMS레이어명 */}
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||
{item.wmsLayerNm ?? <span className="text-text-3">-</span>}
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">
|
||||
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||
</td>
|
||||
{/* 정렬순서 */}
|
||||
<td className="px-4 py-3 text-xs text-text-3 text-center font-mono">
|
||||
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
||||
{item.sortOrd}
|
||||
</td>
|
||||
{/* 등록일시 */}
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
|
||||
{item.regDtm ?? '-'}
|
||||
</td>
|
||||
{/* 사용여부 토글 */}
|
||||
@ -540,10 +540,10 @@ const LayerPanel = () => {
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-40 ${
|
||||
item.useYn === 'Y' && item.parentUseYn !== 'N'
|
||||
? 'bg-primary-cyan'
|
||||
? 'bg-color-accent'
|
||||
: item.useYn === 'Y' && item.parentUseYn === 'N'
|
||||
? 'bg-primary-cyan/40'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||
? 'bg-color-accent/40'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@ -558,7 +558,7 @@ const LayerPanel = () => {
|
||||
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'edit', data: item })}
|
||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
|
||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
@ -580,29 +580,29 @@ const LayerPanel = () => {
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1 shrink-0">
|
||||
<span className="text-[11px] text-text-3 font-korean">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface shrink-0">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{buildPageButtons().map((btn, i) =>
|
||||
btn === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1.5 text-[11px] text-text-3">…</span>
|
||||
<span key={`e${i}`} className="px-1.5 text-[11px] text-fg-disabled">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={btn}
|
||||
onClick={() => setPage(btn)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
|
||||
page === btn
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'border border-border text-text-3 hover:bg-[rgba(255,255,255,0.04)]'
|
||||
? 'bg-color-accent text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'border border-stroke text-fg-disabled hover:bg-[rgba(255,255,255,0.04)]'
|
||||
}`}
|
||||
>
|
||||
{btn}
|
||||
@ -612,7 +612,7 @@ const LayerPanel = () => {
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -75,15 +75,15 @@ function MapBaseModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">
|
||||
{isEdit ? '지도 수정' : '지도 등록'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-3 hover:text-text-1 transition-colors"
|
||||
className="text-fg-disabled hover:text-fg transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
@ -96,7 +96,7 @@ function MapBaseModal({
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* 지도 이름 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
지도 이름 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -104,13 +104,13 @@ function MapBaseModal({
|
||||
value={form.mapNm}
|
||||
onChange={e => setField('mapNm', e.target.value)}
|
||||
placeholder="지도 이름을 입력하세요"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지도 키 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
지도 키 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -119,19 +119,19 @@ function MapBaseModal({
|
||||
onChange={e => setField('mapKey', e.target.value)}
|
||||
placeholder="고유 식별 키 (영문/숫자)"
|
||||
disabled={isEdit}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지도 레벨 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
지도 레벨
|
||||
</label>
|
||||
<select
|
||||
value={form.mapLevelCd}
|
||||
onChange={e => setField('mapLevelCd', e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{MAP_LEVEL_OPTIONS.map(opt => (
|
||||
@ -142,7 +142,7 @@ function MapBaseModal({
|
||||
|
||||
{/* 파일 소스 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
파일 소스
|
||||
</label>
|
||||
<input
|
||||
@ -150,13 +150,13 @@ function MapBaseModal({
|
||||
value={form.mapSrc}
|
||||
onChange={e => setField('mapSrc', e.target.value)}
|
||||
placeholder="타일 URL 또는 파일 경로"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상세 설명 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
상세 설명
|
||||
</label>
|
||||
<textarea
|
||||
@ -164,13 +164,13 @@ function MapBaseModal({
|
||||
value={form.mapDc}
|
||||
onChange={e => setField('mapDc', e.target.value)}
|
||||
placeholder="지도에 대한 설명을 입력하세요"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean resize-none"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 사용여부 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
사용여부
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -178,7 +178,7 @@ function MapBaseModal({
|
||||
type="button"
|
||||
onClick={() => 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-primary-cyan' : 'bg-border'
|
||||
form.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@ -187,7 +187,7 @@ function MapBaseModal({
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-xs text-text-2 font-korean">
|
||||
<span className="text-xs text-fg-sub font-korean">
|
||||
{form.useYn === 'Y' ? '사용' : '미사용'}
|
||||
</span>
|
||||
</div>
|
||||
@ -200,18 +200,18 @@ function MapBaseModal({
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-stroke">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-xs border border-border text-text-2 rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
>
|
||||
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
|
||||
</button>
|
||||
@ -339,16 +339,16 @@ function MapBasePanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-bg-0">
|
||||
<div className="flex flex-col h-full overflow-hidden bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">지도 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}건</p>
|
||||
<h1 className="text-lg font-bold text-fg font-korean">지도 관리</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}건</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openModal(null)}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
>
|
||||
+ 등록
|
||||
</button>
|
||||
@ -370,8 +370,8 @@ function MapBasePanel() {
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-bg-1 z-10">
|
||||
<tr className="border-b border-border text-text-3">
|
||||
<thead className="sticky top-0 bg-bg-surface z-10">
|
||||
<tr className="border-b border-stroke text-fg-disabled">
|
||||
<th className="w-12 py-3 text-center">번호</th>
|
||||
<th className="py-3 text-left pl-4">지도 리스트</th>
|
||||
<th className="w-20 py-3 text-center">지도 레벨</th>
|
||||
@ -385,25 +385,25 @@ function MapBasePanel() {
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-text-3 font-korean">
|
||||
<td colSpan={8} className="py-8 text-center text-fg-disabled font-korean">
|
||||
불러오는 중...
|
||||
</td>
|
||||
</tr>
|
||||
) : items.map((item, idx) => (
|
||||
<tr
|
||||
key={item.mapSn}
|
||||
className="border-b border-border hover:bg-bg-1/50 transition-colors"
|
||||
className="border-b border-stroke hover:bg-bg-surface/50 transition-colors"
|
||||
>
|
||||
<td className="py-3 text-center text-text-3">
|
||||
<td className="py-3 text-center text-fg-disabled">
|
||||
{(page - 1) * 10 + idx + 1}
|
||||
</td>
|
||||
<td className="py-3 pl-4">
|
||||
<span className="text-text-1 font-korean">{item.mapNm}</span>
|
||||
<span className="ml-2 text-[10px] text-text-3 font-mono">{item.mapKey}</span>
|
||||
<span className="text-fg font-korean">{item.mapNm}</span>
|
||||
<span className="ml-2 text-[10px] text-fg-disabled font-mono">{item.mapKey}</span>
|
||||
</td>
|
||||
<td className="py-3 text-center text-text-2">{item.mapLevelCd ?? '-'}</td>
|
||||
<td className="py-3 text-center text-text-2 font-korean">{item.regNm ?? '-'}</td>
|
||||
<td className="py-3 text-center text-text-3">{item.regDtm ?? '-'}</td>
|
||||
<td className="py-3 text-center text-fg-sub">{item.mapLevelCd ?? '-'}</td>
|
||||
<td className="py-3 text-center text-fg-sub font-korean">{item.regNm ?? '-'}</td>
|
||||
<td className="py-3 text-center text-fg-disabled">{item.regDtm ?? '-'}</td>
|
||||
<td
|
||||
className="py-3 text-center cursor-pointer"
|
||||
onClick={() => handleToggleUse(item)}
|
||||
@ -412,7 +412,7 @@ function MapBasePanel() {
|
||||
<button
|
||||
type="button"
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
item.useYn === 'Y' ? 'bg-primary-cyan' : 'bg-border'
|
||||
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@ -426,7 +426,7 @@ function MapBasePanel() {
|
||||
<td className="py-3 text-center">
|
||||
<button
|
||||
onClick={() => openModal(item)}
|
||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan hover:bg-[rgba(6,182,212,0.25)]"
|
||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)]"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
@ -444,7 +444,7 @@ function MapBasePanel() {
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-text-3 font-korean">
|
||||
<div className="flex items-center justify-center h-32 text-xs text-fg-disabled font-korean">
|
||||
등록된 지도가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
@ -452,11 +452,11 @@ function MapBasePanel() {
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-1 py-2 border-t border-border">
|
||||
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
@ -469,7 +469,7 @@ function MapBasePanel() {
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`w-7 h-7 text-xs rounded ${
|
||||
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
|
||||
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-fg-disabled hover:bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
@ -479,7 +479,7 @@ function MapBasePanel() {
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
||||
@ -124,7 +124,7 @@ function MenusPanel() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-text-3 text-sm font-korean">메뉴 설정을 불러오는 중...</div>
|
||||
<div className="text-fg-disabled text-sm font-korean">메뉴 설정을 불러오는 중...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -133,18 +133,18 @@ function MenusPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">메뉴 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다</p>
|
||||
<h1 className="text-lg font-bold text-fg font-korean">메뉴 관리</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||
hasChanges && !saving
|
||||
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
@ -182,10 +182,10 @@ function MenusPanel() {
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeMenu ? (
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-primary-cyan bg-bg-1 shadow-lg opacity-90 max-w-[700px]">
|
||||
<span className="text-text-3 text-xs">⠿</span>
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-color-accent bg-bg-surface shadow-lg opacity-90 max-w-[700px]">
|
||||
<span className="text-fg-disabled text-xs">⠿</span>
|
||||
<span className="text-[16px]">{activeMenu.icon}</span>
|
||||
<span className="text-[13px] font-semibold text-text-1 font-korean">{activeMenu.label}</span>
|
||||
<span className="text-[13px] font-semibold text-fg font-korean">{activeMenu.label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
||||
@ -79,7 +79,7 @@ function StatusBadge({
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-2 text-t2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
조회 중...
|
||||
</span>
|
||||
@ -129,11 +129,11 @@ function ForecastTable({
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{TABLE_HEADERS.map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap"
|
||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
@ -143,16 +143,16 @@ function ForecastTable({
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 6 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{TABLE_HEADERS.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-2 rounded w-16" />
|
||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.jobName} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||
<tr key={row.jobName} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
||||
{row.modelName}
|
||||
</td>
|
||||
@ -203,7 +203,7 @@ export default function MonitorForecastPanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1 shrink-0">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-t1">수치예측자료 모니터링</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
@ -219,7 +219,7 @@ export default function MonitorForecastPanel() {
|
||||
<button
|
||||
onClick={() => void fetchData()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-2 hover:bg-bg-3 text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||
@ -240,7 +240,7 @@ export default function MonitorForecastPanel() {
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b border-border-1 shrink-0 px-5">
|
||||
<div className="flex gap-0 border-b border-stroke-1 shrink-0 px-5">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@ -257,7 +257,7 @@ export default function MonitorForecastPanel() {
|
||||
</div>
|
||||
|
||||
{/* 상태 표시줄 */}
|
||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-border-1 bg-bg-0">
|
||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
||||
<StatusBadge loading={loading} errorCount={errorCount} total={totalCount} />
|
||||
{!loading && totalCount > 0 && (
|
||||
<span className="text-xs text-t3">모델 {totalCount}개</span>
|
||||
|
||||
@ -76,7 +76,7 @@ const fmt = (v: number | null | undefined, digits = 1): string =>
|
||||
function StatusBadge({ loading, errorCount, total }: { loading: boolean; errorCount: number; total: number }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-2 text-t2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
조회 중...
|
||||
</span>
|
||||
@ -113,25 +113,25 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{headers.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-2 rounded w-12" />
|
||||
<div className="h-3 bg-bg-elevated rounded w-12" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.stationName} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||
<tr key={row.stationName} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
|
||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.water_temp)}</td>
|
||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_temp)}</td>
|
||||
@ -165,25 +165,25 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 3 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{headers.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-2 rounded w-12" />
|
||||
<div className="h-3 bg-bg-elevated rounded w-12" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.stationName} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||
<tr key={row.stationName} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
|
||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
|
||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
|
||||
@ -215,25 +215,25 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{headers.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-2 rounded w-14" />
|
||||
<div className="h-3 bg-bg-elevated rounded w-14" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.regId} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||
<tr key={row.regId} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
|
||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
|
||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
|
||||
@ -366,7 +366,7 @@ export default function MonitorRealtimePanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1 shrink-0">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-t1">실시간 관측자료 모니터링</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
@ -377,7 +377,7 @@ export default function MonitorRealtimePanel() {
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-2 hover:bg-bg-3 text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||
@ -393,7 +393,7 @@ export default function MonitorRealtimePanel() {
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b border-border-1 shrink-0 px-5">
|
||||
<div className="flex gap-0 border-b border-stroke-1 shrink-0 px-5">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@ -410,7 +410,7 @@ export default function MonitorRealtimePanel() {
|
||||
</div>
|
||||
|
||||
{/* 상태 표시줄 */}
|
||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-border-1 bg-bg-0">
|
||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
||||
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
|
||||
<span className="text-xs text-t3">
|
||||
{activeTab === 'khoa' && `관측소 ${totalCount}개`}
|
||||
|
||||
@ -72,7 +72,7 @@ function fetchVesselMonitorData(): Promise<VesselMonitorRow[]> {
|
||||
function StatusBadge({ loading, onCount, total }: { loading: boolean; onCount: number; total: number }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-2 text-t2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
조회 중...
|
||||
</span>
|
||||
@ -137,25 +137,25 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{HEADERS.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{HEADERS.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-2 rounded w-14" />
|
||||
<div className="h-3 bg-bg-elevated rounded w-14" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row, idx) => (
|
||||
<tr key={`${row.institutionCode}-${row.systemName}`} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||
<tr key={`${row.institutionCode}-${row.systemName}`} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.institution}</td>
|
||||
<td className="px-3 py-2 text-t2">{row.institutionCode}</td>
|
||||
@ -203,7 +203,7 @@ export default function MonitorVesselPanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1 shrink-0">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-t1">선박위치정보 모니터링</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
@ -214,7 +214,7 @@ export default function MonitorVesselPanel() {
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-2 hover:bg-bg-3 text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||
@ -230,7 +230,7 @@ export default function MonitorVesselPanel() {
|
||||
</div>
|
||||
|
||||
{/* 상태 표시줄 */}
|
||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-border-1 bg-bg-0">
|
||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
||||
<StatusBadge loading={loading} onCount={onCount} total={rows.length} />
|
||||
<span className="text-xs text-t3">
|
||||
연계 채널 {rows.length}개 (ON: {onCount} / OFF: {rows.length - onCount})
|
||||
|
||||
@ -130,14 +130,14 @@ function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) {
|
||||
switch (state) {
|
||||
case 'explicit-granted':
|
||||
classes = readOnly
|
||||
? `${baseClasses} bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan cursor-default`
|
||||
: `${baseClasses} bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan cursor-pointer hover:bg-[rgba(6,182,212,0.3)]`
|
||||
? `${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-primary-cyan`
|
||||
: `${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':
|
||||
@ -147,7 +147,7 @@ function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) {
|
||||
icon = '—'
|
||||
break
|
||||
case 'forced-denied':
|
||||
classes = `${baseClasses} bg-bg-2 border-border text-text-3 opacity-40 cursor-not-allowed`
|
||||
classes = `${baseClasses} bg-bg-elevated border-stroke text-fg-disabled opacity-40 cursor-not-allowed`
|
||||
icon = '—'
|
||||
break
|
||||
}
|
||||
@ -195,13 +195,13 @@ function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm, readO
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<tr className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="px-3 py-1">
|
||||
<div className="flex items-center" style={{ paddingLeft: indent }}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => onToggleExpand(node.code)}
|
||||
className="w-4 h-4 flex items-center justify-center text-text-3 hover:text-text-1 transition-colors mr-1 flex-shrink-0"
|
||||
className="w-4 h-4 flex items-center justify-center text-fg-disabled hover:text-fg transition-colors mr-1 flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
@ -211,13 +211,13 @@ function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm, readO
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 mr-1 flex-shrink-0 text-center text-text-3 text-[9px]">
|
||||
<span className="w-4 mr-1 flex-shrink-0 text-center text-fg-disabled text-[9px]">
|
||||
{node.level > 0 ? '├' : ''}
|
||||
</span>
|
||||
)}
|
||||
{node.icon && <span className="mr-1 flex-shrink-0 text-[11px]">{node.icon}</span>}
|
||||
<div className="min-w-0">
|
||||
<div className={`text-[11px] font-korean truncate ${node.level === 0 ? 'font-bold text-text-1' : 'font-medium text-text-2'}`}>
|
||||
<div className={`text-[11px] font-korean truncate ${node.level === 0 ? 'font-bold text-fg' : 'font-medium text-fg-sub'}`}>
|
||||
{node.name}
|
||||
</div>
|
||||
</div>
|
||||
@ -260,9 +260,9 @@ function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm, readO
|
||||
// ─── 공통 범례 컴포넌트 ──────────────────────────────
|
||||
function PermLegend() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-1.5 border-b border-border bg-bg-1 text-[9px] text-text-3 font-korean" style={{ flexShrink: 0 }}>
|
||||
<div className="flex items-center gap-3 px-4 py-1.5 border-b border-stroke bg-bg-surface text-[9px] text-fg-disabled font-korean" style={{ flexShrink: 0 }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan text-center text-[8px] leading-3">✓</span>
|
||||
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent text-center text-[8px] leading-3">✓</span>
|
||||
허용
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
@ -274,10 +274,10 @@ function PermLegend() {
|
||||
거부
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-bg-2 border-border text-text-3 opacity-40 text-center text-[8px] leading-3">—</span>
|
||||
<span className="inline-block w-3 h-3 rounded border bg-bg-elevated border-stroke text-fg-disabled opacity-40 text-center text-[8px] leading-3">—</span>
|
||||
비활성
|
||||
</span>
|
||||
<span className="ml-2 border-l border-border pl-2 text-text-3">
|
||||
<span className="ml-2 border-l border-stroke pl-2 text-fg-disabled">
|
||||
R=조회 C=생성 U=수정 D=삭제
|
||||
</span>
|
||||
</div>
|
||||
@ -364,10 +364,10 @@ function RolePermTab({
|
||||
return (
|
||||
<>
|
||||
{/* 헤더 액션 버튼 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border" style={{ flexShrink: 0 }}>
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-stroke" style={{ flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => { setShowCreateForm(true); setCreateError('') }}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||||
>
|
||||
+ 역할 추가
|
||||
</button>
|
||||
@ -375,18 +375,18 @@ function RolePermTab({
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all font-korean ${
|
||||
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||
dirty ? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
</button>
|
||||
{saveError && (
|
||||
<span className="text-[11px] text-status-red font-korean">{saveError}</span>
|
||||
<span className="text-[11px] text-color-danger font-korean">{saveError}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 역할 탭 바 */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border bg-bg-1 overflow-x-auto" style={{ flexShrink: 0 }}>
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface overflow-x-auto" style={{ flexShrink: 0 }}>
|
||||
{roles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx)
|
||||
const isSelected = selectedRoleSn === role.sn
|
||||
@ -397,7 +397,7 @@ function RolePermTab({
|
||||
className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all font-korean ${
|
||||
isSelected
|
||||
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
||||
: 'border border-border text-text-3 hover:border-border'
|
||||
: 'border border-stroke text-fg-disabled hover:border-stroke'
|
||||
}`}
|
||||
style={isSelected ? { borderColor: color, color } : undefined}
|
||||
>
|
||||
@ -413,7 +413,7 @@ function RolePermTab({
|
||||
onBlur={() => handleSaveRoleName(role.sn)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
className="w-20 px-1 py-0 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean"
|
||||
className="w-20 px-1 py-0 text-[11px] font-semibold bg-bg-elevated border border-color-accent rounded text-center text-fg focus:outline-none font-korean"
|
||||
/>
|
||||
) : (
|
||||
<span onDoubleClick={() => handleStartEditName(role)}>
|
||||
@ -421,7 +421,7 @@ function RolePermTab({
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
|
||||
{role.isDefault && <span className="ml-1 text-[9px] text-primary-cyan">기본</span>}
|
||||
{role.isDefault && <span className="ml-1 text-[9px] text-color-accent">기본</span>}
|
||||
</button>
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
@ -429,8 +429,8 @@ function RolePermTab({
|
||||
onClick={() => toggleDefault(role.sn)}
|
||||
className={`px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||||
role.isDefault
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||
: 'text-text-3 hover:text-text-2'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent'
|
||||
: 'text-fg-disabled hover:text-fg-sub'
|
||||
}`}
|
||||
title="신규 사용자 기본 역할 설정"
|
||||
>
|
||||
@ -439,7 +439,7 @@ function RolePermTab({
|
||||
{role.code !== 'ADMIN' && (
|
||||
<button
|
||||
onClick={() => handleDeleteRole(role.sn, role.name)}
|
||||
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
||||
className="w-5 h-5 flex items-center justify-center text-fg-disabled hover:text-red-400 transition-colors"
|
||||
title="역할 삭제"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
@ -460,12 +460,12 @@ function RolePermTab({
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-text-3 font-korean min-w-[200px]">기능</th>
|
||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
||||
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-fg-disabled font-korean min-w-[200px]">기능</th>
|
||||
{OPER_CODES.map(oper => (
|
||||
<th key={oper} className="px-1 py-1.5 text-center w-12">
|
||||
<div className="text-[10px] font-semibold text-text-2">{OPER_LABELS[oper]}</div>
|
||||
<div className="text-[8px] text-text-3 font-korean">{OPER_FULL_LABELS[oper]}</div>
|
||||
<div className="text-[10px] font-semibold text-fg-sub">{OPER_LABELS[oper]}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-korean">{OPER_FULL_LABELS[oper]}</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@ -485,7 +485,7 @@ function RolePermTab({
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
|
||||
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
|
||||
역할을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
@ -493,40 +493,40 @@ function RolePermTab({
|
||||
{/* 역할 생성 모달 */}
|
||||
{showCreateForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-[400px] bg-bg-1 rounded-lg border border-border shadow-2xl">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="text-sm font-bold text-text-1 font-korean">새 역할 추가</h3>
|
||||
<div className="w-[400px] bg-bg-surface rounded-lg border border-stroke shadow-2xl">
|
||||
<div className="px-5 py-4 border-b border-stroke">
|
||||
<h3 className="text-sm font-bold text-fg font-korean">새 역할 추가</h3>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 코드</label>
|
||||
<label className="text-[11px] text-fg-disabled font-korean block mb-1">역할 코드</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleCode}
|
||||
onChange={(e) => setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
|
||||
placeholder="CUSTOM_ROLE"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-3 mt-1 font-korean">영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)</p>
|
||||
<p className="text-[10px] text-fg-disabled mt-1 font-korean">영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 이름</label>
|
||||
<label className="text-[11px] text-fg-disabled font-korean block mb-1">역할 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleName}
|
||||
onChange={(e) => setNewRoleName(e.target.value)}
|
||||
placeholder="사용자 정의 역할"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">설명 (선택)</label>
|
||||
<label className="text-[11px] text-fg-disabled font-korean block mb-1">설명 (선택)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleDesc}
|
||||
onChange={(e) => setNewRoleDesc(e.target.value)}
|
||||
placeholder="역할에 대한 설명"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
{createError && (
|
||||
@ -535,17 +535,17 @@ function RolePermTab({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||||
<div className="px-5 py-3 border-t border-stroke flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
className="px-4 py-2 text-xs text-text-3 border border-border rounded-md hover:bg-bg-hover font-korean"
|
||||
className="px-4 py-2 text-xs text-fg-disabled border border-stroke rounded-md hover:bg-bg-surface-hover font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateRole}
|
||||
disabled={!newRoleCode || !newRoleName || creating}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
|
||||
>
|
||||
{creating ? '생성 중...' : '생성'}
|
||||
</button>
|
||||
@ -710,8 +710,8 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* 사용자 검색/선택 */}
|
||||
<div className="px-4 py-2.5 border-b border-border" style={{ flexShrink: 0 }}>
|
||||
<label className="text-[10px] text-text-3 font-korean block mb-1.5">사용자 선택</label>
|
||||
<div className="px-4 py-2.5 border-b border-stroke" style={{ flexShrink: 0 }}>
|
||||
<label className="text-[10px] text-fg-disabled font-korean block mb-1.5">사용자 선택</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<input
|
||||
type="text"
|
||||
@ -728,32 +728,32 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'}
|
||||
disabled={loadingUsers}
|
||||
className="w-full max-w-sm px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean disabled:opacity-50"
|
||||
className="w-full max-w-sm px-3 py-2 text-xs 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 && (
|
||||
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-1 border border-border rounded-md shadow-xl z-20 overflow-auto max-h-52">
|
||||
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 overflow-auto max-h-52">
|
||||
{filteredUsers.map(user => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className="w-full px-3 py-2 text-left hover:bg-bg-hover transition-colors flex items-center gap-2"
|
||||
className="w-full px-3 py-2 text-left hover:bg-bg-surface-hover transition-colors flex items-center gap-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-text-1 font-korean truncate">
|
||||
<div className="text-xs font-semibold text-fg font-korean truncate">
|
||||
{user.name}
|
||||
{user.rank && <span className="ml-1 text-[10px] text-text-3 font-korean">{user.rank}</span>}
|
||||
{user.rank && <span className="ml-1 text-[10px] text-fg-disabled font-korean">{user.rank}</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3 font-mono truncate">{user.account}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono truncate">{user.account}</div>
|
||||
</div>
|
||||
{user.orgName && (
|
||||
<span className="text-[10px] text-text-3 font-korean flex-shrink-0 truncate max-w-[100px]">{user.orgName}</span>
|
||||
<span className="text-[10px] text-fg-disabled font-korean flex-shrink-0 truncate max-w-[100px]">{user.orgName}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && (
|
||||
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-1 border border-border rounded-md shadow-xl z-20 px-3 py-2 text-xs text-text-3 font-korean">
|
||||
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 px-3 py-2 text-xs text-fg-disabled font-korean">
|
||||
검색 결과 없음
|
||||
</div>
|
||||
)}
|
||||
@ -763,16 +763,16 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
{selectedUser ? (
|
||||
<>
|
||||
{/* 역할 할당 섹션 */}
|
||||
<div className="px-4 py-2.5 border-b border-border bg-bg-1" style={{ flexShrink: 0 }}>
|
||||
<div className="px-4 py-2.5 border-b border-stroke bg-bg-surface" style={{ flexShrink: 0 }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold text-text-2 font-korean">역할 할당</span>
|
||||
<span className="text-[10px] font-semibold text-fg-sub font-korean">역할 할당</span>
|
||||
<button
|
||||
onClick={handleSaveRoles}
|
||||
disabled={!rolesDirty || savingRoles}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all font-korean ${
|
||||
rolesDirty
|
||||
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{savingRoles ? '저장 중...' : '역할 저장'}
|
||||
@ -787,7 +787,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
key={role.sn}
|
||||
className={[
|
||||
'flex items-center gap-1 px-2 py-1 rounded-md border cursor-pointer transition-all font-korean text-[11px] select-none',
|
||||
isChecked ? '' : 'border-border text-text-3 hover:border-text-2',
|
||||
isChecked ? '' : 'border-stroke text-fg-disabled hover:border-text-2',
|
||||
].join(' ')}
|
||||
style={isChecked ? { borderColor: color, color, backgroundColor: `${color}18` } : undefined}
|
||||
>
|
||||
@ -806,8 +806,8 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
</div>
|
||||
|
||||
{/* 유효 권한 매트릭스 (읽기 전용) */}
|
||||
<div className="px-4 py-1.5 border-b border-border bg-bg-1 text-[9px] text-text-3 font-korean" style={{ flexShrink: 0 }}>
|
||||
<span className="font-semibold text-text-2">유효 권한 (읽기 전용)</span>
|
||||
<div className="px-4 py-1.5 border-b border-stroke bg-bg-surface text-[9px] text-fg-disabled font-korean" style={{ flexShrink: 0 }}>
|
||||
<span className="font-semibold text-fg-sub">유효 권한 (읽기 전용)</span>
|
||||
<span className="ml-2">— 할당된 역할의 권한 합산 결과</span>
|
||||
</div>
|
||||
|
||||
@ -817,12 +817,12 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[240px]">기능</th>
|
||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean min-w-[240px]">기능</th>
|
||||
{OPER_CODES.map(oper => (
|
||||
<th key={oper} className="px-2 py-3 text-center w-16">
|
||||
<div className="text-[11px] font-semibold text-text-2">{OPER_LABELS[oper]}</div>
|
||||
<div className="text-[9px] text-text-3 font-korean">{OPER_FULL_LABELS[oper]}</div>
|
||||
<div className="text-[11px] font-semibold text-fg-sub">{OPER_LABELS[oper]}</div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean">{OPER_FULL_LABELS[oper]}</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@ -843,13 +843,13 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
|
||||
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
|
||||
역할을 하나 이상 할당하면 유효 권한이 표시됩니다
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
|
||||
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
|
||||
사용자를 선택하세요
|
||||
</div>
|
||||
)}
|
||||
@ -1058,25 +1058,25 @@ function PermissionsPanel() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||
return <div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">불러오는 중...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border" style={{ flexShrink: 0 }}>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke" style={{ flexShrink: 0 }}>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-text-1 font-korean">권한 관리</h1>
|
||||
<p className="text-[10px] text-text-3 mt-0.5 font-korean">역할별 리소스 × CRUD 권한 설정</p>
|
||||
<h1 className="text-sm font-bold text-fg font-korean">권한 관리</h1>
|
||||
<p className="text-[10px] text-fg-disabled mt-0.5 font-korean">역할별 리소스 × CRUD 권한 설정</p>
|
||||
</div>
|
||||
{/* 탭 전환 */}
|
||||
<div className="flex items-center gap-1 p-1 bg-bg-2 rounded-lg border border-border">
|
||||
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg border border-stroke">
|
||||
<button
|
||||
onClick={() => setActiveTab('role')}
|
||||
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||
activeTab === 'role'
|
||||
? 'bg-primary-cyan text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'text-text-3 hover:text-text-2'
|
||||
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'text-fg-disabled hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
그룹별
|
||||
@ -1085,8 +1085,8 @@ function PermissionsPanel() {
|
||||
onClick={() => setActiveTab('user')}
|
||||
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||
activeTab === 'user'
|
||||
? 'bg-primary-cyan text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'text-text-3 hover:text-text-2'
|
||||
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'text-fg-disabled hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
사용자별
|
||||
|
||||
@ -127,11 +127,11 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="px-6 py-4 border-b border-stroke shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">{title}</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}개</p>
|
||||
<h1 className="text-lg font-bold text-fg font-korean">{title}</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -141,12 +141,12 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="레이어코드 / 레이어명 검색"
|
||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
<select
|
||||
value={filterUseYn}
|
||||
onChange={e => setFilterUseYn(e.target.value)}
|
||||
className="px-2 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-2 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="Y">사용</option>
|
||||
@ -154,7 +154,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-3 py-1.5 text-xs border border-border text-text-2 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
@ -163,7 +163,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-border shrink-0 font-korean">
|
||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@ -171,28 +171,28 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-text-3 text-sm font-korean">
|
||||
<div className="flex items-center justify-center h-full text-fg-disabled text-sm font-korean">
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">레이어코드</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어전체명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-12 whitespace-nowrap">레벨</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">WMS레이어명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-16">정렬</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-28">등록일시</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-20">사용여부</th>
|
||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">레이어코드</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">레이어명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">레이어전체명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">레벨</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">WMS레이어명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-16">정렬</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-28">등록일시</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-20">사용여부</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-12 text-center text-text-3 text-sm font-korean">
|
||||
<td colSpan={9} className="px-4 py-12 text-center text-fg-disabled text-sm font-korean">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -200,34 +200,34 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
items.map((item, idx) => (
|
||||
<tr
|
||||
key={item.layerCd}
|
||||
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-xs text-text-3 font-mono">
|
||||
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">
|
||||
{item.layerCd}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-1 font-korean">
|
||||
<td className="px-4 py-3 text-xs text-fg font-korean">
|
||||
{item.layerNm}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-2 font-korean max-w-[200px]">
|
||||
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
||||
<span className="block truncate" title={item.layerFullNm}>
|
||||
{item.layerFullNm}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-[rgba(6,182,212,0.3)]">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-color-accent border border-[rgba(6,182,212,0.3)]">
|
||||
{item.layerLevel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||
{item.wmsLayerNm ?? <span className="text-text-3">-</span>}
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">
|
||||
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-3 text-center font-mono">
|
||||
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
||||
{item.sortOrd}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
|
||||
{item.regDtm ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
@ -237,8 +237,8 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||||
item.useYn === 'Y'
|
||||
? 'bg-primary-cyan'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||
? 'bg-color-accent'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@ -258,29 +258,29 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1 shrink-0">
|
||||
<span className="text-[11px] text-text-3 font-korean">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface shrink-0">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{buildPageButtons().map((btn, i) =>
|
||||
btn === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1.5 text-[11px] text-text-3">…</span>
|
||||
<span key={`e${i}`} className="px-1.5 text-[11px] text-fg-disabled">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={btn}
|
||||
onClick={() => setPage(btn)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
|
||||
page === btn
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'border border-border text-text-3 hover:bg-[rgba(255,255,255,0.04)]'
|
||||
? 'bg-color-accent text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'border border-stroke text-fg-disabled hover:bg-[rgba(255,255,255,0.04)]'
|
||||
}`}
|
||||
>
|
||||
{btn}
|
||||
@ -290,7 +290,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -53,31 +53,31 @@ function SettingsPanel() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||
return <div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">불러오는 중...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">시스템 설정</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">사용자 등록 및 권한 관련 시스템 설정을 관리합니다</p>
|
||||
<div className="px-6 py-4 border-b border-stroke">
|
||||
<h1 className="text-lg font-bold text-fg font-korean">시스템 설정</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">사용자 등록 및 권한 관련 시스템 설정을 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-6">
|
||||
<div className="max-w-[640px] flex flex-col gap-6">
|
||||
{/* 사용자 등록 설정 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">사용자 등록 설정</h2>
|
||||
<p className="text-[11px] text-text-3 mt-0.5 font-korean">신규 사용자 등록 시 적용되는 정책을 설정합니다</p>
|
||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 등록 설정</h2>
|
||||
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">신규 사용자 등록 시 적용되는 정책을 설정합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{/* 자동 승인 */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-[13px] font-semibold text-text-1 font-korean">자동 승인</div>
|
||||
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
|
||||
<div className="text-[13px] font-semibold text-fg font-korean">자동 승인</div>
|
||||
<p className="text-[11px] text-fg-disabled mt-1 font-korean leading-relaxed">
|
||||
활성화하면 신규 사용자가 등록 즉시 <span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||||
비활성화하면 관리자 승인 전까지 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로 대기합니다.
|
||||
</p>
|
||||
@ -86,7 +86,7 @@ function SettingsPanel() {
|
||||
onClick={() => handleToggle('autoApprove')}
|
||||
disabled={saving}
|
||||
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
|
||||
settings?.autoApprove ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||
settings?.autoApprove ? 'bg-color-accent' : 'bg-bg-card border border-stroke'
|
||||
} ${saving ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
@ -100,9 +100,9 @@ function SettingsPanel() {
|
||||
{/* 기본 역할 자동 할당 */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-[13px] font-semibold text-text-1 font-korean">기본 역할 자동 할당</div>
|
||||
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
|
||||
활성화하면 신규 사용자에게 <span className="text-primary-cyan font-semibold">기본 역할</span>이 자동으로 할당됩니다.
|
||||
<div className="text-[13px] font-semibold text-fg font-korean">기본 역할 자동 할당</div>
|
||||
<p className="text-[11px] text-fg-disabled mt-1 font-korean leading-relaxed">
|
||||
활성화하면 신규 사용자에게 <span className="text-color-accent font-semibold">기본 역할</span>이 자동으로 할당됩니다.
|
||||
기본 역할은 권한 관리 탭에서 설정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
@ -110,7 +110,7 @@ function SettingsPanel() {
|
||||
onClick={() => handleToggle('defaultRole')}
|
||||
disabled={saving}
|
||||
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
|
||||
settings?.defaultRole ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||
settings?.defaultRole ? 'bg-color-accent' : 'bg-bg-card border border-stroke'
|
||||
} ${saving ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
@ -124,15 +124,15 @@ function SettingsPanel() {
|
||||
</div>
|
||||
|
||||
{/* OAuth 설정 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">Google OAuth 설정</h2>
|
||||
<p className="text-[11px] text-text-3 mt-0.5 font-korean">Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다</p>
|
||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">Google OAuth 설정</h2>
|
||||
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex-1 mr-4 mb-3">
|
||||
<div className="text-[13px] font-semibold text-text-1 font-korean mb-1">자동 승인 도메인</div>
|
||||
<p className="text-[11px] text-text-3 font-korean leading-relaxed mb-3">
|
||||
<div className="text-[13px] font-semibold text-fg font-korean mb-1">자동 승인 도메인</div>
|
||||
<p className="text-[11px] text-fg-disabled font-korean leading-relaxed mb-3">
|
||||
지정된 도메인의 Google 계정은 가입 즉시 <span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||||
미지정 도메인은 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로 관리자 승인이 필요합니다.
|
||||
여러 도메인은 쉼표(,)로 구분합니다.
|
||||
@ -143,7 +143,7 @@ function SettingsPanel() {
|
||||
value={oauthDomainInput}
|
||||
onChange={(e) => setOauthDomainInput(e.target.value)}
|
||||
placeholder="gcsc.co.kr, example.com"
|
||||
className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
className="flex-1 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
@ -161,8 +161,8 @@ function SettingsPanel() {
|
||||
disabled={savingOAuth || oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
|
||||
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
|
||||
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{savingOAuth ? '저장 중...' : '저장'}
|
||||
@ -175,7 +175,7 @@ function SettingsPanel() {
|
||||
<span
|
||||
key={domain}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
|
||||
style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,0.25)' }}
|
||||
style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--color-accent)', border: '1px solid rgba(6,182,212,0.25)' }}
|
||||
>
|
||||
@{domain}
|
||||
</span>
|
||||
@ -186,15 +186,15 @@ function SettingsPanel() {
|
||||
</div>
|
||||
|
||||
{/* 현재 설정 상태 요약 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">설정 상태 요약</h2>
|
||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">설정 상태 요약</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex flex-col gap-3 text-[12px] font-korean">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} />
|
||||
<span className="text-text-2">
|
||||
<span className="text-fg-sub">
|
||||
신규 사용자 등록 시{' '}
|
||||
{settings?.autoApprove ? (
|
||||
<span className="text-green-400 font-semibold">즉시 활성화</span>
|
||||
@ -204,24 +204,24 @@ function SettingsPanel() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-text-3'}`} />
|
||||
<span className="text-text-2">
|
||||
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-fg-disabled'}`} />
|
||||
<span className="text-fg-sub">
|
||||
기본 역할 자동 할당{' '}
|
||||
{settings?.defaultRole ? (
|
||||
<span className="text-green-400 font-semibold">활성</span>
|
||||
) : (
|
||||
<span className="text-text-3 font-semibold">비활성</span>
|
||||
<span className="text-fg-disabled font-semibold">비활성</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-text-3'}`} />
|
||||
<span className="text-text-2">
|
||||
<span className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-fg-disabled'}`} />
|
||||
<span className="text-fg-sub">
|
||||
Google OAuth 자동 승인 도메인{' '}
|
||||
{oauthSettings?.autoApproveDomains ? (
|
||||
<span className="text-blue-400 font-semibold font-mono">{oauthSettings.autoApproveDomains}</span>
|
||||
) : (
|
||||
<span className="text-text-3 font-semibold">미설정</span>
|
||||
<span className="text-fg-disabled font-semibold">미설정</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -47,15 +47,15 @@ function SortableMenuItem({
|
||||
style={style}
|
||||
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
|
||||
menu.enabled
|
||||
? 'bg-bg-1 border-border'
|
||||
: 'bg-bg-0 border-border opacity-50'
|
||||
? 'bg-bg-surface border-stroke'
|
||||
: 'bg-bg-base border-stroke opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing w-6 h-7 flex items-center justify-center text-text-3 hover:text-text-1 transition-all shrink-0"
|
||||
className="cursor-grab active:cursor-grabbing w-6 h-7 flex items-center justify-center text-fg-disabled hover:text-fg transition-all shrink-0"
|
||||
title="드래그하여 순서 변경"
|
||||
>
|
||||
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
|
||||
@ -64,13 +64,13 @@ function SortableMenuItem({
|
||||
<circle cx="3" cy="14" r="1.5" /><circle cx="9" cy="14" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
|
||||
<span className="text-fg-disabled text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => onEmojiPickerToggle(emojiPickerId === menu.id ? null : menu.id)}
|
||||
className="w-10 h-10 text-[20px] bg-bg-2 border border-border rounded flex items-center justify-center hover:border-primary-cyan transition-all"
|
||||
className="w-10 h-10 text-[20px] bg-bg-elevated border border-stroke rounded flex items-center justify-center hover:border-color-accent transition-all"
|
||||
title="아이콘 변경"
|
||||
>
|
||||
{menu.icon}
|
||||
@ -94,13 +94,13 @@ function SortableMenuItem({
|
||||
type="text"
|
||||
value={menu.label}
|
||||
onChange={(e) => onLabelChange(menu.id, e.target.value)}
|
||||
className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||
className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-elevated border border-stroke rounded px-2 text-fg focus:border-color-accent focus:outline-none"
|
||||
/>
|
||||
<div className="text-[10px] text-text-3 font-mono mt-0.5">{menu.id}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono mt-0.5">{menu.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onEditEnd}
|
||||
className="shrink-0 px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||||
className="shrink-0 px-2 py-1 text-[10px] font-semibold text-color-accent border border-color-accent rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||||
>
|
||||
완료
|
||||
</button>
|
||||
@ -109,14 +109,14 @@ function SortableMenuItem({
|
||||
<>
|
||||
<span className="text-[16px] shrink-0">{menu.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-text-1' : 'text-text-3'}`}>
|
||||
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-fg' : 'text-fg-disabled'}`}>
|
||||
{menu.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3 font-mono">{menu.id}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono">{menu.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onEditStart(menu.id)}
|
||||
className="shrink-0 w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-[11px] flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
|
||||
className="shrink-0 w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-[11px] flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all"
|
||||
title="라벨/아이콘 편집"
|
||||
>
|
||||
✏️
|
||||
@ -128,7 +128,7 @@ function SortableMenuItem({
|
||||
<button
|
||||
onClick={() => onToggle(menu.id)}
|
||||
className={`relative w-10 h-5 rounded-full transition-all ${
|
||||
menu.enabled ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||
menu.enabled ? 'bg-color-accent' : 'bg-bg-card border border-stroke'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@ -141,14 +141,14 @@ function SortableMenuItem({
|
||||
<button
|
||||
onClick={() => onMove(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-xs flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMove(idx, 1)}
|
||||
disabled={idx === totalCount - 1}
|
||||
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-xs flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
|
||||
@ -84,13 +84,13 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">사용자 등록</h2>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 등록</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-3 hover:text-text-1 transition-colors"
|
||||
className="text-fg-disabled hover:text-fg transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
@ -103,7 +103,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* 계정 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
계정 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -111,13 +111,13 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="로그인 계정 ID"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
비밀번호 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -125,13 +125,13 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="초기 비밀번호"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 사용자명 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
사용자명 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -139,13 +139,13 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="실명"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 직급 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
직급
|
||||
</label>
|
||||
<input
|
||||
@ -153,19 +153,19 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
value={rank}
|
||||
onChange={e => setRank(e.target.value)}
|
||||
placeholder="예: 팀장, 주임 등"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 소속 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
소속
|
||||
</label>
|
||||
<select
|
||||
value={orgSn}
|
||||
onChange={e => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">소속 없음</option>
|
||||
{allOrgs.map(org => (
|
||||
@ -178,7 +178,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
|
||||
{/* 이메일 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
@ -186,18 +186,18 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="이메일 주소"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 역할 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
역할
|
||||
</label>
|
||||
<div className="bg-bg-2 border border-border rounded-md p-2 space-y-1 max-h-[120px] overflow-y-auto">
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2 space-y-1 max-h-[120px] overflow-y-auto">
|
||||
{allRoles.length === 0 ? (
|
||||
<p className="text-[10px] text-text-3 font-korean px-1 py-1">역할 없음</p>
|
||||
<p className="text-[10px] text-fg-disabled font-korean px-1 py-1">역할 없음</p>
|
||||
) : allRoles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx)
|
||||
return (
|
||||
@ -212,7 +212,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
style={{ accentColor: color }}
|
||||
/>
|
||||
<span className="text-xs font-korean" style={{ color }}>{role.name}</span>
|
||||
<span className="text-[10px] text-text-3 font-mono">{role.code}</span>
|
||||
<span className="text-[10px] text-fg-disabled font-mono">{role.code}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
@ -226,18 +226,18 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-stroke">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-xs border border-border text-text-2 rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
>
|
||||
{submitting ? '등록 중...' : '등록'}
|
||||
</button>
|
||||
@ -321,14 +321,14 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">사용자 정보</h2>
|
||||
<p className="text-[10px] text-text-3 font-mono mt-0.5">{user.account}</p>
|
||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 정보</h2>
|
||||
<p className="text-[10px] text-fg-disabled font-mono mt-0.5">{user.account}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-3 hover:text-text-1 transition-colors">
|
||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
@ -338,34 +338,34 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||||
{/* 기본 정보 수정 */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-text-2 font-korean mb-3">기본 정보 수정</h3>
|
||||
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-3">기본 정보 수정</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[10px] text-text-3 font-korean mb-1">사용자명</label>
|
||||
<label className="block text-[10px] text-fg-disabled font-korean mb-1">사용자명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] text-text-3 font-korean mb-1">직급</label>
|
||||
<label className="block text-[10px] text-fg-disabled font-korean mb-1">직급</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rank}
|
||||
onChange={e => setRank(e.target.value)}
|
||||
placeholder="예: 팀장"
|
||||
className="w-full px-3 py-1.5 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-text-3 font-korean mb-1">소속</label>
|
||||
<label className="block text-[10px] text-fg-disabled font-korean mb-1">소속</label>
|
||||
<select
|
||||
value={orgSn}
|
||||
onChange={e => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
|
||||
className="w-full px-3 py-1.5 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">소속 없음</option>
|
||||
{allOrgs.map(org => (
|
||||
@ -379,7 +379,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
<button
|
||||
onClick={handleSaveInfo}
|
||||
disabled={saving || !name.trim()}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
>
|
||||
{saving ? '저장 중...' : '정보 저장'}
|
||||
</button>
|
||||
@ -387,20 +387,20 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-border" />
|
||||
<div className="border-t border-stroke" />
|
||||
|
||||
{/* 비밀번호 초기화 */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-text-2 font-korean mb-3">비밀번호 초기화</h3>
|
||||
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-3">비밀번호 초기화</h3>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-text-3 font-korean mb-1">새 비밀번호</label>
|
||||
<label className="block text-[10px] text-fg-disabled font-korean mb-1">새 비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="새 비밀번호 입력"
|
||||
className="w-full px-3 py-1.5 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@ -419,16 +419,16 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-text-3 font-korean mt-1.5">초기화 후 사용자에게 새 비밀번호를 전달하세요.</p>
|
||||
<p className="text-[9px] text-fg-disabled font-korean mt-1.5">초기화 후 사용자에게 새 비밀번호를 전달하세요.</p>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-border" />
|
||||
<div className="border-t border-stroke" />
|
||||
|
||||
{/* 계정 잠금 해제 */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-text-2 font-korean mb-2">계정 상태</h3>
|
||||
<div className="flex items-center justify-between bg-bg-2 border border-border rounded-md px-4 py-3">
|
||||
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-2">계정 상태</h3>
|
||||
<div className="flex items-center justify-between bg-bg-elevated border border-stroke rounded-md px-4 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold font-korean ${(statusLabels[user.status] || statusLabels.INACTIVE).color}`}>
|
||||
@ -442,7 +442,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
)}
|
||||
</div>
|
||||
{user.status === 'LOCKED' && (
|
||||
<p className="text-[9px] text-text-3 font-korean mt-1">
|
||||
<p className="text-[9px] text-fg-disabled font-korean mt-1">
|
||||
비밀번호 5회 이상 오류로 잠금 처리됨
|
||||
</p>
|
||||
)}
|
||||
@ -461,23 +461,23 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
|
||||
{/* 기타 정보 (읽기 전용) */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-text-2 font-korean mb-2">기타 정보</h3>
|
||||
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-2">기타 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] font-korean">
|
||||
<div className="bg-bg-2 border border-border rounded px-3 py-2">
|
||||
<span className="text-text-3">이메일: </span>
|
||||
<span className="text-text-2 font-mono">{user.email || '-'}</span>
|
||||
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
|
||||
<span className="text-fg-disabled">이메일: </span>
|
||||
<span className="text-fg-sub font-mono">{user.email || '-'}</span>
|
||||
</div>
|
||||
<div className="bg-bg-2 border border-border rounded px-3 py-2">
|
||||
<span className="text-text-3">OAuth: </span>
|
||||
<span className="text-text-2">{user.oauthProvider || '-'}</span>
|
||||
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
|
||||
<span className="text-fg-disabled">OAuth: </span>
|
||||
<span className="text-fg-sub">{user.oauthProvider || '-'}</span>
|
||||
</div>
|
||||
<div className="bg-bg-2 border border-border rounded px-3 py-2">
|
||||
<span className="text-text-3">최종 로그인: </span>
|
||||
<span className="text-text-2">{user.lastLogin ? formatDate(user.lastLogin) : '-'}</span>
|
||||
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
|
||||
<span className="text-fg-disabled">최종 로그인: </span>
|
||||
<span className="text-fg-sub">{user.lastLogin ? formatDate(user.lastLogin) : '-'}</span>
|
||||
</div>
|
||||
<div className="bg-bg-2 border border-border rounded px-3 py-2">
|
||||
<span className="text-text-3">등록일: </span>
|
||||
<span className="text-text-2">{formatDate(user.regDtm)}</span>
|
||||
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
|
||||
<span className="text-fg-disabled">등록일: </span>
|
||||
<span className="text-fg-sub">{formatDate(user.regDtm)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -495,10 +495,10 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-end px-6 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-end px-6 py-3 border-t border-stroke">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-xs border border-border text-text-2 rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
@ -641,11 +641,11 @@ function UsersPanel() {
|
||||
<>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">사용자 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {filteredUsers.length}명</p>
|
||||
<h1 className="text-lg font-bold text-fg font-korean">사용자 관리</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {filteredUsers.length}명</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<span className="px-2.5 py-1 text-[10px] font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
|
||||
@ -658,7 +658,7 @@ function UsersPanel() {
|
||||
<select
|
||||
value={orgFilter}
|
||||
onChange={e => { setOrgFilter(e.target.value); setCurrentPage(1) }}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">전체 소속</option>
|
||||
{allOrgs.map(org => (
|
||||
@ -671,7 +671,7 @@ function UsersPanel() {
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="PENDING">승인대기</option>
|
||||
@ -686,11 +686,11 @@ function UsersPanel() {
|
||||
placeholder="이름, 계정 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowRegisterModal(true)}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
>
|
||||
+ 사용자 등록
|
||||
</button>
|
||||
@ -700,28 +700,28 @@ function UsersPanel() {
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">
|
||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">ID</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">사용자명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">직급</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">소속</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">이메일</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">역할</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">승인상태</th>
|
||||
<th className="px-4 py-3 text-right text-[11px] font-semibold text-text-3 font-korean">관리</th>
|
||||
<tr className="border-b border-stroke bg-bg-surface">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">ID</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">사용자명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">직급</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">소속</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">이메일</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">역할</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">승인상태</th>
|
||||
<th className="px-4 py-3 text-right text-[11px] font-semibold text-fg-disabled font-korean">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-6 py-10 text-center text-xs text-text-3 font-korean">
|
||||
<td colSpan={9} className="px-6 py-10 text-center text-xs text-fg-disabled font-korean">
|
||||
조회된 사용자가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -731,34 +731,34 @@ function UsersPanel() {
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
{/* 번호 */}
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono text-center">{rowNum}</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">{rowNum}</td>
|
||||
|
||||
{/* ID(account) */}
|
||||
<td className="px-4 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
|
||||
<td className="px-4 py-3 text-[12px] text-fg-sub font-mono">{user.account}</td>
|
||||
|
||||
{/* 사용자명 */}
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setDetailUser(user)}
|
||||
className="text-[12px] text-primary-cyan font-semibold font-korean hover:underline"
|
||||
className="text-[12px] text-color-accent font-semibold font-korean hover:underline"
|
||||
>
|
||||
{user.name}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* 직급 */}
|
||||
<td className="px-4 py-3 text-[12px] text-text-2 font-korean">{user.rank || '-'}</td>
|
||||
<td className="px-4 py-3 text-[12px] text-fg-sub font-korean">{user.rank || '-'}</td>
|
||||
|
||||
{/* 소속 */}
|
||||
<td className="px-4 py-3 text-[12px] text-text-2 font-korean">
|
||||
<td className="px-4 py-3 text-[12px] text-fg-sub font-korean">
|
||||
{user.orgAbbr || user.orgName || '-'}
|
||||
</td>
|
||||
|
||||
{/* 이메일 */}
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
|
||||
{user.email || '-'}
|
||||
</td>
|
||||
|
||||
@ -787,9 +787,9 @@ function UsersPanel() {
|
||||
</span>
|
||||
)
|
||||
}) : (
|
||||
<span className="text-[10px] text-text-3 font-korean">역할 없음</span>
|
||||
<span className="text-[10px] text-fg-disabled font-korean">역할 없음</span>
|
||||
)}
|
||||
<span className="text-[10px] text-text-3 ml-0.5">
|
||||
<span className="text-[10px] text-fg-disabled ml-0.5">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
@ -799,9 +799,9 @@ function UsersPanel() {
|
||||
{roleEditUserId === user.id && (
|
||||
<div
|
||||
ref={roleDropdownRef}
|
||||
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-1 border border-border rounded-lg shadow-lg min-w-[200px]"
|
||||
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-surface border border-stroke rounded-lg shadow-lg min-w-[200px]"
|
||||
>
|
||||
<div className="text-[10px] text-text-3 font-korean font-semibold mb-1.5 px-1">역할 선택</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean font-semibold mb-1.5 px-1">역할 선택</div>
|
||||
{allRoles.map((role, roleIdx) => {
|
||||
const color = getRoleColor(role.code, roleIdx)
|
||||
return (
|
||||
@ -816,21 +816,21 @@ function UsersPanel() {
|
||||
style={{ accentColor: color }}
|
||||
/>
|
||||
<span className="text-xs font-korean" style={{ color }}>{role.name}</span>
|
||||
<span className="text-[10px] text-text-3 font-mono">{role.code}</span>
|
||||
<span className="text-[10px] text-fg-disabled font-mono">{role.code}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-border">
|
||||
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-stroke">
|
||||
<button
|
||||
onClick={() => setRoleEditUserId(null)}
|
||||
className="px-3 py-1 text-[10px] text-text-3 border border-border rounded hover:bg-bg-hover font-korean"
|
||||
className="px-3 py-1 text-[10px] text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSaveRoles(user.id)}
|
||||
disabled={selectedRoleSns.length === 0}
|
||||
className="px-3 py-1 text-[10px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
|
||||
className="px-3 py-1 text-[10px] font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
@ -878,7 +878,7 @@ function UsersPanel() {
|
||||
{user.status === 'ACTIVE' && (
|
||||
<button
|
||||
onClick={() => handleDeactivate(user.id)}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
className="px-2 py-1 text-[10px] font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
@ -903,15 +903,15 @@ function UsersPanel() {
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1">
|
||||
<span className="text-[11px] text-text-3 font-korean">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
{(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, totalCount)} / {totalCount}명
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
@ -926,7 +926,7 @@ function UsersPanel() {
|
||||
}, [])
|
||||
.map((item, i) =>
|
||||
item === '...' ? (
|
||||
<span key={`ellipsis-${i}`} className="px-2 text-[11px] text-text-3">…</span>
|
||||
<span key={`ellipsis-${i}`} className="px-2 text-[11px] text-fg-disabled">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
@ -934,8 +934,8 @@ function UsersPanel() {
|
||||
className="px-2.5 py-1 text-[11px] border rounded transition-all font-mono"
|
||||
style={
|
||||
currentPage === item
|
||||
? { borderColor: 'var(--cyan)', color: 'var(--cyan)', background: 'rgba(6,182,212,0.1)' }
|
||||
: { borderColor: 'var(--border)', color: 'var(--t3)' }
|
||||
? { borderColor: 'var(--color-accent)', color: 'var(--color-accent)', background: 'rgba(6,182,212,0.1)' }
|
||||
: { borderColor: 'var(--border)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>
|
||||
{item}
|
||||
@ -945,7 +945,7 @@ function UsersPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -69,16 +69,16 @@ function VesselMaterialsPanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">방제선 보유자재 현황</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {filtered.length}개 기관 (방제선 보유)</p>
|
||||
<h1 className="text-lg font-bold text-fg font-korean">방제선 보유자재 현황</h1>
|
||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {filtered.length}개 기관 (방제선 보유)</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={regionFilter}
|
||||
onChange={handleFilterChange(setRegionFilter)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="전체">전체 관할청</option>
|
||||
<option value="남해">남해청</option>
|
||||
@ -90,7 +90,7 @@ function VesselMaterialsPanel() {
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={handleFilterChange(setTypeFilter)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||
>
|
||||
<option value="전체">전체 유형</option>
|
||||
{typeOptions.map(t => (
|
||||
@ -102,11 +102,11 @@ function VesselMaterialsPanel() {
|
||||
placeholder="기관명, 주소 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||
/>
|
||||
<button
|
||||
onClick={load}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-2 border border-border text-text-2 hover:border-primary-cyan hover:text-primary-cyan transition-all font-korean"
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
@ -116,36 +116,36 @@ function VesselMaterialsPanel() {
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">
|
||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-primary-cyan bg-primary-cyan/5">방제선</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">유회수기</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">이송펌프</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">방제차량</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">살포장치</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
||||
<tr className="border-b border-stroke bg-bg-surface">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">유형</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">관할청</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">기관명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">주소</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-color-accent bg-color-accent/5">방제선</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">유회수기</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">이송펌프</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">방제차량</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">살포장치</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean">총자산</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-xs text-text-3 font-korean">
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-xs text-fg-disabled font-korean">
|
||||
조회된 기관이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : paged.map((org, idx) => (
|
||||
<tr key={org.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono text-center">
|
||||
<tr key={org.id} className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
|
||||
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@ -153,31 +153,31 @@ function VesselMaterialsPanel() {
|
||||
{org.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-korean">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
|
||||
{regionShort(org.jurisdiction)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-1 font-korean font-semibold">
|
||||
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
|
||||
{org.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
|
||||
{org.address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-primary-cyan font-semibold bg-primary-cyan/5">
|
||||
{org.vessel > 0 ? org.vessel : <span className="text-text-3">—</span>}
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-color-accent font-semibold bg-color-accent/5">
|
||||
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.skimmer > 0 ? org.skimmer : <span className="text-text-3">—</span>}
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
|
||||
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.pump > 0 ? org.pump : <span className="text-text-3">—</span>}
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
|
||||
{org.pump > 0 ? org.pump : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.vehicle > 0 ? org.vehicle : <span className="text-text-3">—</span>}
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
|
||||
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.sprayer > 0 ? org.sprayer : <span className="text-text-3">—</span>}
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
|
||||
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-color-accent">
|
||||
{org.totalAssets.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
@ -189,8 +189,8 @@ function VesselMaterialsPanel() {
|
||||
|
||||
{/* 합계 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center gap-4 px-6 py-2 border-t border-border bg-bg-0/80">
|
||||
<span className="text-[10px] text-text-3 font-korean font-semibold mr-auto">
|
||||
<div className="flex items-center gap-4 px-6 py-2 border-t border-stroke bg-bg-base/80">
|
||||
<span className="text-[10px] text-fg-disabled font-korean font-semibold mr-auto">
|
||||
합계 ({filtered.length}개 기관)
|
||||
</span>
|
||||
{[
|
||||
@ -201,9 +201,9 @@ function VesselMaterialsPanel() {
|
||||
{ label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대', active: false },
|
||||
{ label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '', active: true },
|
||||
].map((t) => (
|
||||
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-primary-cyan/10' : ''}`}>
|
||||
<span className={`text-[9px] font-korean ${t.active ? 'text-primary-cyan' : 'text-text-3'}`}>{t.label}</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${t.active ? 'text-primary-cyan' : 'text-text-1'}`}>
|
||||
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-color-accent/10' : ''}`}>
|
||||
<span className={`text-[9px] font-korean ${t.active ? 'text-color-accent' : 'text-fg-disabled'}`}>{t.label}</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${t.active ? 'text-color-accent' : 'text-fg'}`}>
|
||||
{t.value.toLocaleString()}{t.unit}
|
||||
</span>
|
||||
</div>
|
||||
@ -213,15 +213,15 @@ function VesselMaterialsPanel() {
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
||||
<span className="text-[11px] text-text-3 font-korean">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} / 전체 {filtered.length}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
@ -231,7 +231,7 @@ function VesselMaterialsPanel() {
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
|
||||
style={p === safePage
|
||||
? { borderColor: 'var(--cyan)', color: 'var(--cyan)', background: 'rgba(6,182,212,0.1)' }
|
||||
? { borderColor: 'var(--color-accent)', color: 'var(--color-accent)', background: 'rgba(6,182,212,0.1)' }
|
||||
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
|
||||
}
|
||||
>
|
||||
@ -241,7 +241,7 @@ function VesselMaterialsPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
||||
@ -125,18 +125,18 @@ export default function VesselSignalPanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-border-1">
|
||||
<h2 className="text-sm font-semibold text-text-1">선박신호 수신 현황</h2>
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke-1">
|
||||
<h2 className="text-sm font-semibold text-fg">선박신호 수신 현황</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1"
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg"
|
||||
/>
|
||||
<button
|
||||
onClick={load}
|
||||
className="px-3 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
|
||||
className="px-3 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
@ -147,7 +147,7 @@ export default function VesselSignalPanel() {
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-xs text-text-3">로딩 중...</span>
|
||||
<span className="text-xs text-fg-disabled">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
@ -174,14 +174,14 @@ export default function VesselSignalPanel() {
|
||||
{HOURS.map(h => (
|
||||
<span
|
||||
key={h}
|
||||
className="absolute text-[10px] text-text-3 font-mono"
|
||||
className="absolute text-[10px] text-fg-disabled font-mono"
|
||||
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
{String(h).padStart(2, '0')}시
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className="absolute text-[10px] text-text-3 font-mono"
|
||||
className="absolute text-[10px] text-fg-disabled font-mono"
|
||||
style={{ right: 0 }}
|
||||
>
|
||||
24시
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'var(--red)',
|
||||
ADMIN: 'var(--color-danger)',
|
||||
HQ_CLEANUP: '#34d399',
|
||||
MANAGER: 'var(--orange)',
|
||||
USER: 'var(--cyan)',
|
||||
VIEWER: 'var(--t3)',
|
||||
MANAGER: 'var(--color-warning)',
|
||||
USER: 'var(--color-accent)',
|
||||
VIEWER: 'var(--fg-disabled)',
|
||||
}
|
||||
|
||||
export const CUSTOM_ROLE_COLORS = [
|
||||
@ -18,7 +18,7 @@ export const statusLabels: Record<string, { label: string; color: string; dot: s
|
||||
PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' },
|
||||
ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' },
|
||||
LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' },
|
||||
INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' },
|
||||
INACTIVE: { label: '비활성', color: 'text-fg-disabled', dot: 'bg-fg-disabled' },
|
||||
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
|
||||
}
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ export function AerialTheoryView() {
|
||||
const [activePanel, setActivePanel] = useState(0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full flex-1 overflow-hidden bg-bg-0">
|
||||
<div className="flex flex-col h-full w-full flex-1 overflow-hidden bg-bg-base">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
@ -36,21 +36,21 @@ export function AerialTheoryView() {
|
||||
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center text-xl border" style={{ background: 'linear-gradient(135deg,rgba(249,115,22,.2),rgba(234,179,8,.15))', borderColor: 'rgba(249,115,22,.3)' }}>📐</div>
|
||||
<div>
|
||||
<div className="text-base font-bold">해양 항공탐색 · 원격탐사 이론</div>
|
||||
<div className="text-[10px] text-text-3 mt-0.5">유출유 원격탐지 · 항공감시 기법 · ESI 방제정보지도 · 등록특허 10-1567431 기반</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5">유출유 원격탐지 · 항공감시 기법 · ESI 방제정보지도 · 등록특허 10-1567431 기반</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내부 네비게이션 */}
|
||||
<div className="flex gap-[3px] bg-bg-3 rounded-lg p-1 mb-5 border border-border">
|
||||
<div className="flex gap-[3px] bg-bg-card rounded-lg p-1 mb-5 border border-stroke">
|
||||
{panels.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setActivePanel(p.id)}
|
||||
className={`flex-1 py-2 text-[10px] rounded-md border-none cursor-pointer transition-colors ${
|
||||
activePanel === p.id
|
||||
? 'bg-[rgba(6,182,212,.15)] text-primary-cyan font-bold'
|
||||
: 'bg-transparent text-text-3 font-normal'
|
||||
? 'bg-[rgba(6,182,212,.15)] text-color-accent font-bold'
|
||||
: 'bg-transparent text-fg-disabled font-normal'
|
||||
}`}
|
||||
>
|
||||
{p.icon} {p.label}
|
||||
@ -68,93 +68,93 @@ export function AerialTheoryView() {
|
||||
// ═══ PANEL 0: 개요 ═══
|
||||
const panel0Html = `
|
||||
<div style="background:linear-gradient(135deg,rgba(249,115,22,.06),rgba(234,179,8,.04));border:1px solid rgba(249,115,22,.2);border-radius:12px;padding:20px;margin-bottom:16px;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--orange),var(--yellow),var(--cyan))"></div>
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--color-warning),var(--color-caution),var(--color-accent))"></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
||||
<div style="width:30px;height:30px;border-radius:8px;background:rgba(249,115,22,.15);display:flex;align-items:center;justify-content:center;font-size:16px">📋</div>
|
||||
<span style="font-size:14px;font-weight:700;color:var(--t1);font-family:var(--fK)">항공탐색이란?</span>
|
||||
<span style="font-size:14px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean)">항공탐색이란?</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--t2);font-family:var(--fK);line-height:1.8">
|
||||
해양 유류오염 발생 시 <b style="color:var(--orange)">드론·유인항공기·위성</b>을 활용하여 유출유의 위치·면적·오염형태를 실시간으로 탐지하고, 방제정보지도(ESI)와 연계하여 <b style="color:var(--cyan)">확산예측 모델의 검증·보정 입력자료</b>로 활용하는 통합 탐색 체계입니다.
|
||||
<div style="font-size:11px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8">
|
||||
해양 유류오염 발생 시 <b style="color:var(--color-warning)">드론·유인항공기·위성</b>을 활용하여 유출유의 위치·면적·오염형태를 실시간으로 탐지하고, 방제정보지도(ESI)와 연계하여 <b style="color:var(--color-accent)">확산예측 모델의 검증·보정 입력자료</b>로 활용하는 통합 탐색 체계입니다.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
||||
<div style="width:30px;height:30px;border-radius:8px;background:rgba(6,182,212,.15);display:flex;align-items:center;justify-content:center;font-size:16px">🎯</div>
|
||||
<span style="font-size:14px;font-weight:700;color:var(--t1);font-family:var(--fK)">WING 항공탐색 목적</span>
|
||||
<span style="font-size:14px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean)">WING 항공탐색 목적</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:11px;color:var(--t2);font-family:var(--fK)">
|
||||
<div style="padding:6px 10px;background:rgba(249,115,22,.04);border:1px solid rgba(249,115,22,.12);border-radius:6px"><span style="color:var(--orange);font-weight:700">①</span> 유출유 <b>실시간 위치·면적</b> 파악 → 확산예측 초기조건 보정</div>
|
||||
<div style="padding:6px 10px;background:rgba(6,182,212,.04);border:1px solid rgba(6,182,212,.12);border-radius:6px"><span style="color:var(--cyan);font-weight:700">②</span> <b>수온(SST) 위성자료</b> 실시간 수신 → 유출유 풍화모델 입력</div>
|
||||
<div style="padding:6px 10px;background:rgba(34,197,94,.04);border:1px solid rgba(34,197,94,.12);border-radius:6px"><span style="color:var(--green);font-weight:700">③</span> <b>ESI 환경민감자원</b> 현장 확인 → 방제 우선순위 결정</div>
|
||||
<div style="padding:6px 10px;background:rgba(168,85,247,.04);border:1px solid rgba(168,85,247,.12);border-radius:6px"><span style="color:var(--purple);font-weight:700">④</span> 드론 <b>3D 재구성</b> → 선박 식별·오염원 정밀 분석</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:11px;color:var(--fg-sub);font-family:var(--font-korean)">
|
||||
<div style="padding:6px 10px;background:rgba(249,115,22,.04);border:1px solid rgba(249,115,22,.12);border-radius:6px"><span style="color:var(--color-warning);font-weight:700">①</span> 유출유 <b>실시간 위치·면적</b> 파악 → 확산예측 초기조건 보정</div>
|
||||
<div style="padding:6px 10px;background:rgba(6,182,212,.04);border:1px solid rgba(6,182,212,.12);border-radius:6px"><span style="color:var(--color-accent);font-weight:700">②</span> <b>수온(SST) 위성자료</b> 실시간 수신 → 유출유 풍화모델 입력</div>
|
||||
<div style="padding:6px 10px;background:rgba(34,197,94,.04);border:1px solid rgba(34,197,94,.12);border-radius:6px"><span style="color:var(--color-success);font-weight:700">③</span> <b>ESI 환경민감자원</b> 현장 확인 → 방제 우선순위 결정</div>
|
||||
<div style="padding:6px 10px;background:rgba(168,85,247,.04);border:1px solid rgba(168,85,247,.12);border-radius:6px"><span style="color:var(--color-tertiary);font-weight:700">④</span> 드론 <b>3D 재구성</b> → 선박 식별·오염원 정밀 분석</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:16px;margin-bottom:16px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:14px">⚙️ 항공탐색 → 방제대응 통합 흐름</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:16px;margin-bottom:16px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:14px">⚙️ 항공탐색 → 방제대응 통합 흐름</div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:0;flex-wrap:wrap;padding:8px 0">
|
||||
<div style="padding:10px 14px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:8px;text-align:center;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:10px 14px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:8px;text-align:center;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="font-size:16px;margin-bottom:4px">🛸✈🛰</div>
|
||||
<div style="font-weight:700;color:var(--orange)">탐지 플랫폼</div>
|
||||
<div style="color:var(--t3)">드론·항공기·위성</div>
|
||||
<div style="font-weight:700;color:var(--color-warning)">탐지 플랫폼</div>
|
||||
<div style="color:var(--fg-disabled)">드론·항공기·위성</div>
|
||||
</div>
|
||||
<div style="width:24px;height:1px;background:var(--bdL);position:relative"><div style="position:absolute;right:-4px;top:-4px;color:var(--t3);font-size:10px">▶</div></div>
|
||||
<div style="padding:10px 14px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:8px;text-align:center;font-size:9px;font-family:var(--fK)">
|
||||
<div style="width:24px;height:1px;background:var(--stroke-light);position:relative"><div style="position:absolute;right:-4px;top:-4px;color:var(--fg-disabled);font-size:10px">▶</div></div>
|
||||
<div style="padding:10px 14px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:8px;text-align:center;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="font-size:16px;margin-bottom:4px">📷🌡️📡</div>
|
||||
<div style="font-weight:700;color:var(--cyan)">센서 데이터</div>
|
||||
<div style="color:var(--t3)">광학·IR·SAR·SST</div>
|
||||
<div style="font-weight:700;color:var(--color-accent)">센서 데이터</div>
|
||||
<div style="color:var(--fg-disabled)">광학·IR·SAR·SST</div>
|
||||
</div>
|
||||
<div style="width:24px;height:1px;background:var(--bdL);position:relative"><div style="position:absolute;right:-4px;top:-4px;color:var(--t3);font-size:10px">▶</div></div>
|
||||
<div style="padding:10px 14px;background:rgba(234,179,8,.08);border:1px solid rgba(234,179,8,.2);border-radius:8px;text-align:center;font-size:9px;font-family:var(--fK)">
|
||||
<div style="width:24px;height:1px;background:var(--stroke-light);position:relative"><div style="position:absolute;right:-4px;top:-4px;color:var(--fg-disabled);font-size:10px">▶</div></div>
|
||||
<div style="padding:10px 14px;background:rgba(234,179,8,.08);border:1px solid rgba(234,179,8,.2);border-radius:8px;text-align:center;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="font-size:16px;margin-bottom:4px">🗺️📐</div>
|
||||
<div style="font-weight:700;color:var(--yellow)">영상 처리</div>
|
||||
<div style="color:var(--t3)">좌표변환·면적산정</div>
|
||||
<div style="font-weight:700;color:var(--color-caution)">영상 처리</div>
|
||||
<div style="color:var(--fg-disabled)">좌표변환·면적산정</div>
|
||||
</div>
|
||||
<div style="width:24px;height:1px;background:var(--bdL);position:relative"><div style="position:absolute;right:-4px;top:-4px;color:var(--t3);font-size:10px">▶</div></div>
|
||||
<div style="padding:10px 14px;background:rgba(59,130,246,.08);border:2px solid rgba(59,130,246,.3);border-radius:8px;text-align:center;font-size:9px;font-family:var(--fK)">
|
||||
<div style="width:24px;height:1px;background:var(--stroke-light);position:relative"><div style="position:absolute;right:-4px;top:-4px;color:var(--fg-disabled);font-size:10px">▶</div></div>
|
||||
<div style="padding:10px 14px;background:rgba(59,130,246,.08);border:2px solid rgba(59,130,246,.3);border-radius:8px;text-align:center;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="font-size:16px;margin-bottom:4px">🌊⚙️</div>
|
||||
<div style="font-weight:700;color:var(--blue)">확산모델 입력</div>
|
||||
<div style="color:var(--t3)">유출위치·유출량·SST</div>
|
||||
<div style="font-weight:700;color:var(--color-info)">확산모델 입력</div>
|
||||
<div style="color:var(--fg-disabled)">유출위치·유출량·SST</div>
|
||||
</div>
|
||||
<div style="width:24px;height:1px;background:var(--bdL);position:relative"><div style="position:absolute;right:-4px;top:-4px;color:var(--t3);font-size:10px">▶</div></div>
|
||||
<div style="padding:10px 14px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:8px;text-align:center;font-size:9px;font-family:var(--fK)">
|
||||
<div style="width:24px;height:1px;background:var(--stroke-light);position:relative"><div style="position:absolute;right:-4px;top:-4px;color:var(--fg-disabled);font-size:10px">▶</div></div>
|
||||
<div style="padding:10px 14px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:8px;text-align:center;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="font-size:16px;margin-bottom:4px">🗂️🚢</div>
|
||||
<div style="font-weight:700;color:var(--green)">방제 의사결정</div>
|
||||
<div style="color:var(--t3)">ESI 연계·자원 배치</div>
|
||||
<div style="font-weight:700;color:var(--color-success)">방제 의사결정</div>
|
||||
<div style="color:var(--fg-disabled)">ESI 연계·자원 배치</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-top:3px solid var(--orange)">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--orange);font-family:var(--fK);margin-bottom:8px">🛸 드론 (UAV)</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">현장 즉시 투입·저고도 정밀 촬영. 광학·적외선 카메라 탑재, 실시간 영상 전송, 3D 재구성.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--t2)">고도: 30~500m · 속도: 15~25m/s</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--t2)">GSD: 1~5cm/px (100m 고도 기준)</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--t2)">운용반경: 5~30km · 체공: 30~90분</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-top:3px solid var(--color-warning)">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--color-warning);font-family:var(--font-korean);margin-bottom:8px">🛸 드론 (UAV)</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">현장 즉시 투입·저고도 정밀 촬영. 광학·적외선 카메라 탑재, 실시간 영상 전송, 3D 재구성.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--fg-sub)">고도: 30~500m · 속도: 15~25m/s</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--fg-sub)">GSD: 1~5cm/px (100m 고도 기준)</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--fg-sub)">운용반경: 5~30km · 체공: 30~90분</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-top:3px solid var(--blue)">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--blue);font-family:var(--fK);margin-bottom:8px">✈️ 유인 항공기</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">광역 탐색·장시간 체공. 광학·IR·SAR·SLAR·UV 형광 센서 복합 탑재. 해경 해양오염 감시 항공기.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:3px 7px;background:rgba(59,130,246,.06);border-radius:3px;color:var(--t2)">고도: 300~3,000m · 속도: 60~150m/s</div>
|
||||
<div style="padding:3px 7px;background:rgba(59,130,246,.06);border-radius:3px;color:var(--t2)">탐색폭: 5~50km · 체공: 4~8시간</div>
|
||||
<div style="padding:3px 7px;background:rgba(59,130,246,.06);border-radius:3px;color:var(--t2)">야간·악기상 SAR 탐지 가능</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-top:3px solid var(--color-info)">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--color-info);font-family:var(--font-korean);margin-bottom:8px">✈️ 유인 항공기</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">광역 탐색·장시간 체공. 광학·IR·SAR·SLAR·UV 형광 센서 복합 탑재. 해경 해양오염 감시 항공기.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:3px 7px;background:rgba(59,130,246,.06);border-radius:3px;color:var(--fg-sub)">고도: 300~3,000m · 속도: 60~150m/s</div>
|
||||
<div style="padding:3px 7px;background:rgba(59,130,246,.06);border-radius:3px;color:var(--fg-sub)">탐색폭: 5~50km · 체공: 4~8시간</div>
|
||||
<div style="padding:3px 7px;background:rgba(59,130,246,.06);border-radius:3px;color:var(--fg-sub)">야간·악기상 SAR 탐지 가능</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-top:3px solid var(--purple)">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--purple);font-family:var(--fK);margin-bottom:8px">🛰️ 위성</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">광역·반복 관측. SST(NOAA AVHRR·NGSST)·SAR(Sentinel-1)·광학(KOMPSAT) 활용. 구름 문제 존재.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:3px 7px;background:rgba(168,85,247,.06);border-radius:3px;color:var(--t2)">NGSST: 5km 해상도 · 일 1회 갱신</div>
|
||||
<div style="padding:3px 7px;background:rgba(168,85,247,.06);border-radius:3px;color:var(--t2)">SAR: 5~25m 해상도 · 구름 무관</div>
|
||||
<div style="padding:3px 7px;background:rgba(168,85,247,.06);border-radius:3px;color:var(--t2)">KOMPSAT-5: X-band SAR 1m급</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-top:3px solid var(--color-tertiary)">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--color-tertiary);font-family:var(--font-korean);margin-bottom:8px">🛰️ 위성</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">광역·반복 관측. SST(NOAA AVHRR·NGSST)·SAR(Sentinel-1)·광학(KOMPSAT) 활용. 구름 문제 존재.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:3px 7px;background:rgba(168,85,247,.06);border-radius:3px;color:var(--fg-sub)">NGSST: 5km 해상도 · 일 1회 갱신</div>
|
||||
<div style="padding:3px 7px;background:rgba(168,85,247,.06);border-radius:3px;color:var(--fg-sub)">SAR: 5~25m 해상도 · 구름 무관</div>
|
||||
<div style="padding:3px 7px;background:rgba(168,85,247,.06);border-radius:3px;color:var(--fg-sub)">KOMPSAT-5: X-band SAR 1m급</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -162,90 +162,90 @@ const panel0Html = `
|
||||
|
||||
// ═══ PANEL 1: 탐지 장비 ═══
|
||||
const panel1Html = `
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:16px;margin-bottom:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:12px">🔬 유출유 탐지 센서 종류 및 특성 비교</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:16px;margin-bottom:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:12px">🔬 유출유 탐지 센서 종류 및 특성 비교</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-family:var(--fK);font-size:10px">
|
||||
<table style="width:100%;border-collapse:collapse;font-family:var(--font-korean);font-size:10px">
|
||||
<thead>
|
||||
<tr style="background:rgba(255,255,255,.03);border-bottom:1px solid var(--bdL)">
|
||||
<th style="padding:7px 10px;text-align:left;color:var(--t3);font-weight:600">센서</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--t3);font-weight:600">파장대</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--t3);font-weight:600">탐지 원리</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--t3);font-weight:600">강점</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--t3);font-weight:600">한계</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--t3);font-weight:600">탑재 플랫폼</th>
|
||||
<tr style="background:rgba(255,255,255,.03);border-bottom:1px solid var(--stroke-light)">
|
||||
<th style="padding:7px 10px;text-align:left;color:var(--fg-disabled);font-weight:600">센서</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--fg-disabled);font-weight:600">파장대</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--fg-disabled);font-weight:600">탐지 원리</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--fg-disabled);font-weight:600">강점</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--fg-disabled);font-weight:600">한계</th>
|
||||
<th style="padding:7px 10px;text-align:center;color:var(--fg-disabled);font-weight:600">탑재 플랫폼</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,.04)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--orange)">광학(EO)</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">0.4~0.7μm</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">반사 휘도 차이<br>유막 광택·색조</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">고해상도<br>직관적 식별</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--red);font-size:9px">야간·구름 불가</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">드론·항공기·위성</td>
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-warning)">광학(EO)</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">0.4~0.7μm</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">반사 휘도 차이<br>유막 광택·색조</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">고해상도<br>직관적 식별</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--color-danger);font-size:9px">야간·구름 불가</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">드론·항공기·위성</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,.04);background:rgba(255,255,255,.01)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--red)">열적외선(IR)</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">8~14μm</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">유막 열방사 차이<br>해수면 온도 대비</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">야간 탐지<br>두께 추정 가능</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--red);font-size:9px">구름 투과 불가<br>얇은 유막 한계</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">드론·항공기<br>NOAA AVHRR</td>
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-danger)">열적외선(IR)</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">8~14μm</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">유막 열방사 차이<br>해수면 온도 대비</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">야간 탐지<br>두께 추정 가능</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--color-danger);font-size:9px">구름 투과 불가<br>얇은 유막 한계</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">드론·항공기<br>NOAA AVHRR</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,.04)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--blue)">SAR</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">1~10cm<br>(마이크로파)</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">유막 표면장력 증가<br>→ 브래그 후방산란 감소</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">구름·야간 무관<br>광역 탐지</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--red);font-size:9px">유사 픽처 오탐<br>고해상도 한계</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">위성(Sentinel-1<br>KOMPSAT-5)</td>
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-info)">SAR</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">1~10cm<br>(마이크로파)</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">유막 표면장력 증가<br>→ 브래그 후방산란 감소</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">구름·야간 무관<br>광역 탐지</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--color-danger);font-size:9px">유사 픽처 오탐<br>고해상도 한계</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">위성(Sentinel-1<br>KOMPSAT-5)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,.04);background:rgba(255,255,255,.01)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--purple)">SLAR/RAR</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">마이크로파</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">측방향 레이더<br>유막 감쇠대 탐지</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">야간·구름 무관<br>광역 감시선</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--red);font-size:9px">항공기 탑재 전용<br>저해상도</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">해경 감시 항공기</td>
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-tertiary)">SLAR/RAR</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">마이크로파</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">측방향 레이더<br>유막 감쇠대 탐지</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">야간·구름 무관<br>광역 감시선</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--color-danger);font-size:9px">항공기 탑재 전용<br>저해상도</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">해경 감시 항공기</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,.04)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--yellow)">UV 형광</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">0.3~0.4μm</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">자외선 조사 → 기름<br>형광 방출</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">박막 유막(μm급)<br>탐지 가능</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--red);font-size:9px">야간 전용<br>주간 오탐 많음</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">항공기</td>
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-caution)">UV 형광</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">0.3~0.4μm</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">자외선 조사 → 기름<br>형광 방출</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">박막 유막(μm급)<br>탐지 가능</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--color-danger);font-size:9px">야간 전용<br>주간 오탐 많음</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">항공기</td>
|
||||
</tr>
|
||||
<tr style="background:rgba(255,255,255,.01)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--cyan)">마이크로파 복사계</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">수cm<br>(수동형)</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">유막 방사율 변화<br>밝기온도 차이</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--cyan)"><b>구름 완전 투과</b><br>야간 가능</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--red);font-size:9px">저해상도(50km)<br>NGSST 혼합 사용</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--t2)">AMSR-E 위성<br>(NGSST 융합)</td>
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-accent)">마이크로파 복사계</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">수cm<br>(수동형)</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">유막 방사율 변화<br>밝기온도 차이</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--color-accent)"><b>구름 완전 투과</b><br>야간 가능</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--color-danger);font-size:9px">저해상도(50km)<br>NGSST 혼합 사용</td>
|
||||
<td style="padding:7px 10px;text-align:center;color:var(--fg-sub)">AMSR-E 위성<br>(NGSST 융합)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--cyan);font-family:var(--fK);margin-bottom:10px">🌡️ NGSST (New Generation SST) 위성 수온자료 — 특허 10-1567431 적용</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--color-accent);font-family:var(--font-korean);margin-bottom:10px">🌡️ NGSST (New Generation SST) 위성 수온자료 — 특허 10-1567431 적용</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.8">
|
||||
일본 토호쿠대학 Kawamura 교수팀이 제공하는 북서태평양 광역 수온자료입니다. <b style="color:var(--cyan)">열적외선(AVHRR·MODIS)</b>과 <b style="color:var(--orange)">마이크로파(AMSR-E)</b>를 융합하여 구름 유무와 관계없이 일별 수온을 제공합니다.
|
||||
<div style="margin-top:8px;background:var(--bg0);border-radius:6px;padding:10px;font-family:var(--fM);font-size:10px;line-height:2;color:var(--t1)">
|
||||
SST(℃) = <span style="color:var(--orange)">0.15</span> × DN − <span style="color:var(--red)">3.0</span><br>
|
||||
<span style="color:var(--t3);font-size:9px">DN: 바이너리 파일 1byte 디지털 수치</span>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8">
|
||||
일본 토호쿠대학 Kawamura 교수팀이 제공하는 북서태평양 광역 수온자료입니다. <b style="color:var(--color-accent)">열적외선(AVHRR·MODIS)</b>과 <b style="color:var(--color-warning)">마이크로파(AMSR-E)</b>를 융합하여 구름 유무와 관계없이 일별 수온을 제공합니다.
|
||||
<div style="margin-top:8px;background:var(--bg-base);border-radius:6px;padding:10px;font-family:var(--font-mono);font-size:10px;line-height:2;color:var(--fg-default)">
|
||||
SST(℃) = <span style="color:var(--color-warning)">0.15</span> × DN − <span style="color:var(--color-danger)">3.0</span><br>
|
||||
<span style="color:var(--fg-disabled);font-size:9px">DN: 바이너리 파일 1byte 디지털 수치</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:6px 9px;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.12);border-radius:5px"><span style="color:var(--cyan);font-weight:700">영역</span> : 116~166°E, 13~63°N (북서태평양 50×50°)</div>
|
||||
<div style="padding:6px 9px;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.12);border-radius:5px"><span style="color:var(--cyan);font-weight:700">해상도</span> : 3분(약 5km) · 격자 1000×1000</div>
|
||||
<div style="padding:6px 9px;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.12);border-radius:5px"><span style="color:var(--cyan);font-weight:700">갱신</span> : 매일 12시 FTP 자동수신 · 최근 5일 합성</div>
|
||||
<div style="padding:6px 9px;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.12);border-radius:5px"><span style="color:var(--cyan);font-weight:700">보간</span> : Akima(1978) 2차원 5차다항식 → 500m 격자 변환</div>
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><span style="color:var(--orange);font-weight:700">WING 활용</span> : 유출유 증발·유상화·점도변화 모델 수온 입력</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:6px 9px;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.12);border-radius:5px"><span style="color:var(--color-accent);font-weight:700">영역</span> : 116~166°E, 13~63°N (북서태평양 50×50°)</div>
|
||||
<div style="padding:6px 9px;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.12);border-radius:5px"><span style="color:var(--color-accent);font-weight:700">해상도</span> : 3분(약 5km) · 격자 1000×1000</div>
|
||||
<div style="padding:6px 9px;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.12);border-radius:5px"><span style="color:var(--color-accent);font-weight:700">갱신</span> : 매일 12시 FTP 자동수신 · 최근 5일 합성</div>
|
||||
<div style="padding:6px 9px;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.12);border-radius:5px"><span style="color:var(--color-accent);font-weight:700">보간</span> : Akima(1978) 2차원 5차다항식 → 500m 격자 변환</div>
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><span style="color:var(--color-warning);font-weight:700">WING 활용</span> : 유출유 증발·유상화·점도변화 모델 수온 입력</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -254,55 +254,55 @@ const panel1Html = `
|
||||
// ═══ PANEL 2: 원격탐사 ═══
|
||||
const panel2Html = `
|
||||
<div style="background:linear-gradient(135deg,rgba(168,85,247,.06),rgba(59,130,246,.04));border:1px solid rgba(168,85,247,.2);border-radius:12px;padding:16px;margin-bottom:14px;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--purple),var(--blue))"></div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:10px">🛰️ 위성 원격탐사 — 유출유 탐지 원리</div>
|
||||
<div style="font-size:11px;color:var(--t2);font-family:var(--fK);line-height:1.8">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--color-tertiary),var(--color-info))"></div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:10px">🛰️ 위성 원격탐사 — 유출유 탐지 원리</div>
|
||||
<div style="font-size:11px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8">
|
||||
해양 유출유는 해수면에 유막을 형성하여 전자기파 반사·방출·산란 특성을 변화시킵니다. 이 물리적 특성 변화를 위성·항공 센서로 감지하여 유막의 위치·범위·두께를 추정합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-left:3px solid var(--orange)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--orange);font-family:var(--fK);margin-bottom:8px">① 열적외선 방출 원리</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">
|
||||
해수면 위 유막은 해수보다 <b style="color:var(--t1)">방사율이 낮아</b> 동일 온도에서도 적외선 방출량이 다릅니다. 파장 10~12μm 열적외선 밴드에서 유막과 주변 해수의 <b style="color:var(--orange)">밝기온도(Brightness Temperature) 차이</b>로 유막을 탐지합니다.
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-left:3px solid var(--color-warning)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-warning);font-family:var(--font-korean);margin-bottom:8px">① 열적외선 방출 원리</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">
|
||||
해수면 위 유막은 해수보다 <b style="color:var(--fg-default)">방사율이 낮아</b> 동일 온도에서도 적외선 방출량이 다릅니다. 파장 10~12μm 열적외선 밴드에서 유막과 주변 해수의 <b style="color:var(--color-warning)">밝기온도(Brightness Temperature) 차이</b>로 유막을 탐지합니다.
|
||||
</div>
|
||||
<div style="background:var(--bg0);border-radius:5px;padding:8px;font-size:9px;color:var(--t3);font-family:var(--fK)">⚠️ 구름은 열적외선을 완전 차단 → 마이크로파 보조 필요 (NGSST 융합 이유)</div>
|
||||
<div style="background:var(--bg-base);border-radius:5px;padding:8px;font-size:9px;color:var(--fg-disabled);font-family:var(--font-korean)">⚠️ 구름은 열적외선을 완전 차단 → 마이크로파 보조 필요 (NGSST 융합 이유)</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-left:3px solid var(--blue)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--blue);font-family:var(--fK);margin-bottom:8px">② SAR 브래그 산란 원리</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">
|
||||
해수면 위 유막은 <b style="color:var(--t1)">표면장력을 증가</b>시켜 소파를 억제합니다. SAR에서 해수면 산란의 주원인인 <b style="color:var(--blue)">브래그(Bragg) 후방산란이 감소</b>하여 유막 영역이 어둡게 나타납니다.
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-left:3px solid var(--color-info)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-info);font-family:var(--font-korean);margin-bottom:8px">② SAR 브래그 산란 원리</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">
|
||||
해수면 위 유막은 <b style="color:var(--fg-default)">표면장력을 증가</b>시켜 소파를 억제합니다. SAR에서 해수면 산란의 주원인인 <b style="color:var(--color-info)">브래그(Bragg) 후방산란이 감소</b>하여 유막 영역이 어둡게 나타납니다.
|
||||
</div>
|
||||
<div style="background:var(--bg0);border-radius:5px;padding:8px;font-size:9px;color:var(--t3);font-family:var(--fK)">활용: Sentinel-1(C-band) · KOMPSAT-5(X-band) · ALOS PALSAR(L-band)</div>
|
||||
<div style="background:var(--bg-base);border-radius:5px;padding:8px;font-size:9px;color:var(--fg-disabled);font-family:var(--font-korean)">활용: Sentinel-1(C-band) · KOMPSAT-5(X-band) · ALOS PALSAR(L-band)</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-left:3px solid var(--yellow)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--yellow);font-family:var(--fK);margin-bottom:8px">③ UV 형광 탐지 원리</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">
|
||||
석유계 탄화수소에 자외선(310~400nm)을 조사하면 <b style="color:var(--yellow)">형광 발광</b>합니다. 주야간 모두 활용 가능하나 야간 효과가 우수합니다. 수μm 수준의 매우 얇은 유막도 탐지 가능한 고감도 센서입니다.
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-left:3px solid var(--color-caution)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-caution);font-family:var(--font-korean);margin-bottom:8px">③ UV 형광 탐지 원리</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">
|
||||
석유계 탄화수소에 자외선(310~400nm)을 조사하면 <b style="color:var(--color-caution)">형광 발광</b>합니다. 주야간 모두 활용 가능하나 야간 효과가 우수합니다. 수μm 수준의 매우 얇은 유막도 탐지 가능한 고감도 센서입니다.
|
||||
</div>
|
||||
<div style="background:var(--bg0);border-radius:5px;padding:8px;font-size:9px;color:var(--t3);font-family:var(--fK)">적용: 해경 감시 항공기 야간 탐색 · 비정상 유출 신고 확인</div>
|
||||
<div style="background:var(--bg-base);border-radius:5px;padding:8px;font-size:9px;color:var(--fg-disabled);font-family:var(--font-korean)">적용: 해경 감시 항공기 야간 탐색 · 비정상 유출 신고 확인</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-left:3px solid var(--cyan)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--cyan);font-family:var(--fK);margin-bottom:8px">④ 마이크로파 복사계 원리</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">
|
||||
수동 마이크로파 복사계는 지구 방출 마이크로파를 수신합니다. 유막이 있으면 <b style="color:var(--cyan)">방사율 변화</b>로 밝기온도가 달라집니다. 파장이 길어 구름 완전 투과·야간 관측 가능.
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-left:3px solid var(--color-accent)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-accent);font-family:var(--font-korean);margin-bottom:8px">④ 마이크로파 복사계 원리</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">
|
||||
수동 마이크로파 복사계는 지구 방출 마이크로파를 수신합니다. 유막이 있으면 <b style="color:var(--color-accent)">방사율 변화</b>로 밝기온도가 달라집니다. 파장이 길어 구름 완전 투과·야간 관측 가능.
|
||||
</div>
|
||||
<div style="background:var(--bg0);border-radius:5px;padding:8px;font-size:9px;color:var(--t3);font-family:var(--fK)">해상도 한계(50km)로 단독 사용 불가 → 열적외선과 융합 (NGSST 방식)</div>
|
||||
<div style="background:var(--bg-base);border-radius:5px;padding:8px;font-size:9px;color:var(--fg-disabled);font-family:var(--font-korean)">해상도 한계(50km)로 단독 사용 불가 → 열적외선과 융합 (NGSST 방식)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--green);font-family:var(--fK);margin-bottom:10px">🗺️ 전자해도(ENC) 수심자료 처리 — 특허 10-1567431 기반</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--color-success);font-family:var(--font-korean);margin-bottom:10px">🗺️ 전자해도(ENC) 수심자료 처리 — 특허 10-1567431 기반</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.8">
|
||||
항공탐색 좌표 데이터는 <b style="color:var(--green)">전자해도(ENC) 수심격자</b>와 중첩되어 유출유의 수심환경, 조간대 분포, 해안선 형태를 분석합니다. 수심자료는 국립해양조사원 전자해도 약 300종에서 추출 후 <b style="color:var(--green)">Akima 보간</b>으로 15초 등간격 격자에 정규화합니다.
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8">
|
||||
항공탐색 좌표 데이터는 <b style="color:var(--color-success)">전자해도(ENC) 수심격자</b>와 중첩되어 유출유의 수심환경, 조간대 분포, 해안선 형태를 분석합니다. 수심자료는 국립해양조사원 전자해도 약 300종에서 추출 후 <b style="color:var(--color-success)">Akima 보간</b>으로 15초 등간격 격자에 정규화합니다.
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:6px 9px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.12);border-radius:5px"><span style="color:var(--green);font-weight:700">원본</span> : ENC 전자해도 무작위 측심점</div>
|
||||
<div style="padding:6px 9px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.12);border-radius:5px"><span style="color:var(--green);font-weight:700">삼각망</span> : TIN(Triangulated Irregular Network) 구성</div>
|
||||
<div style="padding:6px 9px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.12);border-radius:5px"><span style="color:var(--green);font-weight:700">보간</span> : Akima 2차원 5차다항식 (21개 계수)</div>
|
||||
<div style="padding:6px 9px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.12);border-radius:5px"><span style="color:var(--green);font-weight:700">결과</span> : 15초(463m) 등간격 · 3,225,600 격자</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:6px 9px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.12);border-radius:5px"><span style="color:var(--color-success);font-weight:700">원본</span> : ENC 전자해도 무작위 측심점</div>
|
||||
<div style="padding:6px 9px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.12);border-radius:5px"><span style="color:var(--color-success);font-weight:700">삼각망</span> : TIN(Triangulated Irregular Network) 구성</div>
|
||||
<div style="padding:6px 9px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.12);border-radius:5px"><span style="color:var(--color-success);font-weight:700">보간</span> : Akima 2차원 5차다항식 (21개 계수)</div>
|
||||
<div style="padding:6px 9px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.12);border-radius:5px"><span style="color:var(--color-success);font-weight:700">결과</span> : 15초(463m) 등간격 · 3,225,600 격자</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -311,104 +311,104 @@ const panel2Html = `
|
||||
// ═══ PANEL 3: ESI 방제지도 ═══
|
||||
const panel3Html = `
|
||||
<div style="background:linear-gradient(135deg,rgba(34,197,94,.06),rgba(234,179,8,.04));border:1px solid rgba(34,197,94,.2);border-radius:12px;padding:16px;margin-bottom:14px;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--green),var(--yellow))"></div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:8px">🗺️ ESI 방제정보지도 (Environmental Sensitivity Index Map)</div>
|
||||
<div style="font-size:11px;color:var(--t2);font-family:var(--fK);line-height:1.8">
|
||||
ESI 지도는 이문진 박사 특허(등록특허 10-1567431)의 핵심 기반 데이터입니다. 해안선 유형·생태민감도·사회경제적 자원 분포를 통합 등급화하여 <b style="color:var(--green)">방제 우선순위 결정</b>의 근거가 됩니다. 1999~2002년 구축, 해경 인천·태안·군산·목포·완도·제주·여수 관할해역 대상.
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--color-success),var(--color-caution))"></div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:8px">🗺️ ESI 방제정보지도 (Environmental Sensitivity Index Map)</div>
|
||||
<div style="font-size:11px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8">
|
||||
ESI 지도는 이문진 박사 특허(등록특허 10-1567431)의 핵심 기반 데이터입니다. 해안선 유형·생태민감도·사회경제적 자원 분포를 통합 등급화하여 <b style="color:var(--color-success)">방제 우선순위 결정</b>의 근거가 됩니다. 1999~2002년 구축, 해경 인천·태안·군산·목포·완도·제주·여수 관할해역 대상.
|
||||
</div>
|
||||
<div style="margin-top:8px;padding:7px 10px;background:rgba(34,197,94,.06);border:1px solid rgba(34,197,94,.15);border-radius:5px;font-size:9px;color:var(--t3);font-family:var(--fK)">📚 원전: NOAA ESI Mapping Program · 국내 적용: 해양수산부·한국해양과학기술원</div>
|
||||
<div style="margin-top:8px;padding:7px 10px;background:rgba(34,197,94,.06);border:1px solid rgba(34,197,94,.15);border-radius:5px;font-size:9px;color:var(--fg-disabled);font-family:var(--font-korean)">📚 원전: NOAA ESI Mapping Program · 국내 적용: 해양수산부·한국해양과학기술원</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:14px">
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-top:3px solid var(--blue)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--blue);font-family:var(--fK);margin-bottom:8px">① 해안선 분류 (Shoreline)</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">해안선 유형을 1~10 등급으로 분류. 등급이 높을수록 오염 취약·방제 난이도 높음.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.06);border-radius:3px;color:var(--t2)">ESI 1~2: 노출 암반·절벽 (낮은 민감도)</div>
|
||||
<div style="padding:3px 7px;background:rgba(234,179,8,.06);border-radius:3px;color:var(--t2)">ESI 5~7: 자갈·모래 해변 (중간)</div>
|
||||
<div style="padding:3px 7px;background:rgba(239,68,68,.06);border-radius:3px;color:var(--t2)">ESI 8~10: 조간대·갯벌·맹그로브 (최고)</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-top:3px solid var(--color-info)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-info);font-family:var(--font-korean);margin-bottom:8px">① 해안선 분류 (Shoreline)</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">해안선 유형을 1~10 등급으로 분류. 등급이 높을수록 오염 취약·방제 난이도 높음.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.06);border-radius:3px;color:var(--fg-sub)">ESI 1~2: 노출 암반·절벽 (낮은 민감도)</div>
|
||||
<div style="padding:3px 7px;background:rgba(234,179,8,.06);border-radius:3px;color:var(--fg-sub)">ESI 5~7: 자갈·모래 해변 (중간)</div>
|
||||
<div style="padding:3px 7px;background:rgba(239,68,68,.06);border-radius:3px;color:var(--fg-sub)">ESI 8~10: 조간대·갯벌·맹그로브 (최고)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-top:3px solid var(--green)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--green);font-family:var(--fK);margin-bottom:8px">② 생물자원 (Biological Resources)</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">오염 취약 생물 서식지·번식지·이동경로를 위치 기반으로 등록.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.06);border-radius:3px;color:var(--t2)">해조류·어류 산란장·양식장</div>
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.06);border-radius:3px;color:var(--t2)">철새 도래지·해조류 번식지</div>
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.06);border-radius:3px;color:var(--t2)">보호 해양생물 서식구역</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-top:3px solid var(--color-success)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-success);font-family:var(--font-korean);margin-bottom:8px">② 생물자원 (Biological Resources)</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">오염 취약 생물 서식지·번식지·이동경로를 위치 기반으로 등록.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.06);border-radius:3px;color:var(--fg-sub)">해조류·어류 산란장·양식장</div>
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.06);border-radius:3px;color:var(--fg-sub)">철새 도래지·해조류 번식지</div>
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.06);border-radius:3px;color:var(--fg-sub)">보호 해양생물 서식구역</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-top:3px solid var(--orange)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--orange);font-family:var(--fK);margin-bottom:8px">③ 인문·사회자원 (Human-Use)</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">경제·사회적 피해 가능 지역을 방제 우선순위 결정에 활용.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--t2)">취수원·정수장·발전소 냉각수</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--t2)">항구·어항·수산물 위판장</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--t2)">해수욕장·관광지·문화재</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-top:3px solid var(--color-warning)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-warning);font-family:var(--font-korean);margin-bottom:8px">③ 인문·사회자원 (Human-Use)</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">경제·사회적 피해 가능 지역을 방제 우선순위 결정에 활용.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--fg-sub)">취수원·정수장·발전소 냉각수</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--fg-sub)">항구·어항·수산물 위판장</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.06);border-radius:3px;color:var(--fg-sub)">해수욕장·관광지·문화재</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:8px">📏 ESI 해안선 자료 구축 현황 (등록특허 10-1567431)</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.8">
|
||||
본 특허의 ESI 기반 데이터는 1999~2002년(약 3년) 구축된 방제정보지도 <b style="color:var(--t1)">25,000:1 해안선자료</b>를 기반으로 합니다. 매립·준설공사로 변형된 부분은 국립해양조사원 전자해도(ENC) 해안선으로 보완하였습니다. 항공탐색에서 획득한 최신 영상 데이터는 이 ESI DB와 실시간 중첩되어 방제 우선구역을 즉시 산출합니다.
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:8px">📏 ESI 해안선 자료 구축 현황 (등록특허 10-1567431)</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8">
|
||||
본 특허의 ESI 기반 데이터는 1999~2002년(약 3년) 구축된 방제정보지도 <b style="color:var(--fg-default)">25,000:1 해안선자료</b>를 기반으로 합니다. 매립·준설공사로 변형된 부분은 국립해양조사원 전자해도(ENC) 해안선으로 보완하였습니다. 항공탐색에서 획득한 최신 영상 데이터는 이 ESI DB와 실시간 중첩되어 방제 우선구역을 즉시 산출합니다.
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// ═══ PANEL 4: 면적 산정 ═══
|
||||
const panel4Html = `
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:16px;margin-bottom:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:12px">📏 항공 영상 기반 유출유 면적 산정 방법론</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:16px;margin-bottom:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:12px">📏 항공 영상 기반 유출유 면적 산정 방법론</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<div style="background:var(--bg0);border:1px solid rgba(249,115,22,.2);border-radius:8px;padding:12px;border-left:3px solid var(--orange)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--orange);font-family:var(--fK);margin-bottom:8px">① 픽셀 분류법 (Pixel Classification)</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">광학 영상의 각 픽셀을 <b style="color:var(--t1)">반사도·색상·질감</b>에 따라 기름/해수로 분류. 분류된 픽셀 수 × GSD² = 면적.</div>
|
||||
<div style="background:rgba(255,255,255,.03);border-radius:5px;padding:8px;font-family:var(--fM);font-size:10px;color:var(--t1);line-height:1.8">A = N<sub>oil</sub> × (GSD)<sup>2</sup><br><span style="color:var(--t3);font-size:9px">N: 기름 픽셀수, GSD: 지상 표본거리</span></div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(249,115,22,.2);border-radius:8px;padding:12px;border-left:3px solid var(--color-warning)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-warning);font-family:var(--font-korean);margin-bottom:8px">① 픽셀 분류법 (Pixel Classification)</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">광학 영상의 각 픽셀을 <b style="color:var(--fg-default)">반사도·색상·질감</b>에 따라 기름/해수로 분류. 분류된 픽셀 수 × GSD² = 면적.</div>
|
||||
<div style="background:rgba(255,255,255,.03);border-radius:5px;padding:8px;font-family:var(--font-mono);font-size:10px;color:var(--fg-default);line-height:1.8">A = N<sub>oil</sub> × (GSD)<sup>2</sup><br><span style="color:var(--fg-disabled);font-size:9px">N: 기름 픽셀수, GSD: 지상 표본거리</span></div>
|
||||
</div>
|
||||
<div style="background:var(--bg0);border:1px solid rgba(6,182,212,.2);border-radius:8px;padding:12px;border-left:3px solid var(--cyan)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--cyan);font-family:var(--fK);margin-bottom:8px">② 다중분광 지수법 (Spectral Index)</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7;margin-bottom:8px">다중분광 센서로 촬영한 밴드 조합으로 <b style="color:var(--t1)">유막 특유의 분광 반응</b>을 지수화하여 자동 분류.</div>
|
||||
<div style="background:rgba(255,255,255,.03);border-radius:5px;padding:8px;font-family:var(--fM);font-size:10px;color:var(--t1);line-height:1.8">OSDI = (B<sub>NIR</sub>−B<sub>Red</sub>) / (B<sub>NIR</sub>+B<sub>Red</sub>)<br><span style="color:var(--t3);font-size:9px">Oil Spill Detection Index</span></div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(6,182,212,.2);border-radius:8px;padding:12px;border-left:3px solid var(--color-accent)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-accent);font-family:var(--font-korean);margin-bottom:8px">② 다중분광 지수법 (Spectral Index)</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7;margin-bottom:8px">다중분광 센서로 촬영한 밴드 조합으로 <b style="color:var(--fg-default)">유막 특유의 분광 반응</b>을 지수화하여 자동 분류.</div>
|
||||
<div style="background:rgba(255,255,255,.03);border-radius:5px;padding:8px;font-family:var(--font-mono);font-size:10px;color:var(--fg-default);line-height:1.8">OSDI = (B<sub>NIR</sub>−B<sub>Red</sub>) / (B<sub>NIR</sub>+B<sub>Red</sub>)<br><span style="color:var(--fg-disabled);font-size:9px">Oil Spill Detection Index</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div style="background:var(--bg0);border:1px solid rgba(59,130,246,.2);border-radius:8px;padding:12px;border-left:3px solid var(--blue)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--blue);font-family:var(--fK);margin-bottom:8px">③ SAR 임계값 분리법</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7">SAR 영상에서 후방산란계수(σ°)의 임계값 이하를 유막으로 판정합니다. 단, 풍속 3m/s 이하나 생물막, 강우 영역이 <b style="color:var(--red)">False Alarm</b> 오탐 발생 원인이 됩니다.</div>
|
||||
<div style="margin-top:6px;background:rgba(255,255,255,.03);border-radius:5px;padding:7px;font-family:var(--fM);font-size:10px;color:var(--t1)">Oil = {(x,y) | σ°(x,y) < T<sub>th</sub>}<br><span style="color:var(--t3);font-size:9px">T<sub>th</sub>: 최적 임계값 (국소 적응형)</span></div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(59,130,246,.2);border-radius:8px;padding:12px;border-left:3px solid var(--color-info)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-info);font-family:var(--font-korean);margin-bottom:8px">③ SAR 임계값 분리법</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7">SAR 영상에서 후방산란계수(σ°)의 임계값 이하를 유막으로 판정합니다. 단, 풍속 3m/s 이하나 생물막, 강우 영역이 <b style="color:var(--color-danger)">False Alarm</b> 오탐 발생 원인이 됩니다.</div>
|
||||
<div style="margin-top:6px;background:rgba(255,255,255,.03);border-radius:5px;padding:7px;font-family:var(--font-mono);font-size:10px;color:var(--fg-default)">Oil = {(x,y) | σ°(x,y) < T<sub>th</sub>}<br><span style="color:var(--fg-disabled);font-size:9px">T<sub>th</sub>: 최적 임계값 (국소 적응형)</span></div>
|
||||
</div>
|
||||
<div style="background:var(--bg0);border:1px solid rgba(34,197,94,.2);border-radius:8px;padding:12px;border-left:3px solid var(--green)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--green);font-family:var(--fK);margin-bottom:8px">④ 유량 역산 추정</div>
|
||||
<div style="font-size:10px;color:var(--t2);font-family:var(--fK);line-height:1.7">면적(A)과 평균 유막 두께(d) 및 풍화 경과 시간(t)으로부터 원래 유출유량(V₀)을 역산합니다.</div>
|
||||
<div style="margin-top:6px;background:rgba(255,255,255,.03);border-radius:5px;padding:7px;font-family:var(--fM);font-size:10px;color:var(--t1);line-height:1.8">V₀ = A × d / (1 − f<sub>e</sub>(t))<br><span style="color:var(--t3);font-size:9px">f<sub>e</sub>: 누적 증발비 (Stiver & Mackay 1984)</span></div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(34,197,94,.2);border-radius:8px;padding:12px;border-left:3px solid var(--color-success)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-success);font-family:var(--font-korean);margin-bottom:8px">④ 유량 역산 추정</div>
|
||||
<div style="font-size:10px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7">면적(A)과 평균 유막 두께(d) 및 풍화 경과 시간(t)으로부터 원래 유출유량(V₀)을 역산합니다.</div>
|
||||
<div style="margin-top:6px;background:rgba(255,255,255,.03);border-radius:5px;padding:7px;font-family:var(--font-mono);font-size:10px;color:var(--fg-default);line-height:1.8">V₀ = A × d / (1 − f<sub>e</sub>(t))<br><span style="color:var(--fg-disabled);font-size:9px">f<sub>e</sub>: 누적 증발비 (Stiver & Mackay 1984)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--yellow);font-family:var(--fK);margin-bottom:10px">🎨 유막 두께 시각적 추정 기준 (Bonn Agreement Color Code)</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:6px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-caution);font-family:var(--font-korean);margin-bottom:10px">🎨 유막 두께 시각적 추정 기준 (Bonn Agreement Color Code)</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:6px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:6px;text-align:center">
|
||||
<div style="width:20px;height:20px;background:rgba(200,200,255,.3);border-radius:3px;margin:0 auto 4px"></div>
|
||||
<div style="font-weight:700;color:var(--t1)">은회색</div><div style="color:var(--t3)">< 0.1μm</div><div style="color:var(--t3)">광택층</div>
|
||||
<div style="font-weight:700;color:var(--fg-default)">은회색</div><div style="color:var(--fg-disabled)">< 0.1μm</div><div style="color:var(--fg-disabled)">광택층</div>
|
||||
</div>
|
||||
<div style="padding:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:6px;text-align:center">
|
||||
<div style="width:20px;height:20px;background:rgba(180,220,180,.4);border-radius:3px;margin:0 auto 4px"></div>
|
||||
<div style="font-weight:700;color:var(--t1)">무지개색</div><div style="color:var(--t3)">0.1~0.3μm</div><div style="color:var(--t3)">박막층</div>
|
||||
<div style="font-weight:700;color:var(--fg-default)">무지개색</div><div style="color:var(--fg-disabled)">0.1~0.3μm</div><div style="color:var(--fg-disabled)">박막층</div>
|
||||
</div>
|
||||
<div style="padding:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:6px;text-align:center">
|
||||
<div style="width:20px;height:20px;background:rgba(200,160,80,.5);border-radius:3px;margin:0 auto 4px"></div>
|
||||
<div style="font-weight:700;color:var(--t1)">메탈릭</div><div style="color:var(--t3)">0.3~5μm</div><div style="color:var(--t3)">광택층</div>
|
||||
<div style="font-weight:700;color:var(--fg-default)">메탈릭</div><div style="color:var(--fg-disabled)">0.3~5μm</div><div style="color:var(--fg-disabled)">광택층</div>
|
||||
</div>
|
||||
<div style="padding:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:6px;text-align:center">
|
||||
<div style="width:20px;height:20px;background:rgba(120,90,40,.7);border-radius:3px;margin:0 auto 4px"></div>
|
||||
<div style="font-weight:700;color:var(--orange)">갈색</div><div style="color:var(--t3)">5~200μm</div><div style="color:var(--t3)">두꺼운층</div>
|
||||
<div style="font-weight:700;color:var(--color-warning)">갈색</div><div style="color:var(--fg-disabled)">5~200μm</div><div style="color:var(--fg-disabled)">두꺼운층</div>
|
||||
</div>
|
||||
<div style="padding:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:6px;text-align:center">
|
||||
<div style="width:20px;height:20px;background:rgba(40,40,40,.9);border-radius:3px;margin:0 auto 4px"></div>
|
||||
<div style="font-weight:700;color:var(--t1)">흑색</div><div style="color:var(--t3)">>200μm</div><div style="color:var(--t3)">농축층</div>
|
||||
<div style="font-weight:700;color:var(--fg-default)">흑색</div><div style="color:var(--fg-disabled)">>200μm</div><div style="color:var(--fg-disabled)">농축층</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -417,57 +417,57 @@ const panel4Html = `
|
||||
// ═══ PANEL 5: 확산예측 연계 ═══
|
||||
const panel5Html = `
|
||||
<div style="background:linear-gradient(135deg,rgba(59,130,246,.06),rgba(6,182,212,.04));border:1px solid rgba(59,130,246,.2);border-radius:12px;padding:16px;margin-bottom:14px;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--blue),var(--cyan))"></div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:8px">🔗 항공탐색 데이터 → 유출유 확산예측 연계 체계</div>
|
||||
<div style="font-size:11px;color:var(--t2);font-family:var(--fK);line-height:1.8">
|
||||
이문진 박사 특허(등록특허 10-1567431)는 <b style="color:var(--cyan)">위성영상수신시스템(SST)</b>을 기상예측시스템·검조소와 함께 인터넷으로 연결하여, 항공탐색 데이터가 실시간 확산 예측의 핵심 입력자료가 되는 통합 네트워크를 구성합니다.
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--color-info),var(--color-accent))"></div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:8px">🔗 항공탐색 데이터 → 유출유 확산예측 연계 체계</div>
|
||||
<div style="font-size:11px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8">
|
||||
이문진 박사 특허(등록특허 10-1567431)는 <b style="color:var(--color-accent)">위성영상수신시스템(SST)</b>을 기상예측시스템·검조소와 함께 인터넷으로 연결하여, 항공탐색 데이터가 실시간 확산 예측의 핵심 입력자료가 되는 통합 네트워크를 구성합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-left:3px solid var(--orange)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--orange);font-family:var(--fK);margin-bottom:8px">📡 항공탐색 → 모델 입력</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:10px;font-family:var(--fK)">
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><div style="font-weight:700;color:var(--t1);margin-bottom:1px">📍 유출 위치 보정</div><div style="color:var(--t3)">드론·위성 영상 → GPS 좌표 → 모델 유출지점 갱신</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><div style="font-weight:700;color:var(--t1);margin-bottom:1px">🛢️ 유출량 역산</div><div style="color:var(--t3)">면적×두께 → 유량 추정 → 확산모델 유출량 보정</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><div style="font-weight:700;color:var(--t1);margin-bottom:1px">🌡️ SST 수온 입력</div><div style="color:var(--t3)">NGSST FTP 수신 → Akima 보간 → 풍화모델 수온값</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><div style="font-weight:700;color:var(--t1);margin-bottom:1px">🎨 풍화 상태 확인</div><div style="color:var(--t3)">색상 분류 → 증발비 추정 → 풍화모델 초기값 보정</div></div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-left:3px solid var(--color-warning)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-warning);font-family:var(--font-korean);margin-bottom:8px">📡 항공탐색 → 모델 입력</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:10px;font-family:var(--font-korean)">
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><div style="font-weight:700;color:var(--fg-default);margin-bottom:1px">📍 유출 위치 보정</div><div style="color:var(--fg-disabled)">드론·위성 영상 → GPS 좌표 → 모델 유출지점 갱신</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><div style="font-weight:700;color:var(--fg-default);margin-bottom:1px">🛢️ 유출량 역산</div><div style="color:var(--fg-disabled)">면적×두께 → 유량 추정 → 확산모델 유출량 보정</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><div style="font-weight:700;color:var(--fg-default);margin-bottom:1px">🌡️ SST 수온 입력</div><div style="color:var(--fg-disabled)">NGSST FTP 수신 → Akima 보간 → 풍화모델 수온값</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(249,115,22,.05);border:1px solid rgba(249,115,22,.12);border-radius:5px"><div style="font-weight:700;color:var(--fg-default);margin-bottom:1px">🎨 풍화 상태 확인</div><div style="color:var(--fg-disabled)">색상 분류 → 증발비 추정 → 풍화모델 초기값 보정</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px;border-left:3px solid var(--blue)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--blue);font-family:var(--fK);margin-bottom:8px">🔄 모델 → 항공탐색 피드백</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:10px;font-family:var(--fK)">
|
||||
<div style="padding:6px 9px;background:rgba(59,130,246,.05);border:1px solid rgba(59,130,246,.12);border-radius:5px"><div style="font-weight:700;color:var(--t1);margin-bottom:1px">🗺️ 탐색 우선구역 제공</div><div style="color:var(--t3)">확산 예측 결과 → 다음 탐색 집중구역 자동 생성</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(59,130,246,.05);border:1px solid rgba(59,130,246,.12);border-radius:5px"><div style="font-weight:700;color:var(--t1);margin-bottom:1px">📊 모델 검증 자료</div><div style="color:var(--t3)">실측 유출유 위치 ↔ 예측값 오차 분석 → 정확도 평가</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(59,130,246,.05);border:1px solid rgba(59,130,246,.12);border-radius:5px"><div style="font-weight:700;color:var(--t1);margin-bottom:1px">🏖️ ESI 피해 위험구역</div><div style="color:var(--t3)">확산경로×ESI 중첩 → 항공탐색 ESI 현장확인 우선순위</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(59,130,246,.05);border:1px solid rgba(59,130,246,.12);border-radius:5px"><div style="font-weight:700;color:var(--t1);margin-bottom:1px">🚢 방제자원 배치안</div><div style="color:var(--t3)">예측 도달시간 → 오일펜스·방제정 최적 배치 좌표 제공</div></div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px;border-left:3px solid var(--color-info)">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--color-info);font-family:var(--font-korean);margin-bottom:8px">🔄 모델 → 항공탐색 피드백</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:10px;font-family:var(--font-korean)">
|
||||
<div style="padding:6px 9px;background:rgba(59,130,246,.05);border:1px solid rgba(59,130,246,.12);border-radius:5px"><div style="font-weight:700;color:var(--fg-default);margin-bottom:1px">🗺️ 탐색 우선구역 제공</div><div style="color:var(--fg-disabled)">확산 예측 결과 → 다음 탐색 집중구역 자동 생성</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(59,130,246,.05);border:1px solid rgba(59,130,246,.12);border-radius:5px"><div style="font-weight:700;color:var(--fg-default);margin-bottom:1px">📊 모델 검증 자료</div><div style="color:var(--fg-disabled)">실측 유출유 위치 ↔ 예측값 오차 분석 → 정확도 평가</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(59,130,246,.05);border:1px solid rgba(59,130,246,.12);border-radius:5px"><div style="font-weight:700;color:var(--fg-default);margin-bottom:1px">🏖️ ESI 피해 위험구역</div><div style="color:var(--fg-disabled)">확산경로×ESI 중첩 → 항공탐색 ESI 현장확인 우선순위</div></div>
|
||||
<div style="padding:6px 9px;background:rgba(59,130,246,.05);border:1px solid rgba(59,130,246,.12);border-radius:5px"><div style="font-weight:700;color:var(--fg-default);margin-bottom:1px">🚢 방제자원 배치안</div><div style="color:var(--fg-disabled)">예측 도달시간 → 오일펜스·방제정 최적 배치 좌표 제공</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:12px">📡 특허 10-1567431 실시간 자료 연계 네트워크 (도면 9)</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:12px">📡 특허 10-1567431 실시간 자료 연계 네트워크 (도면 9)</div>
|
||||
<div style="display:flex;align-items:flex-start;justify-content:center;gap:0;padding:8px 0">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;align-items:center">
|
||||
<div style="padding:8px 12px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:7px;text-align:center;font-size:9px;font-family:var(--fK)"><div style="font-weight:700;color:var(--cyan)">기상예측시스템 ①</div><div style="color:var(--t3)">바람·기온·기압<br>국립환경과학원</div></div>
|
||||
<div style="padding:8px 12px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:7px;text-align:center;font-size:9px;font-family:var(--fK)"><div style="font-weight:700;color:var(--orange)">위성영상수신시스템 ②</div><div style="color:var(--t3)">SST(NGSST)<br>토호쿠대학 FTP</div></div>
|
||||
<div style="padding:8px 12px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:7px;text-align:center;font-size:9px;font-family:var(--fK)"><div style="font-weight:700;color:var(--green)">검조소</div><div style="color:var(--t3)">실시간 조위<br>조석정보</div></div>
|
||||
<div style="padding:8px 12px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:7px;text-align:center;font-size:9px;font-family:var(--font-korean)"><div style="font-weight:700;color:var(--color-accent)">기상예측시스템 ①</div><div style="color:var(--fg-disabled)">바람·기온·기압<br>국립환경과학원</div></div>
|
||||
<div style="padding:8px 12px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:7px;text-align:center;font-size:9px;font-family:var(--font-korean)"><div style="font-weight:700;color:var(--color-warning)">위성영상수신시스템 ②</div><div style="color:var(--fg-disabled)">SST(NGSST)<br>토호쿠대학 FTP</div></div>
|
||||
<div style="padding:8px 12px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:7px;text-align:center;font-size:9px;font-family:var(--font-korean)"><div style="font-weight:700;color:var(--color-success)">검조소</div><div style="color:var(--fg-disabled)">실시간 조위<br>조석정보</div></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;justify-content:center;height:160px;padding:0 12px;gap:2px">
|
||||
<div style="width:30px;height:1px;background:var(--bdL)"></div>
|
||||
<div style="width:30px;height:1px;background:var(--bdL)"></div>
|
||||
<div style="width:30px;height:1px;background:var(--bdL)"></div>
|
||||
<div style="width:30px;height:1px;background:var(--stroke-light)"></div>
|
||||
<div style="width:30px;height:1px;background:var(--stroke-light)"></div>
|
||||
<div style="width:30px;height:1px;background:var(--stroke-light)"></div>
|
||||
</div>
|
||||
<div style="padding:16px 20px;background:rgba(168,85,247,.08);border:2px solid rgba(168,85,247,.3);border-radius:10px;text-align:center;font-size:10px;font-family:var(--fK);align-self:center">
|
||||
<div style="padding:16px 20px;background:rgba(168,85,247,.08);border:2px solid rgba(168,85,247,.3);border-radius:10px;text-align:center;font-size:10px;font-family:var(--font-korean);align-self:center">
|
||||
<div style="font-size:16px;margin-bottom:4px">🖥️</div>
|
||||
<div style="font-weight:700;color:var(--purple)">서버(WING)</div><div style="color:var(--t3);font-size:9px">데이터 수신·처리<br>모델 구동</div>
|
||||
<div style="font-weight:700;color:var(--color-tertiary)">서버(WING)</div><div style="color:var(--fg-disabled);font-size:9px">데이터 수신·처리<br>모델 구동</div>
|
||||
</div>
|
||||
<div style="width:30px;height:1px;background:var(--bdL);align-self:center"></div>
|
||||
<div style="width:30px;height:1px;background:var(--stroke-light);align-self:center"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;align-items:center;align-self:center">
|
||||
<div style="padding:8px 12px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:7px;text-align:center;font-size:9px;font-family:var(--fK)"><div style="font-weight:700;color:var(--orange)">클라이언트</div><div style="color:var(--t3)">유출지점·유출량<br>입력 및 결과 수령</div></div>
|
||||
<div style="padding:8px 12px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:7px;text-align:center;font-size:9px;font-family:var(--font-korean)"><div style="font-weight:700;color:var(--color-warning)">클라이언트</div><div style="color:var(--fg-disabled)">유출지점·유출량<br>입력 및 결과 수령</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:var(--t3);font-family:var(--fK);text-align:center;margin-top:8px">기상자료·수온자료·조석정보 실시간 수신 → CHARRY 조류 + 취송류 예측 → 유출유 확산 예측 (S10→S20→S30)</div>
|
||||
<div style="font-size:9px;color:var(--fg-disabled);font-family:var(--font-korean);text-align:center;margin-top:8px">기상자료·수온자료·조석정보 실시간 수신 → CHARRY 조류 + 취송류 예측 → 유출유 확산 예측 (S10→S20→S30)</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@ -476,108 +476,108 @@ const panel6Html = `
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">
|
||||
<div style="width:36px;height:36px;border-radius:9px;background:linear-gradient(135deg,rgba(234,179,8,.2),rgba(249,115,22,.15));border:1px solid rgba(234,179,8,.3);display:flex;align-items:center;justify-content:center;font-size:18px">📜</div>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:700;color:var(--t1);font-family:var(--fK)">등록특허 원문 기반 이론 근거</div>
|
||||
<div style="font-size:9px;color:var(--t3);font-family:var(--fK);margin-top:1px">WING 탑재 유출유 확산예측 시스템의 특허 원전 2건 전체 분석</div>
|
||||
<div style="font-size:14px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean)">등록특허 원문 기반 이론 근거</div>
|
||||
<div style="font-size:9px;color:var(--fg-disabled);font-family:var(--font-korean);margin-top:1px">WING 탑재 유출유 확산예측 시스템의 특허 원전 2건 전체 분석</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 특허 1: 10-1567431 -->
|
||||
<div style="background:linear-gradient(135deg,rgba(6,182,212,.05),rgba(34,197,94,.03));border:1px solid rgba(6,182,212,.25);border-radius:12px;padding:18px;margin-bottom:16px;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--cyan),var(--green))"></div>
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--color-accent),var(--color-success))"></div>
|
||||
<div style="display:flex;align-items:flex-start;gap:14px;margin-bottom:14px">
|
||||
<div style="padding:8px 12px;background:rgba(6,182,212,.12);border:1px solid rgba(6,182,212,.3);border-radius:8px;text-align:center;white-space:nowrap;flex-shrink:0">
|
||||
<div style="font-size:8px;color:var(--t3);font-family:var(--fK)">대한민국 등록특허</div>
|
||||
<div style="font-size:13px;font-weight:800;color:var(--cyan);font-family:var(--fM)">10-1567431</div>
|
||||
<div style="font-size:8px;color:var(--t3);font-family:var(--fK);margin-top:2px">등록: 2015.11.03</div>
|
||||
<div style="font-size:8px;color:var(--fg-disabled);font-family:var(--font-korean)">대한민국 등록특허</div>
|
||||
<div style="font-size:13px;font-weight:800;color:var(--color-accent);font-family:var(--font-mono)">10-1567431</div>
|
||||
<div style="font-size:8px;color:var(--fg-disabled);font-family:var(--font-korean);margin-top:2px">등록: 2015.11.03</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:13px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:5px;line-height:1.5">해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법</div>
|
||||
<div style="font-size:9px;color:var(--t2);font-family:var(--fK);line-height:1.8"><span style="color:var(--cyan)">특허권자</span> : 한국해양과학기술원 | <span style="color:var(--cyan)">발명자</span> : 이문진 · 김혜진 · 이승현 · 전태병</div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:5px;line-height:1.5">해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법</div>
|
||||
<div style="font-size:9px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8"><span style="color:var(--color-accent)">특허권자</span> : 한국해양과학기술원 | <span style="color:var(--color-accent)">발명자</span> : 이문진 · 김혜진 · 이승현 · 전태병</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg0);border:1px solid rgba(6,182,212,.15);border-radius:8px;padding:12px;margin-bottom:12px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--cyan);font-family:var(--fK);margin-bottom:9px">📋 청구항 1 — ESI 기반 실시간 확산 3단계</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;font-size:10px;font-family:var(--fK)">
|
||||
<div style="display:flex;gap:10px;align-items:flex-start"><div style="min-width:32px;height:22px;background:rgba(249,115,22,.15);border:1px solid rgba(249,115,22,.3);border-radius:5px;display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--orange);font-size:10px;flex-shrink:0">S10</div><div style="color:var(--t2);line-height:1.7"><b style="color:var(--t1)">실시간 자료 수신</b> — 기상예측시스템·위성영상수신시스템·검조소 인터넷 연결. FTP로 기상자료+수온자료(NGSST)+조석정보 자동 수신</div></div>
|
||||
<div style="display:flex;gap:10px;align-items:flex-start"><div style="min-width:32px;height:22px;background:rgba(6,182,212,.15);border:1px solid rgba(6,182,212,.3);border-radius:5px;display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--cyan);font-size:10px;flex-shrink:0">S20</div><div style="color:var(--t2);line-height:1.7"><b style="color:var(--t1)">조류·취송류 예측</b> — CHARRY모델 조화분석 + 취송류 경험식(0.029×Vw, θw+18.6°)</div></div>
|
||||
<div style="display:flex;gap:10px;align-items:flex-start"><div style="min-width:32px;height:22px;background:rgba(34,197,94,.15);border:1px solid rgba(34,197,94,.3);border-radius:5px;display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--green);font-size:10px;flex-shrink:0">S30</div><div style="color:var(--t2);line-height:1.7"><b style="color:var(--t1)">유출유 확산 실시간 예측</b> — Monte Carlo 입자추적 + fBm 난류확산 + 풍화 5단계 → ESI 방제정보지도 기반 방제방안 수립</div></div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(6,182,212,.15);border-radius:8px;padding:12px;margin-bottom:12px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--color-accent);font-family:var(--font-korean);margin-bottom:9px">📋 청구항 1 — ESI 기반 실시간 확산 3단계</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;font-size:10px;font-family:var(--font-korean)">
|
||||
<div style="display:flex;gap:10px;align-items:flex-start"><div style="min-width:32px;height:22px;background:rgba(249,115,22,.15);border:1px solid rgba(249,115,22,.3);border-radius:5px;display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--color-warning);font-size:10px;flex-shrink:0">S10</div><div style="color:var(--fg-sub);line-height:1.7"><b style="color:var(--fg-default)">실시간 자료 수신</b> — 기상예측시스템·위성영상수신시스템·검조소 인터넷 연결. FTP로 기상자료+수온자료(NGSST)+조석정보 자동 수신</div></div>
|
||||
<div style="display:flex;gap:10px;align-items:flex-start"><div style="min-width:32px;height:22px;background:rgba(6,182,212,.15);border:1px solid rgba(6,182,212,.3);border-radius:5px;display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--color-accent);font-size:10px;flex-shrink:0">S20</div><div style="color:var(--fg-sub);line-height:1.7"><b style="color:var(--fg-default)">조류·취송류 예측</b> — CHARRY모델 조화분석 + 취송류 경험식(0.029×Vw, θw+18.6°)</div></div>
|
||||
<div style="display:flex;gap:10px;align-items:flex-start"><div style="min-width:32px;height:22px;background:rgba(34,197,94,.15);border:1px solid rgba(34,197,94,.3);border-radius:5px;display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--color-success);font-size:10px;flex-shrink:0">S30</div><div style="color:var(--fg-sub);line-height:1.7"><b style="color:var(--fg-default)">유출유 확산 실시간 예측</b> — Monte Carlo 입자추적 + fBm 난류확산 + 풍화 5단계 → ESI 방제정보지도 기반 방제방안 수립</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg0);border:1px solid rgba(34,197,94,.15);border-radius:8px;padding:11px;margin-bottom:10px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--green);font-family:var(--fK);margin-bottom:7px">🌀 fBm 난류확산 | <span style="color:var(--t2);font-weight:400">σ²(t) = A·t^m, m=0.45~2.46</span></div>
|
||||
<div style="font-size:9px;color:var(--t2);font-family:var(--fK);line-height:1.7">분수 브라운운동(fBm) 기반 무작위 확산거리 생성. 등방성(isotropic) 확산 가정.</div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(34,197,94,.15);border-radius:8px;padding:11px;margin-bottom:10px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--color-success);font-family:var(--font-korean);margin-bottom:7px">🌀 fBm 난류확산 | <span style="color:var(--fg-sub);font-weight:400">σ²(t) = A·t^m, m=0.45~2.46</span></div>
|
||||
<div style="font-size:9px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7">분수 브라운운동(fBm) 기반 무작위 확산거리 생성. 등방성(isotropic) 확산 가정.</div>
|
||||
</div>
|
||||
<div style="background:var(--bg0);border:1px solid rgba(249,115,22,.15);border-radius:8px;padding:12px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--orange);font-family:var(--fK);margin-bottom:9px">🧪 유출유 풍화(Weathering) 5단계</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--orange)">① 퍼짐</div><div style="color:var(--t2);line-height:1.6">Fay(1969): 중력-관성력. Mackay et al.(1980) 표면장력-점성력</div></div>
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--yellow)">② 증발</div><div style="color:var(--t2);line-height:1.6">Stiver & Mackay(1984) 해석적 방법. 수일~10일간 약 25%</div></div>
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--cyan)">③ 소산</div><div style="color:var(--t2);line-height:1.6">쇄파 기인. 파도에너지·풍속 함수. 전체 약 15%</div></div>
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--blue)">④ 유상화</div><div style="color:var(--t2);line-height:1.6">Water-in-oil. Mackay et al.(1980) 풍속·수분 함수</div></div>
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--t2)">⑤ 침강</div><div style="color:var(--t2);line-height:1.6">용해·미생물 분해. 질량 손실률 = 초기 누유량에 선형 비례</div></div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(249,115,22,.15);border-radius:8px;padding:12px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--color-warning);font-family:var(--font-korean);margin-bottom:9px">🧪 유출유 풍화(Weathering) 5단계</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--color-warning)">① 퍼짐</div><div style="color:var(--fg-sub);line-height:1.6">Fay(1969): 중력-관성력. Mackay et al.(1980) 표면장력-점성력</div></div>
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--color-caution)">② 증발</div><div style="color:var(--fg-sub);line-height:1.6">Stiver & Mackay(1984) 해석적 방법. 수일~10일간 약 25%</div></div>
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--color-accent)">③ 소산</div><div style="color:var(--fg-sub);line-height:1.6">쇄파 기인. 파도에너지·풍속 함수. 전체 약 15%</div></div>
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--color-info)">④ 유상화</div><div style="color:var(--fg-sub);line-height:1.6">Water-in-oil. Mackay et al.(1980) 풍속·수분 함수</div></div>
|
||||
<div style="display:grid;grid-template-columns:60px 1fr;gap:8px;padding:5px 8px;background:rgba(249,115,22,.04);border-radius:4px"><div style="font-weight:700;color:var(--fg-sub)">⑤ 침강</div><div style="color:var(--fg-sub);line-height:1.6">용해·미생물 분해. 질량 손실률 = 초기 누유량에 선형 비례</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 특허 2: 10-1868791 -->
|
||||
<div style="background:linear-gradient(135deg,rgba(59,130,246,.05),rgba(168,85,247,.03));border:1px solid rgba(59,130,246,.25);border-radius:12px;padding:18px;margin-bottom:16px;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--blue),var(--purple))"></div>
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--color-info),var(--color-tertiary))"></div>
|
||||
<div style="display:flex;align-items:flex-start;gap:14px;margin-bottom:14px">
|
||||
<div style="padding:8px 12px;background:rgba(59,130,246,.12);border:1px solid rgba(59,130,246,.3);border-radius:8px;text-align:center;white-space:nowrap;flex-shrink:0">
|
||||
<div style="font-size:8px;color:var(--t3);font-family:var(--fK)">대한민국 등록특허</div>
|
||||
<div style="font-size:13px;font-weight:800;color:var(--blue);font-family:var(--fM)">10-1868791</div>
|
||||
<div style="font-size:8px;color:var(--t3);font-family:var(--fK);margin-top:2px">등록: 2018.06.12</div>
|
||||
<div style="font-size:8px;color:var(--fg-disabled);font-family:var(--font-korean)">대한민국 등록특허</div>
|
||||
<div style="font-size:13px;font-weight:800;color:var(--color-info);font-family:var(--font-mono)">10-1868791</div>
|
||||
<div style="font-size:8px;color:var(--fg-disabled);font-family:var(--font-korean);margin-top:2px">등록: 2018.06.12</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:13px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:5px;line-height:1.5">유출유(Oil spill) 확산 예측을 위한 입자 추적 모듈 최적화 방법 및 이를 이용한 예측 시스템</div>
|
||||
<div style="font-size:9px;color:var(--t2);font-family:var(--fK);line-height:1.8"><span style="color:var(--blue)">특허권자</span> : 주식회사 아라종합기술 | <span style="color:var(--blue)">발명자</span> : 김도연·김용혁·김충기·김성은·박상훈·오정환</div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:5px;line-height:1.5">유출유(Oil spill) 확산 예측을 위한 입자 추적 모듈 최적화 방법 및 이를 이용한 예측 시스템</div>
|
||||
<div style="font-size:9px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.8"><span style="color:var(--color-info)">특허권자</span> : 주식회사 아라종합기술 | <span style="color:var(--color-info)">발명자</span> : 김도연·김용혁·김충기·김성은·박상훈·오정환</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg0);border:1px solid rgba(59,130,246,.15);border-radius:8px;padding:11px;margin-bottom:10px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--blue);font-family:var(--fK);margin-bottom:9px">⚙️ 입자 추적 모듈 최적화 5단계</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(59,130,246,.04);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--blue)">(a)</span><div style="color:var(--t2);line-height:1.6"><b>뜰개 관측 + 예측자료 취득</b> : GPS 뜰개 투하 → 실제 이동경로 + 예측 기상·해양자료 취득</div></div>
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(59,130,246,.04);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--blue)">(b)</span><div style="color:var(--t2);line-height:1.6"><b>제1 입자 추적 모델 실행</b> : 예측자료 + 확산계수 → 제1 예측변화량(ΔModel) 산출</div></div>
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(59,130,246,.04);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--blue)">(c)</span><div style="color:var(--t2);line-height:1.6"><b>전처리 차분</b> : 관측경로 Δobs ↔ 제1모델 ΔModel 차분 처리</div></div>
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(59,130,246,.04);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--blue)">(d)</span><div style="color:var(--t2);line-height:1.6"><b>제2 입자 추적 모델 수립</b> : ΔModel 기반 제2모델 → ΔRevised 산출</div></div>
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(168,85,247,.05);border:1px solid rgba(168,85,247,.12);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--purple)">(e)</span><div style="color:var(--t2);line-height:1.6"><b>최적화 알고리즘 적용</b> : ΔRevised ↔ Δobs 비교 → <b style="color:var(--purple)">GA·DE·HS·PSO</b> 매개변수 최적화 반복 수렴</div></div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(59,130,246,.15);border-radius:8px;padding:11px;margin-bottom:10px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--color-info);font-family:var(--font-korean);margin-bottom:9px">⚙️ 입자 추적 모듈 최적화 5단계</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(59,130,246,.04);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--color-info)">(a)</span><div style="color:var(--fg-sub);line-height:1.6"><b>뜰개 관측 + 예측자료 취득</b> : GPS 뜰개 투하 → 실제 이동경로 + 예측 기상·해양자료 취득</div></div>
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(59,130,246,.04);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--color-info)">(b)</span><div style="color:var(--fg-sub);line-height:1.6"><b>제1 입자 추적 모델 실행</b> : 예측자료 + 확산계수 → 제1 예측변화량(ΔModel) 산출</div></div>
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(59,130,246,.04);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--color-info)">(c)</span><div style="color:var(--fg-sub);line-height:1.6"><b>전처리 차분</b> : 관측경로 Δobs ↔ 제1모델 ΔModel 차분 처리</div></div>
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(59,130,246,.04);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--color-info)">(d)</span><div style="color:var(--fg-sub);line-height:1.6"><b>제2 입자 추적 모델 수립</b> : ΔModel 기반 제2모델 → ΔRevised 산출</div></div>
|
||||
<div style="display:flex;gap:7px;padding:5px 8px;background:rgba(168,85,247,.05);border:1px solid rgba(168,85,247,.12);border-radius:4px"><span style="min-width:20px;font-weight:800;color:var(--color-tertiary)">(e)</span><div style="color:var(--fg-sub);line-height:1.6"><b>최적화 알고리즘 적용</b> : ΔRevised ↔ Δobs 비교 → <b style="color:var(--color-tertiary)">GA·DE·HS·PSO</b> 매개변수 최적화 반복 수렴</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div style="background:var(--bg0);border:1px solid var(--bd);border-radius:8px;padding:11px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:7px">입자 추적 수학 모델</div>
|
||||
<div style="background:rgba(59,130,246,.04);border:1px solid rgba(59,130,246,.12);border-radius:5px;padding:8px;font-family:var(--fM);font-size:9px;color:var(--t1);line-height:2">
|
||||
<span style="color:var(--t3)">제1모델:</span> Model<sub>x</sub> = cur<sub>u</sub>·Δt + c·w<sub>u</sub>·Δt<br>
|
||||
<span style="color:var(--t3)">제2모델:</span> Rev<sub>x</sub> = a1·cur<sub>u</sub>+a2·cur<sub>v</sub>+...+a9
|
||||
<div style="background:var(--bg-base);border:1px solid var(--stroke-default);border-radius:8px;padding:11px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:7px">입자 추적 수학 모델</div>
|
||||
<div style="background:rgba(59,130,246,.04);border:1px solid rgba(59,130,246,.12);border-radius:5px;padding:8px;font-family:var(--font-mono);font-size:9px;color:var(--fg-default);line-height:2">
|
||||
<span style="color:var(--fg-disabled)">제1모델:</span> Model<sub>x</sub> = cur<sub>u</sub>·Δt + c·w<sub>u</sub>·Δt<br>
|
||||
<span style="color:var(--fg-disabled)">제2모델:</span> Rev<sub>x</sub> = a1·cur<sub>u</sub>+a2·cur<sub>v</sub>+...+a9
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg0);border:1px solid rgba(168,85,247,.15);border-radius:8px;padding:11px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--purple);font-family:var(--fK);margin-bottom:7px">4대 최적화 알고리즘</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="padding:3px 7px;background:rgba(168,85,247,.04);border-radius:4px;color:var(--t2)"><b style="color:var(--purple)">GA</b> : 유전 알고리즘 — 변이·교배 진화</div>
|
||||
<div style="padding:3px 7px;background:rgba(59,130,246,.04);border-radius:4px;color:var(--t2)"><b style="color:var(--blue)">DE</b> : 미분 진화 — 벡터 차이 기반 전역최적화</div>
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.04);border-radius:4px;color:var(--t2)"><b style="color:var(--green)">HS</b> : 하모니 서치 — 음악구성 수리모델</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.04);border-radius:4px;color:var(--t2)"><b style="color:var(--orange)">PSO</b> : 입자군집 최적화 — 새떼 군집행동 모방</div>
|
||||
<div style="background:var(--bg-base);border:1px solid rgba(168,85,247,.15);border-radius:8px;padding:11px">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--color-tertiary);font-family:var(--font-korean);margin-bottom:7px">4대 최적화 알고리즘</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="padding:3px 7px;background:rgba(168,85,247,.04);border-radius:4px;color:var(--fg-sub)"><b style="color:var(--color-tertiary)">GA</b> : 유전 알고리즘 — 변이·교배 진화</div>
|
||||
<div style="padding:3px 7px;background:rgba(59,130,246,.04);border-radius:4px;color:var(--fg-sub)"><b style="color:var(--color-info)">DE</b> : 미분 진화 — 벡터 차이 기반 전역최적화</div>
|
||||
<div style="padding:3px 7px;background:rgba(34,197,94,.04);border-radius:4px;color:var(--fg-sub)"><b style="color:var(--color-success)">HS</b> : 하모니 서치 — 음악구성 수리모델</div>
|
||||
<div style="padding:3px 7px;background:rgba(249,115,22,.04);border-radius:4px;color:var(--fg-sub)"><b style="color:var(--color-warning)">PSO</b> : 입자군집 최적화 — 새떼 군집행동 모방</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 선행기술 참고문헌 -->
|
||||
<div style="background:var(--bg3);border:1px solid var(--bd);border-radius:10px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--t1);font-family:var(--fK);margin-bottom:10px">📚 특허 원문 인용 선행기술문헌 (심사관 인용 포함)</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--fK)">
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(6,182,212,.1);border-radius:3px;color:var(--cyan);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허① 인용</div><div style="color:var(--t2);line-height:1.6">해양환경안전학회지 제17권 4호 (김혜진·이문진 외) — KOSPS 상시 운용 체계 | 심사관 직접 인용</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(6,182,212,.1);border-radius:3px;color:var(--cyan);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허① 인용</div><div style="color:var(--t2);line-height:1.6">해양환경안전학회 2008 춘계학술발표회 — CHARRY 조류모델 | 심사관 직접 인용</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(6,182,212,.1);border-radius:3px;color:var(--cyan);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허① 인용</div><div style="color:var(--t2);line-height:1.6">KR1020120121163 A — 심사관 인용 선행특허</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(59,130,246,.1);border-radius:3px;color:var(--blue);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허② 인용</div><div style="color:var(--t2);line-height:1.6">KR101538668 B1 / KR101378463 B1 — 심사관 인용 선행특허 2건</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(59,130,246,.1);border-radius:3px;color:var(--blue);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허② 인용</div><div style="color:var(--t2);line-height:1.6">한국 등록특허 제10-1567431 — 발명배경 §[0007]에서 선행기술로 직접 인용</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--orange);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--t2);line-height:1.6">Fay(1969) · Mackay et al.(1980) · Stiver & Mackay(1984) · Mooney(1951) — 풍화 5단계 원전</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--orange);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--t2);line-height:1.6">Akima(1978a, 1978b) — 2차원 5차다항식 보간법 (수심·기상자료 보간)</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--orange);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--t2);line-height:1.6">이문진·강용균(2000) 한국해양학회지 — 취송류 경험식 0.029×Vw, θw+18.6° 원전</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--orange);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--t2);line-height:1.6">Bowden(1983) — fBm 난류확산 σ²=At^m (m=0.45~2.46)</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--orange);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--t2);line-height:1.6">Wahr(1981) — 조석 Love number (k=0.3, h=0.61) · 기조력 계수</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg0);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--orange);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--t2);line-height:1.6">Flather & Heaps(1975) — 조석 간출지(tidal flat) 처리 기법</div></div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:10px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean);margin-bottom:10px">📚 특허 원문 인용 선행기술문헌 (심사관 인용 포함)</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;font-size:9px;font-family:var(--font-korean)">
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(6,182,212,.1);border-radius:3px;color:var(--color-accent);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허① 인용</div><div style="color:var(--fg-sub);line-height:1.6">해양환경안전학회지 제17권 4호 (김혜진·이문진 외) — KOSPS 상시 운용 체계 | 심사관 직접 인용</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(6,182,212,.1);border-radius:3px;color:var(--color-accent);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허① 인용</div><div style="color:var(--fg-sub);line-height:1.6">해양환경안전학회 2008 춘계학술발표회 — CHARRY 조류모델 | 심사관 직접 인용</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(6,182,212,.1);border-radius:3px;color:var(--color-accent);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허① 인용</div><div style="color:var(--fg-sub);line-height:1.6">KR1020120121163 A — 심사관 인용 선행특허</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(59,130,246,.1);border-radius:3px;color:var(--color-info);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허② 인용</div><div style="color:var(--fg-sub);line-height:1.6">KR101538668 B1 / KR101378463 B1 — 심사관 인용 선행특허 2건</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(59,130,246,.1);border-radius:3px;color:var(--color-info);font-weight:700;font-size:8px;text-align:center;height:fit-content">특허② 인용</div><div style="color:var(--fg-sub);line-height:1.6">한국 등록특허 제10-1567431 — 발명배경 §[0007]에서 선행기술로 직접 인용</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--color-warning);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--fg-sub);line-height:1.6">Fay(1969) · Mackay et al.(1980) · Stiver & Mackay(1984) · Mooney(1951) — 풍화 5단계 원전</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--color-warning);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--fg-sub);line-height:1.6">Akima(1978a, 1978b) — 2차원 5차다항식 보간법 (수심·기상자료 보간)</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--color-warning);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--fg-sub);line-height:1.6">이문진·강용균(2000) 한국해양학회지 — 취송류 경험식 0.029×Vw, θw+18.6° 원전</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--color-warning);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--fg-sub);line-height:1.6">Bowden(1983) — fBm 난류확산 σ²=At^m (m=0.45~2.46)</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--color-warning);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--fg-sub);line-height:1.6">Wahr(1981) — 조석 Love number (k=0.3, h=0.61) · 기조력 계수</div></div>
|
||||
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--color-warning);font-weight:700;font-size:8px;text-align:center;height:fit-content">이론 원전</div><div style="color:var(--fg-sub);line-height:1.6">Flather & Heaps(1975) — 조석 간출지(tidal flat) 처리 기법</div></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@ -34,7 +34,7 @@ export function AerialView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex flex-col h-full w-full bg-bg-base">
|
||||
<div className="flex-1 overflow-auto px-6 py-5">
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
@ -226,12 +226,12 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||||
// 오프라인
|
||||
if (playerState === 'offline') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
||||
<div className="text-2xl opacity-30 mb-2">📹</div>
|
||||
<div className="text-[11px] font-korean text-text-3 opacity-70">
|
||||
<div className="text-[11px] font-korean text-fg-disabled opacity-70">
|
||||
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
|
||||
</div>
|
||||
<div className="text-[9px] font-korean text-text-3 opacity-40 mt-1">{cameraNm}</div>
|
||||
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -239,10 +239,10 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||||
// URL 미설정
|
||||
if (playerState === 'no-url') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
||||
<div className="text-2xl opacity-20 mb-2">📹</div>
|
||||
<div className="text-[10px] font-korean text-text-3 opacity-50">스트림 URL 미설정</div>
|
||||
<div className="text-[9px] font-korean text-text-3 opacity-30 mt-1">{cameraNm}</div>
|
||||
<div className="text-[10px] font-korean text-fg-disabled opacity-50">스트림 URL 미설정</div>
|
||||
<div className="text-[9px] font-korean text-fg-disabled opacity-30 mt-1">{cameraNm}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -250,13 +250,13 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||||
// 에러
|
||||
if (playerState === 'error') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
||||
<div className="text-2xl opacity-30 mb-2">⚠️</div>
|
||||
<div className="text-[10px] font-korean text-status-red opacity-70">연결 실패</div>
|
||||
<div className="text-[9px] font-korean text-text-3 opacity-40 mt-1">{cameraNm}</div>
|
||||
<div className="text-[10px] font-korean text-color-danger opacity-70">연결 실패</div>
|
||||
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
|
||||
<button
|
||||
onClick={() => setRetryKey(k => k + 1)}
|
||||
className="mt-2 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
className="mt-2 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
@ -268,9 +268,9 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||||
<div ref={containerRef} className="absolute inset-0">
|
||||
{/* 로딩 오버레이 */}
|
||||
{playerState === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base z-10">
|
||||
<div className="text-lg opacity-40 animate-pulse mb-2">📹</div>
|
||||
<div className="text-[10px] font-korean text-text-3 opacity-50">연결 중...</div>
|
||||
<div className="text-[10px] font-korean text-fg-disabled opacity-50">연결 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -338,14 +338,14 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||||
</span>
|
||||
{sttsCd === 'LIVE' && (
|
||||
<span
|
||||
className="text-[8px] font-bold px-1 py-0.5 rounded text-[#f87171]"
|
||||
className="text-[8px] font-bold px-1 py-0.5 rounded text-color-danger"
|
||||
style={{ background: 'rgba(239,68,68,.3)' }}
|
||||
>
|
||||
● REC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
|
||||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-fg-disabled bg-black/70 z-20">
|
||||
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -155,23 +155,23 @@ export function CctvView() {
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||
{/* 왼쪽: 목록 패널 */}
|
||||
<div className="flex flex-col overflow-hidden bg-bg-1 border-r border-border w-[290px] min-w-[290px]">
|
||||
<div className="flex flex-col overflow-hidden bg-bg-surface border-r border-stroke w-[290px] min-w-[290px]">
|
||||
{/* 헤더 */}
|
||||
<div className="p-3 pb-2.5 border-b border-border shrink-0 bg-bg-2">
|
||||
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />
|
||||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-danger)' }} />
|
||||
실시간 해안 CCTV
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 지도/리스트 뷰 토글 */}
|
||||
<div className="flex border border-border rounded-[5px] overflow-hidden mr-1.5">
|
||||
<div className="flex border border-stroke rounded-[5px] overflow-hidden mr-1.5">
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className="px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer border-none font-korean transition-colors"
|
||||
style={viewMode === 'map'
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t3)' }
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
|
||||
: { background: 'var(--bg-card)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
title="지도 보기"
|
||||
>🗺 지도</button>
|
||||
@ -179,25 +179,25 @@ export function CctvView() {
|
||||
onClick={() => setViewMode('list')}
|
||||
className="px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer border-none font-korean transition-colors"
|
||||
style={viewMode === 'list'
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t3)' }
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
|
||||
: { background: 'var(--bg-card)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
title="리스트 보기"
|
||||
>☰ 리스트</button>
|
||||
</div>
|
||||
<span className="text-[9px] text-text-3 font-korean">API</span>
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: 'var(--green)' }} />
|
||||
<span className="text-[9px] text-fg-disabled font-korean">API</span>
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: 'var(--color-success)' }} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 검색 */}
|
||||
<div className="flex items-center gap-2 bg-bg-0 border border-border rounded-md px-2.5 py-1.5 mb-2 focus-within:border-primary-cyan/50 transition-colors">
|
||||
<span className="text-text-3 text-[11px]">🔍</span>
|
||||
<div className="flex items-center gap-2 bg-bg-base border border-stroke rounded-md px-2.5 py-1.5 mb-2 focus-within:border-color-accent/50 transition-colors">
|
||||
<span className="text-fg-disabled text-[11px]">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="지점명 또는 지역 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-transparent border-none text-text-1 text-[11px] font-korean outline-none"
|
||||
className="flex-1 bg-transparent border-none text-fg text-[11px] font-korean outline-none"
|
||||
/>
|
||||
</div>
|
||||
{/* 지역 필터 */}
|
||||
@ -208,8 +208,8 @@ export function CctvView() {
|
||||
onClick={() => setRegionFilter(r)}
|
||||
className="px-2 py-0.5 rounded text-[9px] font-semibold cursor-pointer font-korean border transition-colors"
|
||||
style={regionFilter === r
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)', borderColor: 'rgba(6,182,212,.3)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)', borderColor: 'rgba(6,182,212,.3)' }
|
||||
: { background: 'var(--bg-card)', color: 'var(--fg-sub)', borderColor: 'var(--stroke-default)' }
|
||||
}
|
||||
>{regionIcons[r] ? `${regionIcons[r]} ` : ''}{r}</button>
|
||||
))}
|
||||
@ -217,15 +217,15 @@ export function CctvView() {
|
||||
</div>
|
||||
|
||||
{/* 상태 바 */}
|
||||
<div className="flex items-center justify-between px-3.5 py-1 border-b border-border shrink-0 bg-bg-1">
|
||||
<div className="text-[9px] text-text-3 font-korean">출처: 국립해양조사원 · KBS 재난안전포털</div>
|
||||
<div className="text-[10px] text-text-2 font-korean"><b className="text-text-1">{filtered.length}</b>개</div>
|
||||
<div className="flex items-center justify-between px-3.5 py-1 border-b border-stroke shrink-0 bg-bg-surface">
|
||||
<div className="text-[9px] text-fg-disabled font-korean">출처: 국립해양조사원 · KBS 재난안전포털</div>
|
||||
<div className="text-[10px] text-fg-sub font-korean"><b className="text-fg">{filtered.length}</b>개</div>
|
||||
</div>
|
||||
|
||||
{/* 카메라 목록 */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
{loading ? (
|
||||
<div className="px-3.5 py-4 text-[11px] text-text-3 font-korean">불러오는 중...</div>
|
||||
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean">불러오는 중...</div>
|
||||
) : filtered.map(cam => (
|
||||
<div
|
||||
key={cam.cctvSn}
|
||||
@ -237,27 +237,27 @@ export function CctvView() {
|
||||
}}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-md bg-bg-card flex items-center justify-center">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="6" width="13" height="10" rx="2" fill={cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)'} />
|
||||
<circle cx="9.5" cy="11" r="2.8" fill="var(--bg3)" />
|
||||
<circle cx="9.5" cy="11" r="1.3" fill={cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)'} />
|
||||
<path d="M17 9l4-2v10l-4-2V9z" fill={cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)'} opacity="0.7" />
|
||||
<rect x="3" y="6" width="13" height="10" rx="2" fill={cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)'} />
|
||||
<circle cx="9.5" cy="11" r="2.8" fill="var(--bg-card)" />
|
||||
<circle cx="9.5" cy="11" r="1.3" fill={cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)'} />
|
||||
<path d="M17 9l4-2v10l-4-2V9z" fill={cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)'} opacity="0.7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)' }} />
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean truncate">{cam.cameraNm}</div>
|
||||
<div className="text-[9px] text-text-3 font-korean truncate">{cam.locDc ?? ''}</div>
|
||||
<div className="text-[11px] font-semibold text-fg font-korean truncate">{cam.cameraNm}</div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean truncate">{cam.locDc ?? ''}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||
{cam.sttsCd === 'LIVE' ? (
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)' }}>LIVE</span>
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--color-success)' }}>LIVE</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--t3)' }}>OFF</span>
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--fg-disabled)' }}>OFF</span>
|
||||
)}
|
||||
{cam.ptzYn === 'Y' && <span className="text-[8px] text-text-3 font-mono">PTZ</span>}
|
||||
{cam.ptzYn === 'Y' && <span className="text-[8px] text-fg-disabled font-mono">PTZ</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -265,35 +265,35 @@ export function CctvView() {
|
||||
</div>
|
||||
|
||||
{/* 가운데: 영상 뷰어 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-[#04070f]">
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-bg-base">
|
||||
{/* 뷰어 툴바 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-2 shrink-0 gap-2.5">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
|
||||
</div>
|
||||
{selectedCamera?.sttsCd === 'LIVE' && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', color: 'var(--red)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />LIVE
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', color: 'var(--color-danger)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-danger)' }} />LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* PTZ 컨트롤 */}
|
||||
{selectedCamera?.ptzYn === 'Y' && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-bg-3 border border-border rounded-[5px]">
|
||||
<span className="text-[9px] text-text-3 font-korean mr-1">PTZ</span>
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-bg-card border border-stroke rounded-[5px]">
|
||||
<span className="text-[9px] text-fg-disabled font-korean mr-1">PTZ</span>
|
||||
{['◀', '▲', '▼', '▶'].map((d, i) => (
|
||||
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-0 border border-border rounded text-[9px] text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">{d}</button>
|
||||
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-base border border-stroke rounded text-[9px] text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">{d}</button>
|
||||
))}
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
{['+', '−'].map((z, i) => (
|
||||
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-0 border border-border rounded text-[9px] text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">{z}</button>
|
||||
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-base border border-stroke rounded text-[9px] text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">{z}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 분할 모드 */}
|
||||
<div className="flex border border-border rounded-[5px] overflow-hidden">
|
||||
<div className="flex border border-stroke rounded-[5px] overflow-hidden">
|
||||
{[
|
||||
{ mode: 1, icon: '▣', label: '1화면' },
|
||||
{ mode: 4, icon: '⊞', label: '4분할' },
|
||||
@ -305,8 +305,8 @@ export function CctvView() {
|
||||
title={g.label}
|
||||
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
|
||||
style={gridMode === g.mode
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t2)' }
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
|
||||
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
|
||||
}
|
||||
>{g.icon}</button>
|
||||
))}
|
||||
@ -315,8 +315,8 @@ export function CctvView() {
|
||||
onClick={() => setOilDetectionEnabled(v => !v)}
|
||||
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||||
style={oilDetectionEnabled
|
||||
? { background: 'rgba(239,68,68,.15)', borderColor: 'rgba(239,68,68,.4)', color: 'var(--red)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
|
||||
? { background: 'rgba(239,68,68,.15)', borderColor: 'rgba(239,68,68,.4)', color: 'var(--color-danger)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }
|
||||
}
|
||||
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '오일 유출 감지'}
|
||||
>
|
||||
@ -327,7 +327,7 @@ export function CctvView() {
|
||||
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||||
style={vesselDetectionEnabled
|
||||
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.4)', color: 'var(--blue, #3b82f6)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }
|
||||
}
|
||||
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '선박 출입 감지'}
|
||||
>
|
||||
@ -338,7 +338,7 @@ export function CctvView() {
|
||||
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||||
style={intrusionDetectionEnabled
|
||||
? { background: 'rgba(249,115,22,.15)', borderColor: 'rgba(249,115,22,.4)', color: 'var(--orange, #f97316)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }
|
||||
}
|
||||
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '침입 감지'}
|
||||
>
|
||||
@ -348,7 +348,7 @@ export function CctvView() {
|
||||
onClick={() => {
|
||||
playerRefs.current.forEach(r => r?.capture())
|
||||
}}
|
||||
className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors"
|
||||
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
|
||||
>📷 캡처</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -356,7 +356,7 @@ export function CctvView() {
|
||||
{/* 영상 그리드 / CCTV 위치 지도 / 리스트 뷰 */}
|
||||
{viewMode === 'list' && activeCells.length === 0 ? (
|
||||
/* ── 리스트 뷰: 출처별 · 지역별 그리드 ── */
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
{(() => {
|
||||
// 출처별 그룹핑
|
||||
const sourceGroups: Record<string, { label: string; icon: string; cameras: CctvCameraItem[] }> = {}
|
||||
@ -385,21 +385,21 @@ export function CctvView() {
|
||||
return (
|
||||
<div key={srcKey} className="mb-5">
|
||||
{/* 출처 헤더 */}
|
||||
<div className="flex items-center gap-2 mb-2 pb-1.5 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2 pb-1.5 border-b border-stroke">
|
||||
<span className="text-sm">{group.icon}</span>
|
||||
<span className="text-[12px] font-bold text-text-1 font-korean">{group.label}</span>
|
||||
<span className="text-[10px] text-text-3 font-korean ml-auto">{group.cameras.length}개</span>
|
||||
<span className="text-[12px] font-bold text-fg font-korean">{group.label}</span>
|
||||
<span className="text-[10px] text-fg-disabled font-korean ml-auto">{group.cameras.length}개</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(regionGroups).map(([rgn, cams]) => (
|
||||
<div key={rgn} className="mb-3">
|
||||
{/* 지역 소제목 */}
|
||||
<div className="flex items-center gap-1.5 mb-1.5 px-1">
|
||||
<span className="text-[10px] font-bold text-primary-cyan font-korean">{rgn}</span>
|
||||
<span className="text-[9px] text-text-3">({cams.length})</span>
|
||||
<span className="text-[10px] font-bold text-color-accent font-korean">{rgn}</span>
|
||||
<span className="text-[9px] text-fg-disabled">({cams.length})</span>
|
||||
</div>
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid px-2 py-1 bg-bg-3 rounded-t text-[9px] font-bold text-text-3 font-korean border border-border"
|
||||
<div className="grid px-2 py-1 bg-bg-card rounded-t text-[9px] font-bold text-fg-disabled font-korean border border-stroke"
|
||||
style={{ gridTemplateColumns: '1fr 1.2fr 70px 130px' }}>
|
||||
<span>카메라명</span>
|
||||
<span>위치</span>
|
||||
@ -411,22 +411,22 @@ export function CctvView() {
|
||||
<div
|
||||
key={cam.cctvSn}
|
||||
onClick={() => { handleSelectCamera(cam); setViewMode('map') }}
|
||||
className="grid px-2 py-1.5 border-b border-x border-border cursor-pointer transition-colors hover:bg-bg-hover"
|
||||
className="grid px-2 py-1.5 border-b border-x border-stroke cursor-pointer transition-colors hover:bg-bg-surface-hover"
|
||||
style={{
|
||||
gridTemplateColumns: '1fr 1.2fr 70px 130px',
|
||||
background: selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-text-1 font-korean font-semibold truncate">{cam.cameraNm}</span>
|
||||
<span className="text-[9px] text-text-3 font-korean truncate">{cam.locDc ?? '—'}</span>
|
||||
<span className="text-[10px] text-fg font-korean font-semibold truncate">{cam.cameraNm}</span>
|
||||
<span className="text-[9px] text-fg-disabled font-korean truncate">{cam.locDc ?? '—'}</span>
|
||||
<span className="text-center">
|
||||
{cam.sttsCd === 'LIVE' ? (
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full inline-block" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)' }}>● LIVE</span>
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full inline-block" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--color-success)' }}>● LIVE</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full inline-block" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--t3)' }}>● OFF</span>
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full inline-block" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--fg-disabled)' }}>● OFF</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[9px] text-text-3 font-mono text-center">{now}</span>
|
||||
<span className="text-[9px] text-fg-disabled font-mono text-center">{now}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -484,9 +484,9 @@ export function CctvView() {
|
||||
offset={14}
|
||||
className="cctv-dark-popup"
|
||||
>
|
||||
<div className="p-2" style={{ minWidth: 150, background: '#1a1f2e', borderRadius: 6 }}>
|
||||
<div className="text-[11px] font-bold text-white mb-1">{mapPopup.cameraNm}</div>
|
||||
<div className="text-[9px] text-gray-400 mb-1.5">{mapPopup.locDc ?? ''}</div>
|
||||
<div className="p-2" style={{ minWidth: 150, background: 'var(--bg-card)', borderRadius: 6 }}>
|
||||
<div className="text-[11px] font-bold text-fg mb-1">{mapPopup.cameraNm}</div>
|
||||
<div className="text-[9px] text-fg-disabled mb-1.5">{mapPopup.locDc ?? ''}</div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
|
||||
style={mapPopup.sttsCd === 'LIVE'
|
||||
@ -494,7 +494,7 @@ export function CctvView() {
|
||||
: { background: 'rgba(148,163,184,.15)', color: '#94a3b8' }
|
||||
}
|
||||
>{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'}</span>
|
||||
<span className="text-[8px] text-gray-500">{mapPopup.sourceNm}</span>
|
||||
<span className="text-[8px] text-fg-disabled">{mapPopup.sourceNm}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { handleSelectCamera(mapPopup); setMapPopup(null) }}
|
||||
@ -520,7 +520,7 @@ export function CctvView() {
|
||||
{Array.from({ length: totalCells }).map((_, i) => {
|
||||
const cam = activeCells[i]
|
||||
return (
|
||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
|
||||
{cam ? (
|
||||
<CCTVPlayer
|
||||
ref={el => { playerRefs.current[i] = el }}
|
||||
@ -535,7 +535,7 @@ export function CctvView() {
|
||||
intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean opacity-40">카메라를 선택하세요</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@ -544,20 +544,20 @@ export function CctvView() {
|
||||
)}
|
||||
|
||||
{/* 하단 정보 바 */}
|
||||
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
|
||||
<div className="text-[10px] text-text-3 font-korean">선택: <b className="text-text-1">{selectedCamera?.cameraNm ?? '–'}</b></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">위치: <span className="text-text-2">{selectedCamera?.locDc ?? '–'}</span></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">좌표: <span className="text-primary-cyan font-mono text-[9px]">{selectedCamera?.coordDc ?? '–'}</span></div>
|
||||
<div className="ml-auto text-[9px] text-text-3 font-korean">API: 국립해양조사원 TAGO 해양 CCTV</div>
|
||||
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-stroke bg-bg-elevated shrink-0">
|
||||
<div className="text-[10px] text-fg-disabled font-korean">선택: <b className="text-fg">{selectedCamera?.cameraNm ?? '–'}</b></div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">위치: <span className="text-fg-sub">{selectedCamera?.locDc ?? '–'}</span></div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">좌표: <span className="text-color-accent font-mono text-[9px]">{selectedCamera?.coordDc ?? '–'}</span></div>
|
||||
<div className="ml-auto text-[9px] text-fg-disabled font-korean">API: 국립해양조사원 TAGO 해양 CCTV</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 미니맵 + 정보 */}
|
||||
<div className="flex flex-col overflow-hidden bg-bg-1 border-l border-border w-[232px] min-w-[232px]">
|
||||
<div className="flex flex-col overflow-hidden bg-bg-surface border-l border-stroke w-[232px] min-w-[232px]">
|
||||
{/* 지도 헤더 */}
|
||||
<div className="px-3 py-2 border-b border-border text-[11px] font-bold text-text-1 font-korean bg-bg-2 shrink-0 flex items-center justify-between">
|
||||
<div className="px-3 py-2 border-b border-stroke text-[11px] font-bold text-fg font-korean bg-bg-elevated shrink-0 flex items-center justify-between">
|
||||
<span>🗺 위치 지도</span>
|
||||
<span className="text-[9px] text-text-3 font-normal">클릭하여 선택</span>
|
||||
<span className="text-[9px] text-fg-disabled font-normal">클릭하여 선택</span>
|
||||
</div>
|
||||
{/* 미니맵 */}
|
||||
<div className="w-full shrink-0 relative h-[210px] overflow-hidden">
|
||||
@ -582,8 +582,8 @@ export function CctvView() {
|
||||
width: selectedCamera?.cctvSn === cam.cctvSn ? 10 : 7,
|
||||
height: selectedCamera?.cctvSn === cam.cctvSn ? 10 : 7,
|
||||
borderRadius: '50%',
|
||||
background: selectedCamera?.cctvSn === cam.cctvSn ? 'var(--cyan)' : cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)',
|
||||
boxShadow: selectedCamera?.cctvSn === cam.cctvSn ? '0 0 8px var(--cyan)' : 'none',
|
||||
background: selectedCamera?.cctvSn === cam.cctvSn ? 'var(--color-accent)' : cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)',
|
||||
boxShadow: selectedCamera?.cctvSn === cam.cctvSn ? '0 0 8px var(--color-accent)' : 'none',
|
||||
border: '1.5px solid rgba(255,255,255,.7)',
|
||||
}}
|
||||
/>
|
||||
@ -593,8 +593,8 @@ export function CctvView() {
|
||||
</div>
|
||||
|
||||
{/* 카메라 정보 */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2.5 border-t border-border" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📋 카메라 정보</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2.5 border-t border-stroke" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">📋 카메라 정보</div>
|
||||
{selectedCamera ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
@ -606,24 +606,24 @@ export function CctvView() {
|
||||
['PTZ', selectedCamera.ptzYn === 'Y' ? '지원' : '미지원'],
|
||||
['출처', selectedCamera.sourceNm ?? '—'],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono text-text-1">{v}</span>
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]">
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="font-mono text-fg">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean">카메라를 선택하세요</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">카메라를 선택하세요</div>
|
||||
)}
|
||||
|
||||
{/* 방제 즐겨찾기 */}
|
||||
<div className="mt-3 pt-2.5 border-t border-border">
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">⭐ 방제 핵심 지점</div>
|
||||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">⭐ 방제 핵심 지점</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{cctvFavorites.map((fav, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 rounded-[5px] cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-bg-card rounded-[5px] cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||||
onClick={() => {
|
||||
const found = cameras.find(c => c.cameraNm === fav.name)
|
||||
if (found) handleSelectCamera(found)
|
||||
@ -631,8 +631,8 @@ export function CctvView() {
|
||||
>
|
||||
<span className="text-[9px]">⭐</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[9px] font-semibold text-text-1 font-korean truncate">{fav.name}</div>
|
||||
<div className="text-[8px] text-text-3 font-korean">{fav.reason}</div>
|
||||
<div className="text-[9px] font-semibold text-fg font-korean truncate">{fav.name}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-korean">{fav.reason}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -640,23 +640,23 @@ export function CctvView() {
|
||||
</div>
|
||||
|
||||
{/* API 연동 현황 */}
|
||||
<div className="mt-3 pt-2.5 border-t border-border">
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">🔌 API 연동 현황</div>
|
||||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">🔌 API 연동 현황</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
{ name: '해양조사원 TAGO', status: '● 연결', color: 'var(--green)' },
|
||||
{ name: 'KBS 재난안전포털', status: '● 연결', color: 'var(--green)' },
|
||||
{ name: '해양조사원 TAGO', status: '● 연결', color: 'var(--color-success)' },
|
||||
{ name: 'KBS 재난안전포털', status: '● 연결', color: 'var(--color-success)' },
|
||||
].map((api, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-2 py-1 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(34,197,94,.2)' }}>
|
||||
<span className="text-[9px] text-text-2 font-korean">{api.name}</span>
|
||||
<div key={i} className="flex items-center justify-between px-2 py-1 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(34,197,94,.2)' }}>
|
||||
<span className="text-[9px] text-fg-sub font-korean">{api.name}</span>
|
||||
<span className="text-[9px] font-bold" style={{ color: api.color }}>{api.status}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-2 py-1 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||
<span className="text-[9px] text-text-2 font-korean">갱신 주기</span>
|
||||
<span className="text-[9px] font-bold font-mono text-primary-blue">1 fps</span>
|
||||
<div className="flex items-center justify-between px-2 py-1 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||
<span className="text-[9px] text-fg-sub font-korean">갱신 주기</span>
|
||||
<span className="text-[9px] font-bold font-mono text-color-info">1 fps</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-mono text-right mt-0.5">최종갱신: {new Date().toLocaleTimeString('ko-KR')}</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono text-right mt-0.5">최종갱신: {new Date().toLocaleTimeString('ko-KR')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,23 +15,23 @@ const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈'
|
||||
|
||||
const equipTagCls = (t: string) =>
|
||||
t === 'drone'
|
||||
? 'bg-[rgba(59,130,246,0.12)] text-primary-blue'
|
||||
? 'bg-[rgba(59,130,246,0.12)] text-color-info'
|
||||
: t === 'plane'
|
||||
? 'bg-[rgba(34,197,94,0.12)] text-status-green'
|
||||
: 'bg-[rgba(168,85,247,0.12)] text-primary-purple'
|
||||
? 'bg-[rgba(34,197,94,0.12)] text-color-success'
|
||||
: 'bg-[rgba(168,85,247,0.12)] text-color-tertiary'
|
||||
|
||||
const mediaTagCls = (t: string) =>
|
||||
t === '영상'
|
||||
? 'bg-[rgba(239,68,68,0.12)] text-status-red'
|
||||
: 'bg-[rgba(234,179,8,0.12)] text-status-yellow'
|
||||
? 'bg-[rgba(239,68,68,0.12)] text-color-danger'
|
||||
: 'bg-[rgba(234,179,8,0.12)] text-color-caution'
|
||||
|
||||
const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
|
||||
active
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@ -163,13 +163,13 @@ export function MediaManagement() {
|
||||
{/* Filters */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<span className="text-[11px] text-text-3 font-korean">촬영 장비:</span>
|
||||
<span className="text-[11px] text-fg-disabled font-korean">촬영 장비:</span>
|
||||
<FilterBtn label="전체" active={equipFilter === 'all'} onClick={() => setEquipFilter('all')} />
|
||||
<FilterBtn label="🛸 드론" active={equipFilter === 'drone'} onClick={() => setEquipFilter('drone')} />
|
||||
<FilterBtn label="✈ 유인항공기" active={equipFilter === 'plane'} onClick={() => setEquipFilter('plane')} />
|
||||
<FilterBtn label="🛰 위성" active={equipFilter === 'satellite'} onClick={() => setEquipFilter('satellite')} />
|
||||
<span className="w-px h-4 bg-border mx-1" />
|
||||
<span className="text-[11px] text-text-3 font-korean">유형:</span>
|
||||
<span className="text-[11px] text-fg-disabled font-korean">유형:</span>
|
||||
<FilterBtn label="📷 사진" active={typeFilter.has('photo')} onClick={() => toggleTypeFilter('photo')} />
|
||||
<FilterBtn label="🎬 영상" active={typeFilter.has('video')} onClick={() => toggleTypeFilter('video')} />
|
||||
</div>
|
||||
@ -179,7 +179,7 @@ export function MediaManagement() {
|
||||
placeholder="파일명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="px-3 py-1.5 bg-bg-0 border border-border rounded-sm text-text-1 font-korean text-[11px] outline-none w-40 focus:border-primary-cyan"
|
||||
className="px-3 py-1.5 bg-bg-base border border-stroke rounded-sm text-fg font-korean text-[11px] outline-none w-40 focus:border-color-accent"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
@ -196,24 +196,24 @@ export function MediaManagement() {
|
||||
{/* Summary Stats */}
|
||||
<div className="flex gap-2.5 mb-4">
|
||||
{[
|
||||
{ icon: '📸', value: loading ? '…' : String(mediaItems.length), label: '총 파일', color: 'text-primary-cyan' },
|
||||
{ icon: '🛸', value: loading ? '…' : String(droneCount), label: '드론', color: 'text-text-1' },
|
||||
{ icon: '✈', value: loading ? '…' : String(planeCount), label: '유인항공기', color: 'text-text-1' },
|
||||
{ icon: '🛰', value: loading ? '…' : String(satCount), label: '위성', color: 'text-text-1' },
|
||||
{ icon: '💾', value: '—', label: '총 용량', color: 'text-text-1' },
|
||||
{ icon: '📸', value: loading ? '…' : String(mediaItems.length), label: '총 파일', color: 'text-color-accent' },
|
||||
{ icon: '🛸', value: loading ? '…' : String(droneCount), label: '드론', color: 'text-fg' },
|
||||
{ icon: '✈', value: loading ? '…' : String(planeCount), label: '유인항공기', color: 'text-fg' },
|
||||
{ icon: '🛰', value: loading ? '…' : String(satCount), label: '위성', color: 'text-fg' },
|
||||
{ icon: '💾', value: '—', label: '총 용량', color: 'text-fg' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-3 border border-border rounded-sm">
|
||||
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-card border border-stroke rounded-sm">
|
||||
<span className="text-xl">{s.icon}</span>
|
||||
<div>
|
||||
<div className={`text-base font-bold font-mono ${s.color}`}>{s.value}</div>
|
||||
<div className="text-[10px] text-text-3 font-korean">{s.label}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File Table */}
|
||||
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
|
||||
<div className="flex-1 bg-bg-card border border-stroke rounded-md overflow-hidden flex flex-col">
|
||||
<div className="overflow-auto flex-1">
|
||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
@ -230,7 +230,7 @@ export function MediaManagement() {
|
||||
<col style={{ width: 50 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-2">
|
||||
<tr className="border-b border-stroke bg-bg-elevated">
|
||||
<th className="px-2 py-2.5 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -240,27 +240,27 @@ export function MediaManagement() {
|
||||
/>
|
||||
</th>
|
||||
<th className="px-1 py-2.5" />
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">사고명</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">위치</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">파일명</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">장비</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">유형</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">촬영일시</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">용량</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">해상도</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 text-center">📥</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">사고명</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">위치</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">파일명</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">장비</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">유형</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">촬영일시</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">용량</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">해상도</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled text-center">📥</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center text-[11px] text-text-3 font-korean">불러오는 중...</td>
|
||||
<td colSpan={11} className="px-4 py-8 text-center text-[11px] text-fg-disabled font-korean">불러오는 중...</td>
|
||||
</tr>
|
||||
) : sorted.map(f => (
|
||||
<tr
|
||||
key={f.aerialMediaSn}
|
||||
onClick={() => toggleId(f.aerialMediaSn)}
|
||||
className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||
className={`border-b border-stroke/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||
}`}
|
||||
>
|
||||
@ -273,9 +273,9 @@ export function MediaManagement() {
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
|
||||
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.acdntSn != null ? String(f.acdntSn) : '—'}</td>
|
||||
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.locDc ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.fileNm}</td>
|
||||
<td className="px-2 py-2 text-[10px] font-semibold text-fg font-korean truncate">{f.acdntSn != null ? String(f.acdntSn) : '—'}</td>
|
||||
<td className="px-2 py-2 text-[10px] text-color-accent font-mono truncate">{f.locDc ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-semibold text-fg font-korean truncate">{f.fileNm}</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}>
|
||||
{f.equipNm}
|
||||
@ -293,7 +293,7 @@ export function MediaManagement() {
|
||||
<button
|
||||
onClick={(e) => handleDownload(e, f)}
|
||||
disabled={downloadingId === f.aerialMediaSn}
|
||||
className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors disabled:opacity-50"
|
||||
className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-color-accent border border-color-accent/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
|
||||
</button>
|
||||
@ -306,24 +306,24 @@ export function MediaManagement() {
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-border">
|
||||
<div className="text-[11px] text-text-3 font-korean">
|
||||
선택된 파일: <span className="text-primary-cyan font-semibold">{selectedIds.size}</span>건
|
||||
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-stroke">
|
||||
<div className="text-[11px] text-fg-disabled font-korean">
|
||||
선택된 파일: <span className="text-color-accent font-semibold">{selectedIds.size}</span>건
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-3 border border-border text-text-2 hover:bg-bg-hover transition-colors font-korean">
|
||||
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean">
|
||||
☑ 전체선택
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkDownload}
|
||||
disabled={bulkDownloading || selectedIds.size === 0}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-color-accent border border-color-accent/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean disabled:opacity-50"
|
||||
>
|
||||
{bulkDownloading ? '⏳ 다운로드 중...' : '📥 선택 다운로드'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToTab('prediction', 'analysis')}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean"
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-color-tertiary border border-color-tertiary/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean"
|
||||
>
|
||||
🔬 유출유확산예측으로 →
|
||||
</button>
|
||||
@ -333,21 +333,21 @@ export function MediaManagement() {
|
||||
{/* 선택 다운로드 결과 팝업 */}
|
||||
{downloadResult && (
|
||||
<div className="fixed inset-0 z-[300] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="bg-bg-1 border border-border rounded-md p-6 w-72 text-center">
|
||||
<div className="bg-bg-surface border border-stroke rounded-md p-6 w-72 text-center">
|
||||
<div className="text-2xl mb-3">📥</div>
|
||||
<div className="text-sm font-bold font-korean mb-3">다운로드 완료</div>
|
||||
<div className="text-[13px] font-korean text-text-2 mb-1">
|
||||
총 <span className="text-primary-cyan font-bold">{downloadResult.total}</span>건 선택
|
||||
<div className="text-[13px] font-korean text-fg-sub mb-1">
|
||||
총 <span className="text-color-accent font-bold">{downloadResult.total}</span>건 선택
|
||||
</div>
|
||||
<div className="text-[13px] font-korean text-text-2 mb-4">
|
||||
<span className="text-status-green font-bold">{downloadResult.success}</span>건 다운로드 성공
|
||||
<div className="text-[13px] font-korean text-fg-sub mb-4">
|
||||
<span className="text-color-success font-bold">{downloadResult.success}</span>건 다운로드 성공
|
||||
{downloadResult.total - downloadResult.success > 0 && (
|
||||
<> / <span className="text-status-red font-bold">{downloadResult.total - downloadResult.success}</span>건 실패</>
|
||||
<> / <span className="text-color-danger font-bold">{downloadResult.total - downloadResult.success}</span>건 실패</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDownloadResult(null)}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30 hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
@ -358,18 +358,18 @@ export function MediaManagement() {
|
||||
{/* Upload Modal */}
|
||||
{showUpload && (
|
||||
<div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
||||
<div ref={modalRef} className="bg-bg-1 border border-border rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6">
|
||||
<div ref={modalRef} className="bg-bg-surface border border-stroke rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-base font-bold font-korean">📤 영상·사진 업로드</span>
|
||||
<button onClick={() => setShowUpload(false)} className="text-text-3 text-lg hover:text-text-1">✕</button>
|
||||
<button onClick={() => setShowUpload(false)} className="text-fg-disabled text-lg hover:text-fg">✕</button>
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-border-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-primary-cyan/40 transition-colors">
|
||||
<div className="border-2 border-dashed border-stroke-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-color-accent/40 transition-colors">
|
||||
<div className="text-3xl mb-2 opacity-50">📁</div>
|
||||
<div className="text-[13px] font-semibold mb-1 font-korean">파일을 드래그하거나 클릭하여 업로드</div>
|
||||
<div className="text-[11px] text-text-3 font-korean">JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean">JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">촬영 장비</label>
|
||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">촬영 장비</label>
|
||||
<select className="prd-i w-full">
|
||||
<option>드론 (DJI M300 RTK)</option>
|
||||
<option>드론 (DJI Mavic 3E)</option>
|
||||
@ -381,7 +381,7 @@ export function MediaManagement() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">연관 사고</label>
|
||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">연관 사고</label>
|
||||
<select className="prd-i w-full">
|
||||
<option>여수항 유류유출 (2026-01-18)</option>
|
||||
<option>통영 해역 기름오염 (2026-01-18)</option>
|
||||
@ -389,13 +389,13 @@ export function MediaManagement() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">메모</label>
|
||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">메모</label>
|
||||
<textarea
|
||||
className="prd-i w-full h-[60px] resize-y"
|
||||
placeholder="촬영 조건, 비고 등..."
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||||
<button className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' }}>
|
||||
📤 업로드 실행
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -49,9 +49,9 @@ interface MetaRowProps {
|
||||
function MetaRow({ label, value }: MetaRowProps) {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<div className="flex justify-between gap-2 py-0.5 border-b border-border/40 last:border-0 font-korean">
|
||||
<span className="text-text-3 shrink-0">{label}</span>
|
||||
<span className="text-text-1 text-right break-all">{value}</span>
|
||||
<div className="flex justify-between gap-2 py-0.5 border-b border-stroke/40 last:border-0 font-korean">
|
||||
<span className="text-fg-disabled shrink-0">{label}</span>
|
||||
<span className="text-fg text-right break-all">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -218,7 +218,7 @@ export function OilAreaAnalysis() {
|
||||
{/* ── Left Panel ── */}
|
||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="text-sm font-bold mb-1 font-korean">🧩 영상사진합성</div>
|
||||
<div className="text-[11px] text-text-3 mb-4 font-korean">
|
||||
<div className="text-[11px] text-fg-disabled mb-4 font-korean">
|
||||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||||
</div>
|
||||
|
||||
@ -234,8 +234,8 @@ export function OilAreaAnalysis() {
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={selectedFiles.length >= MAX_IMAGES || isStitching || isAnalyzing}
|
||||
className="w-full py-2 mb-3 border border-dashed border-border rounded-sm text-xs font-korean text-text-2
|
||||
hover:border-primary-cyan hover:text-primary-cyan transition-colors cursor-pointer
|
||||
className="w-full py-2 mb-3 border border-dashed border-stroke rounded-sm text-xs font-korean text-fg-sub
|
||||
hover:border-color-accent hover:text-color-accent transition-colors cursor-pointer
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
+ 이미지 선택 ({selectedFiles.length}/{MAX_IMAGES})
|
||||
@ -249,16 +249,16 @@ export function OilAreaAnalysis() {
|
||||
{selectedFiles.map((file, i) => (
|
||||
<div key={`${file.name}-${i}`}>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1.5 bg-bg-3 border rounded-sm text-[11px] font-korean cursor-pointer transition-colors
|
||||
${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 bg-bg-card border rounded-sm text-[11px] font-korean cursor-pointer transition-colors
|
||||
${selectedImageIndex === i ? 'border-color-accent' : 'border-stroke'}`}
|
||||
onClick={() => setSelectedImageIndex(i)}
|
||||
>
|
||||
<span className="text-primary-cyan">📷</span>
|
||||
<span className="flex-1 truncate text-text-1">{file.name}</span>
|
||||
<span className="text-color-accent">📷</span>
|
||||
<span className="flex-1 truncate text-fg">{file.name}</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleRemoveFile(i); }}
|
||||
disabled={isStitching || isAnalyzing}
|
||||
className="text-text-3 hover:text-status-red transition-colors cursor-pointer
|
||||
className="text-fg-disabled hover:text-color-danger transition-colors cursor-pointer
|
||||
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
|
||||
title="제거"
|
||||
>
|
||||
@ -266,7 +266,7 @@ export function OilAreaAnalysis() {
|
||||
</button>
|
||||
</div>
|
||||
{selectedImageIndex === i && imageExifs[i] !== undefined && (
|
||||
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-0 border border-border/60 rounded-sm text-[11px] font-korean">
|
||||
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-base border border-stroke/60 rounded-sm text-[11px] font-korean">
|
||||
<MetaRow label="파일 크기" value={formatFileSize(file.size)} />
|
||||
<MetaRow
|
||||
label="해상도"
|
||||
@ -323,7 +323,7 @@ export function OilAreaAnalysis() {
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="mb-3 px-2.5 py-2 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.3)] rounded-sm text-[11px] text-status-red font-korean">
|
||||
<div className="mb-3 px-2.5 py-2 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.3)] rounded-sm text-[11px] text-color-danger font-korean">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@ -333,7 +333,7 @@ export function OilAreaAnalysis() {
|
||||
onClick={handleStitch}
|
||||
disabled={!canStitch}
|
||||
className="w-full py-2.5 mb-2 rounded-sm text-[12px] font-bold font-korean cursor-pointer transition-colors
|
||||
border border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.06)]
|
||||
border border-color-accent text-color-accent bg-[rgba(6,182,212,0.06)]
|
||||
hover:bg-[rgba(6,182,212,0.15)] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isStitching ? '⏳ 합성 중...' : stitchedBlob ? '✅ 다시 합성' : '🔗 이미지 합성'}
|
||||
@ -345,7 +345,7 @@ export function OilAreaAnalysis() {
|
||||
disabled={!canAnalyze}
|
||||
className="w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed text-white"
|
||||
style={canAnalyze ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : { background: 'var(--bg-3)' }}
|
||||
style={canAnalyze ? { background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' } : { background: 'var(--bg-3)' }}
|
||||
>
|
||||
{isAnalyzing ? '⏳ 분석 중...' : '🧩 분석 시작'}
|
||||
</button>
|
||||
@ -359,9 +359,9 @@ export function OilAreaAnalysis() {
|
||||
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`bg-bg-3 border rounded-sm overflow-hidden flex flex-col transition-colors
|
||||
className={`bg-bg-card border rounded-sm overflow-hidden flex flex-col transition-colors
|
||||
${previewUrls[i] ? 'cursor-pointer' : ''}
|
||||
${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
|
||||
${selectedImageIndex === i ? 'border-color-accent' : 'border-stroke'}`}
|
||||
style={{ height: '300px' }}
|
||||
onClick={() => { if (previewUrls[i]) setSelectedImageIndex(i); }}
|
||||
>
|
||||
@ -374,24 +374,24 @@ export function OilAreaAnalysis() {
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1 bg-bg-0 border-t border-border shrink-0 flex items-start justify-between gap-1">
|
||||
<div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0">
|
||||
<div className="px-2 py-1 bg-bg-base border-t border-stroke shrink-0 flex items-start justify-between gap-1">
|
||||
<div className="text-[10px] text-fg-sub truncate font-korean flex-1 min-w-0">
|
||||
{selectedFiles[i]?.name}
|
||||
</div>
|
||||
{imageExifs[i] === undefined ? (
|
||||
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 읽는 중...</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean shrink-0">GPS 읽는 중...</div>
|
||||
) : imageExifs[i]?.lat !== null ? (
|
||||
<div className="text-[10px] text-primary-cyan font-mono leading-tight text-right shrink-0">
|
||||
<div className="text-[10px] text-color-accent font-mono leading-tight text-right shrink-0">
|
||||
{decimalToDMS(imageExifs[i]!.lat!, true)}<br />
|
||||
{decimalToDMS(imageExifs[i]!.lon!, false)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 정보 없음</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean shrink-0">GPS 정보 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
|
||||
<div className="flex items-center justify-center h-full text-fg-disabled text-lg font-mono opacity-20">
|
||||
{i + 1}
|
||||
</div>
|
||||
)}
|
||||
@ -402,7 +402,7 @@ export function OilAreaAnalysis() {
|
||||
{/* 합성 결과 */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">합성 결과</div>
|
||||
<div
|
||||
className="relative bg-bg-0 border border-border rounded-sm overflow-hidden flex items-center justify-center"
|
||||
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center"
|
||||
style={{ minHeight: '160px', flex: '1 1 0' }}
|
||||
>
|
||||
{stitchedPreviewUrl ? (
|
||||
@ -412,7 +412,7 @@ export function OilAreaAnalysis() {
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[12px] text-text-3 font-korean text-center px-4">
|
||||
<div className="text-[12px] text-fg-disabled font-korean text-center px-4">
|
||||
{isStitching
|
||||
? '⏳ 이미지를 합성하고 있습니다...'
|
||||
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
|
||||
|
||||
@ -147,7 +147,7 @@ const OilDetectionOverlay = memo(({ result, isAnalyzing = false, error = null }:
|
||||
|
||||
{/* 분석 중 */}
|
||||
{isAnalyzing && (
|
||||
<span className='text-[9px] font-korean text-text-3 animate-pulse px-1'>
|
||||
<span className='text-[9px] font-korean text-fg-disabled animate-pulse px-1'>
|
||||
분석중...
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -110,10 +110,10 @@ export function RealtimeDrone() {
|
||||
|
||||
const statusInfo = (status: string) => {
|
||||
switch (status) {
|
||||
case 'streaming': return { label: '송출중', color: 'var(--green)', bg: 'rgba(34,197,94,.12)' }
|
||||
case 'starting': return { label: '연결중', color: 'var(--cyan)', bg: 'rgba(6,182,212,.12)' }
|
||||
case 'error': return { label: '오류', color: 'var(--red)', bg: 'rgba(239,68,68,.12)' }
|
||||
default: return { label: '대기', color: 'var(--t3)', bg: 'rgba(255,255,255,.06)' }
|
||||
case 'streaming': return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' }
|
||||
case 'starting': return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' }
|
||||
case 'error': return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' }
|
||||
default: return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' }
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,26 +123,26 @@ export function RealtimeDrone() {
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||
{/* 좌측: 드론 스트림 목록 */}
|
||||
<div className="flex flex-col overflow-hidden bg-bg-1 border-r border-border w-[260px] min-w-[260px]">
|
||||
<div className="flex flex-col overflow-hidden bg-bg-surface border-r border-stroke w-[260px] min-w-[260px]">
|
||||
{/* 헤더 */}
|
||||
<div className="p-3 pb-2.5 border-b border-border shrink-0 bg-bg-2">
|
||||
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: streams.some(s => s.status === 'streaming') ? 'var(--green)' : 'var(--t3)' }} />
|
||||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: streams.some(s => s.status === 'streaming') ? 'var(--color-success)' : 'var(--fg-disabled)' }} />
|
||||
실시간 드론 영상
|
||||
</div>
|
||||
<button
|
||||
onClick={loadStreams}
|
||||
className="px-2 py-0.5 text-[9px] font-korean bg-bg-3 border border-border rounded text-text-2 cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
className="px-2 py-0.5 text-[9px] font-korean bg-bg-card border border-stroke rounded text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||||
>새로고침</button>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-korean">ViewLink RTSP 스트림 · 내부망 전용</div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean">ViewLink RTSP 스트림 · 내부망 전용</div>
|
||||
</div>
|
||||
|
||||
{/* 드론 스트림 카드 */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
{loading ? (
|
||||
<div className="px-3.5 py-4 text-[11px] text-text-3 font-korean">불러오는 중...</div>
|
||||
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean">불러오는 중...</div>
|
||||
) : streams.map(stream => {
|
||||
const si = statusInfo(stream.status)
|
||||
const isSelected = selectedStream?.id === stream.id
|
||||
@ -160,8 +160,8 @@ export function RealtimeDrone() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm">🚁</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean">{stream.shipName} <span className="text-[9px] text-primary-cyan font-semibold">({stream.droneModel})</span></div>
|
||||
<div className="text-[9px] text-text-3 font-mono">{stream.ip}</div>
|
||||
<div className="text-[11px] font-semibold text-fg font-korean">{stream.shipName} <span className="text-[9px] text-color-accent font-semibold">({stream.droneModel})</span></div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">{stream.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@ -171,12 +171,12 @@ export function RealtimeDrone() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-text-3 font-korean px-1.5 py-0.5 rounded bg-bg-3">{stream.region}</span>
|
||||
<span className="text-[8px] text-text-3 font-mono px-1.5 py-0.5 rounded bg-bg-3">RTSP :554</span>
|
||||
<span className="text-[8px] text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">{stream.region}</span>
|
||||
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">RTSP :554</span>
|
||||
</div>
|
||||
|
||||
{stream.error && (
|
||||
<div className="mt-1.5 text-[8px] text-status-red font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
|
||||
<div className="mt-1.5 text-[8px] text-color-danger font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
|
||||
{stream.error}
|
||||
</div>
|
||||
)}
|
||||
@ -187,13 +187,13 @@ export function RealtimeDrone() {
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStartStream(stream.id) }}
|
||||
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
||||
style={{ background: 'rgba(34,197,94,.1)', borderColor: 'rgba(34,197,94,.3)', color: 'var(--green)' }}
|
||||
style={{ background: 'rgba(34,197,94,.1)', borderColor: 'rgba(34,197,94,.3)', color: 'var(--color-success)' }}
|
||||
>▶ 스트림 시작</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStopStream(stream.id) }}
|
||||
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
||||
style={{ background: 'rgba(239,68,68,.1)', borderColor: 'rgba(239,68,68,.3)', color: 'var(--red)' }}
|
||||
style={{ background: 'rgba(239,68,68,.1)', borderColor: 'rgba(239,68,68,.3)', color: 'var(--color-danger)' }}
|
||||
>■ 스트림 중지</button>
|
||||
)}
|
||||
</div>
|
||||
@ -203,8 +203,8 @@ export function RealtimeDrone() {
|
||||
</div>
|
||||
|
||||
{/* 하단 안내 */}
|
||||
<div className="px-3 py-2 border-t border-border bg-bg-2 shrink-0">
|
||||
<div className="text-[8px] text-text-3 font-korean leading-relaxed">
|
||||
<div className="px-3 py-2 border-t border-stroke bg-bg-elevated shrink-0">
|
||||
<div className="text-[8px] text-fg-disabled font-korean leading-relaxed">
|
||||
RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다.
|
||||
ViewLink 프로그램과 연동됩니다.
|
||||
</div>
|
||||
@ -212,27 +212,27 @@ export function RealtimeDrone() {
|
||||
</div>
|
||||
|
||||
{/* 중앙: 영상 뷰어 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-[#04070f]">
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-bg-base">
|
||||
{/* 툴바 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-2 shrink-0 gap-2.5">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
|
||||
</div>
|
||||
{selectedStream?.status === 'streaming' && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(34,197,94,.14)', border: '1px solid rgba(34,197,94,.35)', color: 'var(--green)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--green)' }} />LIVE
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(34,197,94,.14)', border: '1px solid rgba(34,197,94,.35)', color: 'var(--color-success)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-success)' }} />LIVE
|
||||
</div>
|
||||
)}
|
||||
{selectedStream?.status === 'starting' && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(6,182,212,.14)', border: '1px solid rgba(6,182,212,.35)', color: 'var(--cyan)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--cyan)' }} />연결중
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(6,182,212,.14)', border: '1px solid rgba(6,182,212,.35)', color: 'var(--color-accent)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-accent)' }} />연결중
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* 분할 모드 */}
|
||||
<div className="flex border border-border rounded-[5px] overflow-hidden">
|
||||
<div className="flex border border-stroke rounded-[5px] overflow-hidden">
|
||||
{[
|
||||
{ mode: 1, icon: '▣', label: '1화면' },
|
||||
{ mode: 4, icon: '⊞', label: '4분할' },
|
||||
@ -243,15 +243,15 @@ export function RealtimeDrone() {
|
||||
title={g.label}
|
||||
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
|
||||
style={gridMode === g.mode
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t2)' }
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
|
||||
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
|
||||
}
|
||||
>{g.icon}</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => playerRefs.current.forEach(r => r?.capture())}
|
||||
className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors"
|
||||
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
|
||||
>📷 캡처</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -338,13 +338,13 @@ export function RealtimeDrone() {
|
||||
offset={36}
|
||||
className="cctv-dark-popup"
|
||||
>
|
||||
<div className="p-2.5" style={{ minWidth: 170, background: '#1a1f2e', borderRadius: 6 }}>
|
||||
<div className="p-2.5" style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-sm">🚁</span>
|
||||
<div className="text-[11px] font-bold text-white">{mapPopup.shipName}</div>
|
||||
<div className="text-[11px] font-bold text-fg">{mapPopup.shipName}</div>
|
||||
</div>
|
||||
<div className="text-[9px] text-gray-400 mb-0.5">{mapPopup.droneModel}</div>
|
||||
<div className="text-[8px] text-gray-500 font-mono mb-2">{mapPopup.ip} · {mapPopup.region}</div>
|
||||
<div className="text-[9px] text-fg-disabled mb-0.5">{mapPopup.droneModel}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-mono mb-2">{mapPopup.ip} · {mapPopup.region}</div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
|
||||
style={{ background: statusInfo(mapPopup.status).bg, color: statusInfo(mapPopup.status).color }}
|
||||
@ -363,7 +363,7 @@ export function RealtimeDrone() {
|
||||
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
|
||||
>▶ 영상 보기</button>
|
||||
) : (
|
||||
<div className="text-[9px] text-primary-cyan font-korean text-center animate-pulse">연결 중...</div>
|
||||
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse">연결 중...</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
@ -384,7 +384,7 @@ export function RealtimeDrone() {
|
||||
{Array.from({ length: totalCells }).map((_, i) => {
|
||||
const stream = activeCells[i]
|
||||
return (
|
||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
|
||||
{stream && stream.status === 'streaming' && stream.hlsUrl ? (
|
||||
<CCTVPlayer
|
||||
ref={el => { playerRefs.current[i] = el }}
|
||||
@ -398,21 +398,21 @@ export function RealtimeDrone() {
|
||||
) : stream && stream.status === 'starting' ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="text-lg opacity-40 animate-pulse">🚁</div>
|
||||
<div className="text-[10px] text-primary-cyan font-korean animate-pulse">RTSP 스트림 연결 중...</div>
|
||||
<div className="text-[8px] text-text-3 font-mono">{stream.ip}:554</div>
|
||||
<div className="text-[10px] text-color-accent font-korean animate-pulse">RTSP 스트림 연결 중...</div>
|
||||
<div className="text-[8px] text-fg-disabled font-mono">{stream.ip}:554</div>
|
||||
</div>
|
||||
) : stream && stream.status === 'error' ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="text-lg opacity-30">⚠️</div>
|
||||
<div className="text-[10px] text-status-red font-korean">연결 실패</div>
|
||||
<div className="text-[8px] text-text-3 font-korean max-w-[200px] text-center">{stream.error}</div>
|
||||
<div className="text-[10px] text-color-danger font-korean">연결 실패</div>
|
||||
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">{stream.error}</div>
|
||||
<button
|
||||
onClick={() => handleStartStream(stream.id)}
|
||||
className="mt-1 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
className="mt-1 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||||
>재시도</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean opacity-40">
|
||||
<div className="text-[10px] text-fg-disabled font-korean opacity-40">
|
||||
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
|
||||
</div>
|
||||
)}
|
||||
@ -423,22 +423,22 @@ export function RealtimeDrone() {
|
||||
)}
|
||||
|
||||
{/* 하단 정보 바 */}
|
||||
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
|
||||
<div className="text-[10px] text-text-3 font-korean">선택: <b className="text-text-1">{selectedStream?.shipName ?? '–'}</b></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">IP: <span className="text-primary-cyan font-mono text-[9px]">{selectedStream?.ip ?? '–'}</span></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">지역: <span className="text-text-2">{selectedStream?.region ?? '–'}</span></div>
|
||||
<div className="ml-auto text-[9px] text-text-3 font-korean">RTSP → HLS · ViewLink 연동</div>
|
||||
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-stroke bg-bg-elevated shrink-0">
|
||||
<div className="text-[10px] text-fg-disabled font-korean">선택: <b className="text-fg">{selectedStream?.shipName ?? '–'}</b></div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">IP: <span className="text-color-accent font-mono text-[9px]">{selectedStream?.ip ?? '–'}</span></div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">지역: <span className="text-fg-sub">{selectedStream?.region ?? '–'}</span></div>
|
||||
<div className="ml-auto text-[9px] text-fg-disabled font-korean">RTSP → HLS · ViewLink 연동</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 정보 패널 */}
|
||||
<div className="flex flex-col overflow-hidden bg-bg-1 border-l border-border w-[220px] min-w-[220px]">
|
||||
<div className="flex flex-col overflow-hidden bg-bg-surface border-l border-stroke w-[220px] min-w-[220px]">
|
||||
{/* 헤더 */}
|
||||
<div className="px-3 py-2 border-b border-border text-[11px] font-bold text-text-1 font-korean bg-bg-2 shrink-0">
|
||||
<div className="px-3 py-2 border-b border-stroke text-[11px] font-bold text-fg font-korean bg-bg-elevated shrink-0">
|
||||
📋 스트림 정보
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2.5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2.5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
{selectedStream ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
@ -451,49 +451,49 @@ export function RealtimeDrone() {
|
||||
['프로토콜', 'RTSP → HLS'],
|
||||
['상태', statusInfo(selectedStream.status).label],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono text-text-1">{v}</span>
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]">
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="font-mono text-fg">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
{selectedStream.hlsUrl && (
|
||||
<div className="px-2 py-1 bg-bg-0 rounded text-[8px]">
|
||||
<div className="text-text-3 font-korean mb-0.5">HLS URL</div>
|
||||
<div className="font-mono text-primary-cyan break-all">{selectedStream.hlsUrl}</div>
|
||||
<div className="px-2 py-1 bg-bg-base rounded text-[8px]">
|
||||
<div className="text-fg-disabled font-korean mb-0.5">HLS URL</div>
|
||||
<div className="font-mono text-color-accent break-all">{selectedStream.hlsUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean">드론 스트림을 선택하세요</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">드론 스트림을 선택하세요</div>
|
||||
)}
|
||||
|
||||
{/* 연동 시스템 */}
|
||||
<div className="mt-3 pt-2.5 border-t border-border">
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">🔗 연동 시스템</div>
|
||||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">🔗 연동 시스템</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
|
||||
<span className="text-[9px] text-text-2 font-korean">ViewLink 3.5</span>
|
||||
<span className="text-[9px] font-bold" style={{ color: 'var(--cyan)' }}>● RTSP</span>
|
||||
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
|
||||
<span className="text-[9px] text-fg-sub font-korean">ViewLink 3.5</span>
|
||||
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}>● RTSP</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||
<span className="text-[9px] text-text-2 font-korean">FFmpeg 변환</span>
|
||||
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||
<span className="text-[9px] text-fg-sub font-korean">FFmpeg 변환</span>
|
||||
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>RTSP→HLS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전체 상태 요약 */}
|
||||
<div className="mt-3 pt-2.5 border-t border-border">
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📊 스트림 현황</div>
|
||||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">📊 스트림 현황</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{[
|
||||
{ label: '전체', value: streams.length, color: 'text-text-1' },
|
||||
{ label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-status-green' },
|
||||
{ label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-primary-cyan' },
|
||||
{ label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-status-red' },
|
||||
{ label: '전체', value: streams.length, color: 'text-fg' },
|
||||
{ label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-color-success' },
|
||||
{ label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-color-accent' },
|
||||
{ label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-color-danger' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="px-2 py-1.5 bg-bg-0 rounded text-center">
|
||||
<div className="text-[8px] text-text-3 font-korean">{item.label}</div>
|
||||
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
|
||||
<div className="text-[8px] text-fg-disabled font-korean">{item.label}</div>
|
||||
<div className={`text-sm font-bold font-mono ${item.color}`}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -36,10 +36,10 @@ const satRequests: SatRequest[] = [
|
||||
]
|
||||
|
||||
const satellites = [
|
||||
{ name: 'KOMPSAT-3A', desc: '해상도 0.5m · 광학 / IR · 촬영 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'rgba(34,197,94,.2)', pulse: true },
|
||||
{ name: 'KOMPSAT-3', desc: '해상도 1.0m · 광학 · 임무 중', status: '임무중', statusColor: 'var(--yellow)', borderColor: 'rgba(234,179,8,.2)', pulse: true },
|
||||
{ name: 'Sentinel-1 (ESA)', desc: '해상도 20m · SAR · 야간/우천 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false },
|
||||
{ name: 'Sentinel-2 (ESA)', desc: '해상도 10m · 다분광 · 수질 분석 적합', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false },
|
||||
{ name: 'KOMPSAT-3A', desc: '해상도 0.5m · 광학 / IR · 촬영 가능', status: '가용', statusColor: 'var(--color-success)', borderColor: 'rgba(34,197,94,.2)', pulse: true },
|
||||
{ name: 'KOMPSAT-3', desc: '해상도 1.0m · 광학 · 임무 중', status: '임무중', statusColor: 'var(--color-caution)', borderColor: 'rgba(234,179,8,.2)', pulse: true },
|
||||
{ name: 'Sentinel-1 (ESA)', desc: '해상도 20m · SAR · 야간/우천 가능', status: '가용', statusColor: 'var(--color-success)', borderColor: 'var(--stroke-default)', pulse: false },
|
||||
{ name: 'Sentinel-2 (ESA)', desc: '해상도 10m · 다분광 · 수질 분석 적합', status: '가용', statusColor: 'var(--color-success)', borderColor: 'var(--stroke-default)', pulse: false },
|
||||
]
|
||||
|
||||
const passSchedules = [
|
||||
@ -161,26 +161,26 @@ export function SatelliteRequest() {
|
||||
|
||||
const statusBadge = (s: SatRequest['status']) => {
|
||||
if (s === '촬영중') return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.15)', border: '1px solid rgba(234,179,8,.3)', color: 'var(--yellow)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--yellow)' }} />촬영중
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.15)', border: '1px solid rgba(234,179,8,.3)', color: 'var(--color-caution)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-caution)' }} />촬영중
|
||||
</span>
|
||||
)
|
||||
if (s === '대기') return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(59,130,246,.15)', border: '1px solid rgba(59,130,246,.3)', color: 'var(--blue)' }}>⏳ 대기</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(59,130,246,.15)', border: '1px solid rgba(59,130,246,.3)', color: 'var(--color-info)' }}>⏳ 대기</span>
|
||||
)
|
||||
if (s === '취소') return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: 'var(--red)' }}>✕ 취소</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: 'var(--color-danger)' }}>✕ 취소</span>
|
||||
)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: 'var(--green)' }}>✅ 완료</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: 'var(--color-success)' }}>✅ 완료</span>
|
||||
)
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ value: '3', label: '요청 대기', color: 'var(--blue)' },
|
||||
{ value: '1', label: '촬영 진행 중', color: 'var(--yellow)' },
|
||||
{ value: '7', label: '수신 완료', color: 'var(--green)' },
|
||||
{ value: '0.5m', label: '최고 해상도', color: 'var(--cyan)' },
|
||||
{ value: '3', label: '요청 대기', color: 'var(--color-info)' },
|
||||
{ value: '1', label: '촬영 진행 중', color: 'var(--color-caution)' },
|
||||
{ value: '7', label: '수신 완료', color: 'var(--color-success)' },
|
||||
{ value: '0.5m', label: '최고 해상도', color: 'var(--color-accent)' },
|
||||
]
|
||||
|
||||
const filters = ['전체', '대기', '진행', '완료', '취소']
|
||||
@ -189,14 +189,14 @@ export function SatelliteRequest() {
|
||||
|
||||
// ── 섹션 헤더 헬퍼 (BlackSky 폼) ──
|
||||
const sectionHeader = (num: number, label: string) => (
|
||||
<div className="text-[11px] font-bold font-korean mb-2.5 flex items-center gap-1.5 text-[#818cf8]">
|
||||
<div className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold text-[#818cf8]" style={{ background: 'rgba(99,102,241,.12)' }}>{num}</div>
|
||||
<div className="text-[11px] font-bold font-korean mb-2.5 flex items-center gap-1.5 text-color-tertiary">
|
||||
<div className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold text-color-tertiary" style={{ background: 'rgba(99,102,241,.12)' }}>{num}</div>
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
|
||||
const bsInput = "w-full px-3 py-2 rounded-md text-[11px] font-korean outline-none box-border"
|
||||
const bsInputStyle = { border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }
|
||||
const bsInputStyle = { border: '1px solid var(--stroke-default)', background: 'var(--bg-surface)', color: 'var(--fg-default)' }
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto px-6 pt-1 pb-2" style={{ height: mainTab === 'map' ? '100%' : undefined }}>
|
||||
@ -204,44 +204,44 @@ export function SatelliteRequest() {
|
||||
<div className="flex items-center gap-3 mb-2 h-9">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-7 h-7 rounded-md flex items-center justify-center text-sm" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
|
||||
<div className="text-[12px] font-bold font-korean text-text-1">위성 촬영 요청</div>
|
||||
<div className="text-[12px] font-bold font-korean text-fg">위성 촬영 요청</div>
|
||||
</div>
|
||||
<div className="flex gap-1 h-7">
|
||||
<button
|
||||
onClick={() => setMainTab('list')}
|
||||
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={mainTab === 'list'
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--color-info)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>📋 요청 목록</button>
|
||||
<button
|
||||
onClick={() => setMainTab('map')}
|
||||
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={mainTab === 'map'
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--color-info)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>🗺 히스토리 지도</button>
|
||||
</div>
|
||||
<button onClick={() => setModalPhase('provider')} className="ml-auto px-3 h-7 text-white border-none rounded-sm text-[10px] font-semibold cursor-pointer font-korean flex items-center gap-1 shrink-0" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 새 요청</button>
|
||||
<button onClick={() => setModalPhase('provider')} className="ml-auto px-3 h-7 text-white border-none rounded-sm text-[10px] font-semibold cursor-pointer font-korean flex items-center gap-1 shrink-0" style={{ background: 'linear-gradient(135deg,var(--color-info),var(--color-tertiary))' }}>🛰 새 요청</button>
|
||||
</div>
|
||||
|
||||
{mainTab === 'list' && (<>
|
||||
{/* 요약 통계 */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className="bg-bg-2 border border-border rounded-md p-3.5 text-center">
|
||||
<div key={i} className="bg-bg-elevated border border-stroke rounded-md p-3.5 text-center">
|
||||
<div className="text-[22px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
|
||||
<div className="text-[10px] text-text-3 mt-1 font-korean">{s.label}</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-1 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 요청 목록 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden mb-5">
|
||||
<div className="flex items-center justify-between px-4 py-3.5 border-b border-border">
|
||||
<div className="text-[13px] font-bold font-korean text-text-1">📋 위성 요청 목록</div>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden mb-5">
|
||||
<div className="flex items-center justify-between px-4 py-3.5 border-b border-stroke">
|
||||
<div className="text-[13px] font-bold font-korean text-fg">📋 위성 요청 목록</div>
|
||||
<div className="flex gap-1.5">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
@ -249,8 +249,8 @@ export function SatelliteRequest() {
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className="px-2.5 py-1 rounded text-[10px] font-semibold cursor-pointer font-korean border"
|
||||
style={statusFilter === f
|
||||
? { background: 'rgba(59,130,246,.15)', color: 'var(--blue)', borderColor: 'rgba(59,130,246,.3)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
|
||||
? { background: 'rgba(59,130,246,.15)', color: 'var(--color-info)', borderColor: 'rgba(59,130,246,.3)' }
|
||||
: { background: 'var(--bg-card)', color: 'var(--fg-sub)', borderColor: 'var(--stroke-default)' }
|
||||
}
|
||||
>{f}</button>
|
||||
))}
|
||||
@ -258,9 +258,9 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
|
||||
{/* 헤더 행 */}
|
||||
<div className="grid gap-0 px-4 py-2 bg-bg-3 border-b border-border" style={{ gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px' }}>
|
||||
<div className="grid gap-0 px-4 py-2 bg-bg-card border-b border-stroke" style={{ gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px' }}>
|
||||
{['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map(h => (
|
||||
<div key={h} className="text-[9px] font-bold text-text-3 font-korean uppercase tracking-wider">{h}</div>
|
||||
<div key={h} className="text-[9px] font-bold text-fg-disabled font-korean uppercase tracking-wider">{h}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -269,7 +269,7 @@ export function SatelliteRequest() {
|
||||
<div key={r.id}>
|
||||
<div
|
||||
onClick={() => setSelectedRequest(selectedRequest?.id === r.id ? null : r)}
|
||||
className="grid gap-0 px-4 py-3 border-b items-center cursor-pointer hover:bg-bg-hover/30 transition-colors"
|
||||
className="grid gap-0 px-4 py-3 border-b items-center cursor-pointer hover:bg-bg-surface-hover/30 transition-colors"
|
||||
style={{
|
||||
gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px',
|
||||
borderColor: 'rgba(255,255,255,.04)',
|
||||
@ -277,15 +277,15 @@ export function SatelliteRequest() {
|
||||
opacity: (r.status === '완료' || r.status === '취소') ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="text-[11px] font-mono text-text-2">{r.id}</div>
|
||||
<div className="text-[11px] font-mono text-fg-sub">{r.id}</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-text-1 font-korean">{r.zone}</div>
|
||||
<div className="text-[10px] text-text-3 font-mono mt-0.5">{r.zoneCoord} · {r.zoneArea}</div>
|
||||
<div className="text-xs font-semibold text-fg font-korean">{r.zone}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono mt-0.5">{r.zoneCoord} · {r.zoneArea}</div>
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean">{r.satellite}</div>
|
||||
<div className="text-[10px] text-text-2 font-mono">{r.requestDate}</div>
|
||||
<div className="text-[10px] font-semibold font-mono" style={{ color: r.status === '촬영중' ? 'var(--yellow)' : 'var(--t2)' }}>{r.expectedReceive}</div>
|
||||
<div className="text-[11px] font-bold font-mono" style={{ color: r.status === '완료' ? 'var(--t3)' : 'var(--cyan)' }}>{r.resolution}</div>
|
||||
<div className="text-[11px] font-semibold text-fg font-korean">{r.satellite}</div>
|
||||
<div className="text-[10px] text-fg-sub font-mono">{r.requestDate}</div>
|
||||
<div className="text-[10px] font-semibold font-mono" style={{ color: r.status === '촬영중' ? 'var(--color-caution)' : 'var(--fg-sub)' }}>{r.expectedReceive}</div>
|
||||
<div className="text-[11px] font-bold font-mono" style={{ color: r.status === '완료' ? 'var(--fg-disabled)' : 'var(--color-accent)' }}>{r.resolution}</div>
|
||||
<div>{statusBadge(r.status)}</div>
|
||||
</div>
|
||||
{/* 상세 정보 패널 */}
|
||||
@ -298,16 +298,16 @@ export function SatelliteRequest() {
|
||||
['요청자', r.requester || '-'],
|
||||
['촬영 면적', r.zoneArea],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="px-2.5 py-2 bg-bg-0 rounded">
|
||||
<div className="text-[8px] font-bold text-text-3 font-korean mb-1 uppercase">{k}</div>
|
||||
<div className="text-[10px] font-semibold text-text-1 font-korean">{v}</div>
|
||||
<div key={i} className="px-2.5 py-2 bg-bg-base rounded">
|
||||
<div className="text-[8px] font-bold text-fg-disabled font-korean mb-1 uppercase">{k}</div>
|
||||
<div className="text-[10px] font-semibold text-fg font-korean">{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(6,182,212,.08)', borderColor: 'rgba(6,182,212,.2)', color: 'var(--cyan)' }}>📍 지도에서 보기</button>
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-surface-hover transition-colors" style={{ background: 'rgba(6,182,212,.08)', borderColor: 'rgba(6,182,212,.2)', color: 'var(--color-accent)' }}>📍 지도에서 보기</button>
|
||||
{r.status === '완료' && (
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(34,197,94,.08)', borderColor: 'rgba(34,197,94,.2)', color: 'var(--green)' }}>📥 영상 다운로드</button>
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-surface-hover transition-colors" style={{ background: 'rgba(34,197,94,.08)', borderColor: 'rgba(34,197,94,.2)', color: 'var(--color-success)' }}>📥 영상 다운로드</button>
|
||||
)}
|
||||
{(r.status === '대기' || r.status === '촬영중') && (
|
||||
<button
|
||||
@ -318,8 +318,8 @@ export function SatelliteRequest() {
|
||||
setSelectedRequest(null)
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}
|
||||
className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||||
style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--color-danger)' }}
|
||||
>✕ 요청 취소</button>
|
||||
)}
|
||||
</div>
|
||||
@ -330,7 +330,7 @@ export function SatelliteRequest() {
|
||||
|
||||
{/* 페이징 */}
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="text-[9px] text-text-3 font-korean">
|
||||
<div className="text-[9px] text-fg-disabled font-korean">
|
||||
총 {filtered.length}건 중 {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, filtered.length)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@ -338,7 +338,7 @@ export function SatelliteRequest() {
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
className="px-2 py-1 rounded text-[10px] font-mono cursor-pointer border transition-colors"
|
||||
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: currentPage <= 1 ? 'var(--t3)' : 'var(--t1)', opacity: currentPage <= 1 ? 0.5 : 1 }}
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: currentPage <= 1 ? 'var(--fg-disabled)' : 'var(--fg-default)', opacity: currentPage <= 1 ? 0.5 : 1 }}
|
||||
>◀</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||||
<button
|
||||
@ -346,8 +346,8 @@ export function SatelliteRequest() {
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className="w-7 h-7 rounded text-[10px] font-bold font-mono cursor-pointer border transition-colors"
|
||||
style={currentPage === p
|
||||
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--color-info)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>{p}</button>
|
||||
))}
|
||||
@ -355,7 +355,7 @@ export function SatelliteRequest() {
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="px-2 py-1 rounded text-[10px] font-mono cursor-pointer border transition-colors"
|
||||
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: currentPage >= totalPages ? 'var(--t3)' : 'var(--t1)', opacity: currentPage >= totalPages ? 0.5 : 1 }}
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: currentPage >= totalPages ? 'var(--fg-disabled)' : 'var(--fg-default)', opacity: currentPage >= totalPages ? 0.5 : 1 }}
|
||||
>▶</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -364,15 +364,15 @@ export function SatelliteRequest() {
|
||||
{/* 위성 궤도 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3.5">
|
||||
{/* 가용 위성 현황 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md p-4">
|
||||
<div className="text-xs font-bold text-text-1 font-korean mb-3">🛰 가용 위성 현황</div>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
|
||||
<div className="text-xs font-bold text-fg font-korean mb-3">🛰 가용 위성 현황</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{satellites.map((sat, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 px-3 py-2 bg-bg-3 rounded-md" style={{ border: `1px solid ${sat.borderColor}` }}>
|
||||
<div key={i} className="flex items-center gap-2.5 px-3 py-2 bg-bg-card rounded-md" style={{ border: `1px solid ${sat.borderColor}` }}>
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${sat.pulse ? 'animate-pulse' : ''}`} style={{ background: sat.statusColor }} />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean">{sat.name}</div>
|
||||
<div className="text-[9px] text-text-3 font-korean">{sat.desc}</div>
|
||||
<div className="text-[11px] font-semibold text-fg font-korean">{sat.name}</div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean">{sat.desc}</div>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold font-korean" style={{ color: sat.statusColor }}>{sat.status}</div>
|
||||
</div>
|
||||
@ -381,8 +381,8 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
|
||||
{/* 오늘 촬영 가능 시간 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md p-4">
|
||||
<div className="text-xs font-bold text-text-1 font-korean mb-3">⏰ 오늘 촬영 가능 시간 (KST)</div>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
|
||||
<div className="text-xs font-bold text-fg font-korean mb-3">⏰ 오늘 촬영 가능 시간 (KST)</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{passSchedules.map((ps, i) => (
|
||||
<div
|
||||
@ -393,8 +393,8 @@ export function SatelliteRequest() {
|
||||
border: ps.today ? '1px solid rgba(34,197,94,.15)' : '1px solid rgba(59,130,246,.15)',
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] font-bold font-mono min-w-[90px]" style={{ color: ps.today ? 'var(--cyan)' : 'var(--blue)' }}>{ps.time}</span>
|
||||
<span className="text-[10px] text-text-1 font-korean">{ps.desc}</span>
|
||||
<span className="text-[10px] font-bold font-mono min-w-[90px]" style={{ color: ps.today ? 'var(--color-accent)' : 'var(--color-info)' }}>{ps.time}</span>
|
||||
<span className="text-[10px] text-fg font-korean">{ps.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -407,7 +407,7 @@ export function SatelliteRequest() {
|
||||
const dateFiltered = requests.filter(r => r.dateKey === mapSelectedDate)
|
||||
const dateHasDots = [...new Set(requests.map(r => r.dateKey).filter(Boolean))]
|
||||
return (
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden relative" style={{ height: 'calc(100vh - 160px)' }}>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden relative" style={{ height: 'calc(100vh - 160px)' }}>
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
|
||||
mapStyle={SAT_MAP_STYLE}
|
||||
@ -471,15 +471,15 @@ export function SatelliteRequest() {
|
||||
</Map>
|
||||
|
||||
{/* 좌상단: 캘린더 + 날짜별 리스트 */}
|
||||
<div className="absolute top-3 left-3 w-[260px] rounded-lg border border-border z-10 overflow-hidden" style={{ background: 'rgba(18,25,41,.92)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="absolute top-3 left-3 w-[260px] rounded-lg border border-stroke z-10 overflow-hidden" style={{ background: 'rgba(18,25,41,.92)', backdropFilter: 'blur(8px)' }}>
|
||||
{/* 캘린더 헤더 */}
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📅 촬영 날짜 선택</div>
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">📅 촬영 날짜 선택</div>
|
||||
<input
|
||||
type="date"
|
||||
value={mapSelectedDate}
|
||||
onChange={e => { setMapSelectedDate(e.target.value); setMapSelectedItem(null) }}
|
||||
className="w-full px-2.5 py-1.5 bg-bg-3 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-[var(--cyan)] transition-colors"
|
||||
className="w-full px-2.5 py-1.5 bg-bg-card border border-stroke rounded text-[11px] font-mono text-fg outline-none focus:border-[var(--color-accent)] transition-colors"
|
||||
/>
|
||||
{/* 촬영 이력 있는 날짜 점 표시 */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
@ -489,8 +489,8 @@ export function SatelliteRequest() {
|
||||
onClick={() => { setMapSelectedDate(d!); setMapSelectedItem(null) }}
|
||||
className="px-1.5 py-0.5 rounded text-[8px] font-mono cursor-pointer border transition-colors"
|
||||
style={mapSelectedDate === d
|
||||
? { background: 'rgba(6,182,212,.2)', borderColor: 'var(--cyan)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg0)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
? { background: 'rgba(6,182,212,.2)', borderColor: 'var(--color-accent)', color: 'var(--color-accent)' }
|
||||
: { background: 'var(--bg-base)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>{d?.slice(5)}</button>
|
||||
))}
|
||||
@ -498,12 +498,12 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
|
||||
{/* 날짜별 촬영 리스트 */}
|
||||
<div className="max-h-[35vh] overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="px-3 py-1.5 border-b border-border text-[9px] text-text-3 font-korean sticky top-0" style={{ background: 'rgba(18,25,41,.95)' }}>
|
||||
<div className="max-h-[35vh] overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
<div className="px-3 py-1.5 border-b border-stroke text-[9px] text-fg-disabled font-korean sticky top-0" style={{ background: 'rgba(18,25,41,.95)' }}>
|
||||
{mapSelectedDate} · {dateFiltered.length}건
|
||||
</div>
|
||||
{dateFiltered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-[10px] text-text-3 font-korean text-center">이 날짜에 촬영 이력이 없습니다</div>
|
||||
<div className="px-3 py-4 text-[10px] text-fg-disabled font-korean text-center">이 날짜에 촬영 이력이 없습니다</div>
|
||||
) : dateFiltered.map(r => {
|
||||
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
|
||||
const isSelected = mapSelectedItem?.id === r.id
|
||||
@ -518,13 +518,13 @@ export function SatelliteRequest() {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[10px] font-mono text-text-2">{r.id}</span>
|
||||
<span className="text-[10px] font-mono text-fg-sub">{r.id}</span>
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: `${statusColor}20`, color: statusColor }}>{r.status}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-1 font-korean truncate">{r.zone}</div>
|
||||
<div className="text-[8px] text-text-3 font-mono mt-0.5">{r.satellite} · {r.resolution}</div>
|
||||
<div className="text-[9px] text-fg font-korean truncate">{r.zone}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-mono mt-0.5">{r.satellite} · {r.resolution}</div>
|
||||
{r.status === '완료' && (
|
||||
<div className="mt-1 text-[8px] font-korean" style={{ color: 'var(--cyan)' }}>📷 클릭하여 영상 보기</div>
|
||||
<div className="mt-1 text-[8px] font-korean" style={{ color: 'var(--color-accent)' }}>📷 클릭하여 영상 보기</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@ -533,8 +533,8 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
|
||||
{/* 우상단: 범례 */}
|
||||
<div className="absolute top-3 right-3 px-3 py-2.5 rounded-lg border border-border z-10" style={{ background: 'rgba(18,25,41,.9)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">촬영 이력</div>
|
||||
<div className="absolute top-3 right-3 px-3 py-2.5 rounded-lg border border-stroke z-10" style={{ background: 'rgba(18,25,41,.9)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">촬영 이력</div>
|
||||
{[
|
||||
{ label: '촬영중', color: '#eab308' },
|
||||
{ label: '대기', color: '#3b82f6' },
|
||||
@ -543,30 +543,30 @@ export function SatelliteRequest() {
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: item.color, opacity: 0.4, border: `1px solid ${item.color}` }} />
|
||||
<span className="text-[9px] text-text-3 font-korean">{item.label}</span>
|
||||
<span className="text-[9px] text-fg-disabled font-korean">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-[8px] text-text-3 font-korean mt-1.5 pt-1.5 border-t border-border">총 {requests.length}건</div>
|
||||
<div className="text-[8px] text-fg-disabled font-korean mt-1.5 pt-1.5 border-t border-stroke">총 {requests.length}건</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 항목 상세 (하단) */}
|
||||
{mapSelectedItem && (
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-4 py-3 rounded-lg border border-border z-10 max-w-[500px]" style={{ background: 'rgba(18,25,41,.95)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-4 py-3 rounded-lg border border-stroke z-10 max-w-[500px]" style={{ background: 'rgba(18,25,41,.95)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] font-bold text-text-1 font-korean mb-0.5">{mapSelectedItem.zone}</div>
|
||||
<div className="text-[9px] text-text-3 font-mono">{mapSelectedItem.satellite} · {mapSelectedItem.resolution} · {mapSelectedItem.zoneCoord}</div>
|
||||
<div className="text-[11px] font-bold text-fg font-korean mb-0.5">{mapSelectedItem.zone}</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">{mapSelectedItem.satellite} · {mapSelectedItem.resolution} · {mapSelectedItem.zoneCoord}</div>
|
||||
</div>
|
||||
<div className="text-center shrink-0">
|
||||
<div className="text-[8px] text-text-3 font-korean">요청</div>
|
||||
<div className="text-[10px] font-mono text-text-1">{mapSelectedItem.requestDate}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-korean">요청</div>
|
||||
<div className="text-[10px] font-mono text-fg">{mapSelectedItem.requestDate}</div>
|
||||
</div>
|
||||
{mapSelectedItem.status === '완료' && (
|
||||
<div className="px-2 py-1 rounded text-[9px] font-bold font-korean shrink-0" style={{ background: 'rgba(34,197,94,.15)', color: '#22c55e' }}>
|
||||
📷 영상 표출중
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setMapSelectedItem(null)} className="text-text-3 bg-transparent border-none cursor-pointer text-sm">✕</button>
|
||||
<button onClick={() => setMapSelectedItem(null)} className="text-fg-disabled bg-transparent border-none cursor-pointer text-sm">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -581,7 +581,7 @@ export function SatelliteRequest() {
|
||||
|
||||
{/* ── 제공자 선택 ── */}
|
||||
{modalPhase === 'provider' && (
|
||||
<div className="border rounded-2xl w-[640px] overflow-hidden" style={{ background: 'var(--bg1)', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}>
|
||||
<div className="border rounded-2xl w-[640px] overflow-hidden" style={{ background: 'var(--bg-surface)', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}>
|
||||
{/* 헤더 */}
|
||||
<div className="px-7 pt-6 pb-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#3b82f6,#06b6d4)' }} />
|
||||
@ -589,11 +589,11 @@ export function SatelliteRequest() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(99,102,241,.15),rgba(59,130,246,.08))' }}>🛰</div>
|
||||
<div>
|
||||
<div className="text-base font-bold text-text-1 font-korean">위성 촬영 요청 — 제공자 선택</div>
|
||||
<div className="text-[10px] text-text-3 font-korean mt-0.5">요청할 위성 서비스 제공자를 선택하세요</div>
|
||||
<div className="text-base font-bold text-fg font-korean">위성 촬영 요청 — 제공자 선택</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean mt-0.5">요청할 위성 서비스 제공자를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer text-text-3 p-1 bg-transparent border-none hover:text-text-1 transition-colors">✕</button>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer text-fg-disabled p-1 bg-transparent border-none hover:text-fg transition-colors">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -602,124 +602,124 @@ export function SatelliteRequest() {
|
||||
{/* BlackSky (Maxar) */}
|
||||
<div
|
||||
onClick={() => setModalPhase('blacksky')}
|
||||
className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(99,102,241,.5)] hover:bg-[rgba(99,102,241,.04)] transition-all"
|
||||
className="cursor-pointer bg-bg-elevated border border-stroke rounded-xl p-5 relative overflow-hidden hover:border-[rgba(99,102,241,.5)] hover:bg-[rgba(99,102,241,.04)] transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border border-[rgba(99,102,241,.3)]" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)' }}>
|
||||
<span className="text-[11px] font-extrabold font-mono text-[#818cf8] tracking-[-0.5px]">B<span className="text-[#a78bfa]">Sky</span></span>
|
||||
<span className="text-[11px] font-extrabold font-mono text-color-tertiary tracking-[-0.5px]">B<span className="text-color-tertiary">Sky</span></span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-text-1 font-korean">BlackSky</div>
|
||||
<div className="text-[9px] text-text-3 font-korean mt-px">Maxar Electro-Optical API</div>
|
||||
<div className="text-sm font-bold text-fg font-korean">BlackSky</div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean mt-px">Maxar Electro-Optical API</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold text-green-500" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)' }}>API 연결됨</span>
|
||||
<span className="text-base text-text-3">→</span>
|
||||
<span className="text-base text-fg-disabled">→</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-2.5">
|
||||
{[
|
||||
['유형', 'EO (광학)', '#818cf8'],
|
||||
['해상도', '~1m', 'var(--t1)'],
|
||||
['재방문', '≤1시간', 'var(--t1)'],
|
||||
['해상도', '~1m', 'var(--fg-default)'],
|
||||
['재방문', '≤1시간', 'var(--fg-default)'],
|
||||
['납기', '90분 이내', '#22c55e'],
|
||||
].map(([k, v, c], i) => (
|
||||
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
|
||||
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
|
||||
<div key={i} className="text-center p-1.5 bg-bg-base rounded-md">
|
||||
<div className="text-[7px] text-fg-disabled font-korean mb-0.5">{k}</div>
|
||||
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-korean leading-relaxed">고빈도 소형위성 군집 기반 긴급 촬영. 해양 사고 현장 신속 모니터링에 최적화. Dawn-to-Dusk 촬영 가능.</div>
|
||||
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span className="text-[#818cf8]">eapi.maxar.com/e1so/rapidoc</span></div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean leading-relaxed">고빈도 소형위성 군집 기반 긴급 촬영. 해양 사고 현장 신속 모니터링에 최적화. Dawn-to-Dusk 촬영 가능.</div>
|
||||
<div className="mt-2 text-[8px] text-fg-disabled font-mono">API: <span className="text-color-tertiary">eapi.maxar.com/e1so/rapidoc</span></div>
|
||||
</div>
|
||||
|
||||
{/* UP42 (EO + SAR) */}
|
||||
<div
|
||||
onClick={() => setModalPhase('up42')}
|
||||
className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(59,130,246,.5)] hover:bg-[rgba(59,130,246,.04)] transition-all"
|
||||
className="cursor-pointer bg-bg-elevated border border-stroke rounded-xl p-5 relative overflow-hidden hover:border-[rgba(59,130,246,.5)] hover:bg-[rgba(59,130,246,.04)] transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border border-[rgba(59,130,246,.3)]" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)' }}>
|
||||
<span className="text-[13px] font-extrabold font-mono text-[#60a5fa] tracking-[-0.5px]">up<sup className="text-[7px] align-super">42</sup></span>
|
||||
<span className="text-[13px] font-extrabold font-mono text-color-info tracking-[-0.5px]">up<sup className="text-[7px] align-super">42</sup></span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-text-1 font-korean">UP42 — EO + SAR</div>
|
||||
<div className="text-[9px] text-text-3 font-korean mt-px">Optical · SAR · Elevation 통합 마켓플레이스</div>
|
||||
<div className="text-sm font-bold text-fg font-korean">UP42 — EO + SAR</div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean mt-px">Optical · SAR · Elevation 통합 마켓플레이스</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold text-green-500" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)' }}>API 연결됨</span>
|
||||
<span className="text-base text-text-3">→</span>
|
||||
<span className="text-base text-fg-disabled">→</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-2.5">
|
||||
{[
|
||||
['유형', 'EO + SAR', '#60a5fa'],
|
||||
['해상도', '0.3~5m', 'var(--t1)'],
|
||||
['위성 수', '16+ 컬렉션', 'var(--t1)'],
|
||||
['해상도', '0.3~5m', 'var(--fg-default)'],
|
||||
['위성 수', '16+ 컬렉션', 'var(--fg-default)'],
|
||||
['야간/악천후', 'SAR 가능', '#22c55e'],
|
||||
].map(([k, v, c], i) => (
|
||||
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
|
||||
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
|
||||
<div key={i} className="text-center p-1.5 bg-bg-base rounded-md">
|
||||
<div className="text-[7px] text-fg-disabled font-korean mb-0.5">{k}</div>
|
||||
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mb-2.5 flex-wrap">
|
||||
{['Pléiades Neo', 'SPOT 6/7'].map((t, i) => (
|
||||
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean text-[#60a5fa]" style={{ background: 'rgba(59,130,246,.08)', border: '1px solid rgba(59,130,246,.15)' }}>{t}</span>
|
||||
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean text-color-info" style={{ background: 'rgba(59,130,246,.08)', border: '1px solid rgba(59,130,246,.15)' }}>{t}</span>
|
||||
))}
|
||||
{['TerraSAR-X', 'Capella SAR', 'ICEYE'].map((t, i) => (
|
||||
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.15)', color: 'var(--cyan)' }}>{t}</span>
|
||||
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.15)', color: 'var(--color-accent)' }}>{t}</span>
|
||||
))}
|
||||
<span className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(139,148,158,.08)', border: '1px solid rgba(139,148,158,.15)', color: 'var(--t3)' }}>+11 more</span>
|
||||
<span className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(139,148,158,.08)', border: '1px solid rgba(139,148,158,.15)', color: 'var(--fg-disabled)' }}>+11 more</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-korean leading-relaxed">광학(EO) + 합성개구레이더(SAR) 통합 마켓. 야간·악천후 시 SAR 활용. 다중 위성 소스 자동 최적 선택.</div>
|
||||
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span className="text-[#60a5fa]">up42.com</span></div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean leading-relaxed">광학(EO) + 합성개구레이더(SAR) 통합 마켓. 야간·악천후 시 SAR 활용. 다중 위성 소스 자동 최적 선택.</div>
|
||||
<div className="mt-2 text-[8px] text-fg-disabled font-mono">API: <span className="text-color-info">up42.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 */}
|
||||
<div className="px-7 pb-5 flex items-center justify-between">
|
||||
<div className="text-[9px] text-text-3 font-korean leading-relaxed">💡 긴급 촬영: BlackSky 권장 (90분 납기) · 야간/악천후: UP42 SAR 권장</div>
|
||||
<button onClick={() => setModalPhase('none')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean bg-bg-3 text-text-2 border-border">닫기</button>
|
||||
<div className="text-[9px] text-fg-disabled font-korean leading-relaxed">💡 긴급 촬영: BlackSky 권장 (90분 납기) · 야간/악천후: UP42 SAR 권장</div>
|
||||
<button onClick={() => setModalPhase('none')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean bg-bg-card text-fg-sub border-stroke">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── BlackSky 긴급 촬영 요청 ── */}
|
||||
{modalPhase === 'blacksky' && (
|
||||
<div className="border rounded-[14px] w-[860px] max-h-[90vh] flex flex-col overflow-hidden border-[rgba(99,102,241,.3)]" style={{ background: '#0d1117', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
|
||||
<div className="border rounded-[14px] w-[860px] max-h-[90vh] flex flex-col overflow-hidden border-[rgba(99,102,241,.3)]" style={{ background: 'var(--bg-base)', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-[#21262d] flex items-center justify-between shrink-0 relative">
|
||||
<div className="px-6 py-4 border-b border-stroke flex items-center justify-between shrink-0 relative">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#818cf8,#a78bfa)' }} />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center border border-[rgba(99,102,241,.3)]" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)' }}>
|
||||
<span className="text-[10px] font-extrabold font-mono text-[#818cf8]">B<span className="text-[#a78bfa]">Sky</span></span>
|
||||
<span className="text-[10px] font-extrabold font-mono text-color-tertiary">B<span className="text-color-tertiary">Sky</span></span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[15px] font-bold font-korean text-[#e2e8f0]">BlackSky — 긴급 위성 촬영 요청</div>
|
||||
<div className="text-[9px] font-korean mt-0.5 text-[#64748b]">Maxar E1SO RapiDoc API · 고빈도 긴급 태스킹</div>
|
||||
<div className="text-[15px] font-bold font-korean text-fg">BlackSky — 긴급 위성 촬영 요청</div>
|
||||
<div className="text-[9px] font-korean mt-0.5 text-fg-disabled">Maxar E1SO RapiDoc API · 고빈도 긴급 태스킹</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 rounded-md text-[9px] font-semibold font-korean text-[#818cf8]" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.25)' }}>API Docs ↗</span>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none text-[#64748b]">✕</button>
|
||||
<span className="px-3 py-1 rounded-md text-[9px] font-semibold font-korean text-color-tertiary" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.25)' }}>API Docs ↗</span>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none text-fg-disabled">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-default) transparent' }}>
|
||||
{/* API 상태 */}
|
||||
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg" style={{ background: 'rgba(34,197,94,.06)', border: '1px solid rgba(34,197,94,.15)' }}>
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e', boxShadow: '0 0 6px rgba(34,197,94,.5)' }} />
|
||||
<span className="text-[10px] font-semibold font-korean text-green-500">API Connected</span>
|
||||
<span className="text-[9px] font-mono text-[#64748b]">eapi.maxar.com/e1so/rapidoc · Latency: 142ms</span>
|
||||
<span className="ml-auto text-[8px] font-mono text-[#64748b]">Quota: 47/50 요청 잔여</span>
|
||||
<span className="text-[9px] font-mono text-fg-disabled">eapi.maxar.com/e1so/rapidoc · Latency: 142ms</span>
|
||||
<span className="ml-auto text-[8px] font-mono text-fg-disabled">Quota: 47/50 요청 잔여</span>
|
||||
</div>
|
||||
|
||||
{/* ① 태스킹 유형 */}
|
||||
@ -727,7 +727,7 @@ export function SatelliteRequest() {
|
||||
{sectionHeader(1, '태스킹 유형 · 우선순위')}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">촬영 유형 <span className="text-red-400">*</span></label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">촬영 유형 <span className="text-red-400">*</span></label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>긴급 태스킹 (Emergency)</option>
|
||||
<option>표준 태스킹 (Standard)</option>
|
||||
@ -735,7 +735,7 @@ export function SatelliteRequest() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">우선순위 <span className="text-red-400">*</span></label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">우선순위 <span className="text-red-400">*</span></label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>P1 — 긴급 (90분 내)</option>
|
||||
<option>P2 — 높음 (6시간 내)</option>
|
||||
@ -743,7 +743,7 @@ export function SatelliteRequest() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">촬영 모드</label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">촬영 모드</label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>Single Collect</option>
|
||||
<option>Multi-pass Monitoring</option>
|
||||
@ -758,26 +758,26 @@ export function SatelliteRequest() {
|
||||
{sectionHeader(2, '관심 영역 (AOI)')}
|
||||
<div className="grid grid-cols-3 gap-2.5 items-end">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">중심 위도 <span className="text-red-400">*</span></label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">중심 위도 <span className="text-red-400">*</span></label>
|
||||
<input type="text" defaultValue="34.5832" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">중심 경도 <span className="text-red-400">*</span></label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">중심 경도 <span className="text-red-400">*</span></label>
|
||||
<input type="text" defaultValue="128.4217" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<button className="px-3.5 py-2 rounded-md text-[10px] font-semibold cursor-pointer font-korean whitespace-nowrap text-[#818cf8]" style={{ border: '1px solid rgba(99,102,241,.3)', background: 'rgba(99,102,241,.08)' }}>📍 지도에서 AOI 그리기</button>
|
||||
<button className="px-3.5 py-2 rounded-md text-[10px] font-semibold cursor-pointer font-korean whitespace-nowrap text-color-tertiary" style={{ border: '1px solid rgba(99,102,241,.3)', background: 'rgba(99,102,241,.08)' }}>📍 지도에서 AOI 그리기</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">AOI 반경 (km)</label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">AOI 반경 (km)</label>
|
||||
<input type="number" defaultValue={10} step={1} min={1} className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">최대 구름량 (%)</label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">최대 구름량 (%)</label>
|
||||
<input type="number" defaultValue={20} step={5} min={0} max={100} className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">최대 Off-nadir (°)</label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">최대 Off-nadir (°)</label>
|
||||
<input type="number" defaultValue={25} step={5} min={0} max={45} className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
@ -788,15 +788,15 @@ export function SatelliteRequest() {
|
||||
{sectionHeader(3, '촬영 기간 · 반복')}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">촬영 시작 <span className="text-red-400">*</span></label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">촬영 시작 <span className="text-red-400">*</span></label>
|
||||
<input type="datetime-local" defaultValue="2026-02-26T08:00" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">촬영 종료 <span className="text-red-400">*</span></label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">촬영 종료 <span className="text-red-400">*</span></label>
|
||||
<input type="datetime-local" defaultValue="2026-02-27T20:00" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">반복 촬영</label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">반복 촬영</label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>1회 (단일)</option>
|
||||
<option>매 패스 (가용 시)</option>
|
||||
@ -813,7 +813,7 @@ export function SatelliteRequest() {
|
||||
{sectionHeader(4, '산출물 설정')}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">산출물 형식 <span className="text-red-400">*</span></label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">산출물 형식 <span className="text-red-400">*</span></label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>Ortho-Rectified (정사보정)</option>
|
||||
<option>Pan-sharpened (팬샤프닝)</option>
|
||||
@ -821,7 +821,7 @@ export function SatelliteRequest() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">파일 포맷</label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">파일 포맷</label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>GeoTIFF</option>
|
||||
<option>NITF</option>
|
||||
@ -836,7 +836,7 @@ export function SatelliteRequest() {
|
||||
{ label: '변화탐지 (Change Detection)', checked: false },
|
||||
{ label: '웹훅 알림', checked: false },
|
||||
].map((opt, i) => (
|
||||
<label key={i} className="flex items-center gap-1 text-[9px] cursor-pointer font-korean text-[#94a3b8]">
|
||||
<label key={i} className="flex items-center gap-1 text-[9px] cursor-pointer font-korean text-fg-disabled">
|
||||
<input type="checkbox" defaultChecked={opt.checked} style={{ accentColor: '#818cf8', transform: 'scale(.85)' }} /> {opt.label}
|
||||
</label>
|
||||
))}
|
||||
@ -848,7 +848,7 @@ export function SatelliteRequest() {
|
||||
{sectionHeader(5, '연계 사고 · 비고')}
|
||||
<div className="grid grid-cols-2 gap-2.5 mb-2">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">연계 사고번호</label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">연계 사고번호</label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>OIL-2024-0892 · M/V STELLAR DAISY</option>
|
||||
<option>HNS-2024-041 · 울산 온산항 톨루엔 유출</option>
|
||||
@ -857,23 +857,23 @@ export function SatelliteRequest() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">요청자</label>
|
||||
<label className="block text-[9px] font-korean mb-1 text-fg-disabled">요청자</label>
|
||||
<input type="text" placeholder="소속 / 이름" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="촬영 요청 목적, 특이사항, 관심 대상 등을 기록합니다..."
|
||||
className="w-full h-[50px] px-3 py-2.5 rounded-md text-[10px] font-korean outline-none resize-y leading-relaxed box-border border border-[#21262d] text-[#e2e8f0] bg-[#161b22]"
|
||||
className="w-full h-[50px] px-3 py-2.5 rounded-md text-[10px] font-korean outline-none resize-y leading-relaxed box-border border border-stroke text-fg bg-bg-elevated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="px-6 py-3.5 border-t border-[#21262d] flex items-center gap-2 shrink-0">
|
||||
<div className="flex-1 text-[9px] font-korean leading-relaxed text-[#64748b]">
|
||||
<div className="px-6 py-3.5 border-t border-stroke flex items-center gap-2 shrink-0">
|
||||
<div className="flex-1 text-[9px] font-korean leading-relaxed text-fg-disabled">
|
||||
<span className="text-red-400">*</span> 필수 항목 · 긴급 태스킹은 P1 우선순위로 90분 내 최초 영상 수신
|
||||
</div>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-5 py-2.5 rounded-lg border border-[#21262d] text-xs font-semibold cursor-pointer font-korean text-[#94a3b8] bg-[#161b22]">← 뒤로</button>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-5 py-2.5 rounded-lg border border-stroke text-xs font-semibold cursor-pointer font-korean text-fg-sub bg-bg-elevated">← 뒤로</button>
|
||||
<button onClick={() => setModalPhase('none')} className="px-7 py-2.5 rounded-lg border-none text-xs font-bold cursor-pointer font-korean text-white" style={{ background: 'linear-gradient(135deg,#6366f1,#818cf8)', boxShadow: '0 4px 16px rgba(99,102,241,.35)' }}>🛰 BlackSky 촬영 요청 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -881,31 +881,31 @@ export function SatelliteRequest() {
|
||||
|
||||
{/* ── UP42 카탈로그 주문 ── */}
|
||||
{modalPhase === 'up42' && (
|
||||
<div className="border rounded-[14px] w-[920px] flex flex-col overflow-hidden border-[rgba(59,130,246,.3)]" style={{ background: '#0d1117', boxShadow: '0 24px 80px rgba(0,0,0,.7)', height: '85vh' }}>
|
||||
<div className="border rounded-[14px] w-[920px] flex flex-col overflow-hidden border-[rgba(59,130,246,.3)]" style={{ background: 'var(--bg-base)', boxShadow: '0 24px 80px rgba(0,0,0,.7)', height: '85vh' }}>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-[#21262d] flex items-center justify-between shrink-0 relative">
|
||||
<div className="px-6 py-4 border-b border-stroke flex items-center justify-between shrink-0 relative">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#3b82f6,#06b6d4,#22c55e)' }} />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center border border-[rgba(59,130,246,.3)]" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)' }}>
|
||||
<span className="text-[13px] font-extrabold font-mono text-[#60a5fa] tracking-[-0.5px]">up<sup className="text-[7px] align-super">42</sup></span>
|
||||
<span className="text-[13px] font-extrabold font-mono text-color-info tracking-[-0.5px]">up<sup className="text-[7px] align-super">42</sup></span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[15px] font-bold font-korean text-[#e2e8f0]">위성 촬영 요청 — 새 태스킹 주문</div>
|
||||
<div className="text-[9px] font-korean mt-0.5 text-[#64748b]">관심 지역(AOI)을 그리고 위성 패스를 선택하세요</div>
|
||||
<div className="text-[15px] font-bold font-korean text-fg">위성 촬영 요청 — 새 태스킹 주문</div>
|
||||
<div className="text-[9px] font-korean mt-0.5 text-fg-disabled">관심 지역(AOI)을 그리고 위성 패스를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.1)', border: '1px solid rgba(234,179,8,.25)', color: '#eab308' }}>⚠ Beijing-3N 납기 지연 2.15–2.23</span>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none text-[#64748b]">✕</button>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none text-fg-disabled">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 (좌: 사이드바, 우: 지도+AOI) */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 왼쪽: 위성 카탈로그 */}
|
||||
<div className="flex flex-col overflow-hidden border-r border-[#21262d]" style={{ width: 320, minWidth: 320, background: '#0d1117' }}>
|
||||
<div className="flex flex-col overflow-hidden border-r border-stroke" style={{ width: 320, minWidth: 320, background: 'var(--bg-base)' }}>
|
||||
{/* Optical / SAR / Elevation 탭 */}
|
||||
<div className="flex border-b border-[#21262d] shrink-0">
|
||||
<div className="flex border-b border-stroke shrink-0">
|
||||
{(['optical', 'sar', 'elevation'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
@ -920,34 +920,34 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
|
||||
{/* 필터 바 */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-[#21262d] shrink-0">
|
||||
<span className="px-2 py-0.5 rounded text-[9px] font-semibold text-[#60a5fa]" style={{ background: 'rgba(59,130,246,.1)', border: '1px solid rgba(59,130,246,.2)' }}>Filters ✎</span>
|
||||
<span className="px-2 py-0.5 rounded text-[9px] font-semibold text-[#818cf8]" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.2)' }}>☁ 구름 ≤ 20% ✕</span>
|
||||
<span className="ml-auto text-[9px] font-mono text-[#64748b]">↕ 해상도 우선</span>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-stroke shrink-0">
|
||||
<span className="px-2 py-0.5 rounded text-[9px] font-semibold text-color-info" style={{ background: 'rgba(59,130,246,.1)', border: '1px solid rgba(59,130,246,.2)' }}>Filters ✎</span>
|
||||
<span className="px-2 py-0.5 rounded text-[9px] font-semibold text-color-tertiary" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.2)' }}>☁ 구름 ≤ 20% ✕</span>
|
||||
<span className="ml-auto text-[9px] font-mono text-fg-disabled">↕ 해상도 우선</span>
|
||||
</div>
|
||||
|
||||
{/* 컬렉션 수 */}
|
||||
<div className="px-3 py-1.5 border-b border-[#21262d] text-[9px] font-korean shrink-0 text-[#64748b]">
|
||||
이 지역에서 <b className="text-[#e2e8f0]">{up42Filtered.length}</b>개 컬렉션 사용 가능
|
||||
<div className="px-3 py-1.5 border-b border-stroke text-[9px] font-korean shrink-0 text-fg-disabled">
|
||||
이 지역에서 <b className="text-fg">{up42Filtered.length}</b>개 컬렉션 사용 가능
|
||||
</div>
|
||||
|
||||
{/* 위성 목록 */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-default) transparent' }}>
|
||||
{up42Filtered.map(sat => (
|
||||
<div
|
||||
key={sat.id}
|
||||
onClick={() => setUp42SelSat(up42SelSat === sat.id ? null : sat.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-[#161b22] cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-stroke cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: up42SelSat === sat.id ? 'rgba(59,130,246,.08)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: sat.color }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] font-semibold truncate font-korean text-[#e2e8f0]">{sat.name}</div>
|
||||
<div className="text-[11px] font-semibold truncate font-korean text-fg">{sat.name}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[9px] font-bold font-mono" style={{ color: sat.color }}>{sat.res}</span>
|
||||
{sat.cloud > 0 && <span className="text-[8px] font-mono text-[#64748b]">☁ ≤{sat.cloud}%</span>}
|
||||
{sat.cloud > 0 && <span className="text-[8px] font-mono text-fg-disabled">☁ ≤{sat.cloud}%</span>}
|
||||
{'delay' in sat && sat.delay && <span className="text-[8px] font-bold" style={{ color: '#eab308' }}>⚠ 지연</span>}
|
||||
</div>
|
||||
</div>
|
||||
@ -1008,33 +1008,33 @@ export function SatelliteRequest() {
|
||||
</Map>
|
||||
|
||||
{/* 범례 오버레이 */}
|
||||
<div className="absolute top-3 left-3 px-3 py-2 rounded-lg z-10 border border-[#21262d]" style={{ background: 'rgba(13,17,23,.9)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="text-[9px] font-bold text-[#64748b] mb-1.5">🛰 위성 궤도</div>
|
||||
<div className="absolute top-3 left-3 px-3 py-2 rounded-lg z-10 border border-stroke" style={{ background: 'rgba(13,17,23,.9)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="text-[9px] font-bold text-fg-disabled mb-1.5">🛰 위성 궤도</div>
|
||||
{satPasses.slice(0, 4).map(p => (
|
||||
<div key={p.id} className="flex items-center gap-1.5 mb-1">
|
||||
<div className="w-3 h-[2px] rounded-sm" style={{ background: p.color }} />
|
||||
<span className="text-[8px] text-[#94a3b8]">{p.satellite}</span>
|
||||
<span className="text-[8px] text-fg-disabled">{p.satellite}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 mt-1.5 pt-1.5 border-t border-[#21262d]">
|
||||
<div className="flex items-center gap-1.5 mt-1.5 pt-1.5 border-t border-stroke">
|
||||
<div className="w-3 h-3 rounded border border-[#3b82f6]" style={{ background: 'rgba(59,130,246,.1)' }} />
|
||||
<span className="text-[8px] text-[#64748b]">한국 영역 AOI</span>
|
||||
<span className="text-[8px] text-fg-disabled">한국 영역 AOI</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 */}
|
||||
{satPassesLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: 'rgba(0,0,0,.5)' }}>
|
||||
<div className="text-[11px] text-[#60a5fa] font-korean animate-pulse">🛰 위성 패스 조회 중...</div>
|
||||
<div className="text-[11px] text-color-info font-korean animate-pulse">🛰 위성 패스 조회 중...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위성 패스 타임라인 */}
|
||||
<div className="border-t border-[#21262d] px-4 py-3 shrink-0 max-h-[200px] overflow-y-auto" style={{ background: 'rgba(13,17,23,.95)', scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
|
||||
<div className="text-[10px] font-bold font-korean mb-2 text-[#e2e8f0]">
|
||||
<div className="border-t border-stroke px-4 py-3 shrink-0 max-h-[200px] overflow-y-auto" style={{ background: 'rgba(13,17,23,.95)', scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-default) transparent' }}>
|
||||
<div className="text-[10px] font-bold font-korean mb-2 text-fg">
|
||||
🛰 한국 주변 실시간 위성 패스 ({satPasses.length}개)
|
||||
<span className="text-[8px] text-[#64748b] font-normal ml-2">클릭하여 궤도 확인</span>
|
||||
<span className="text-[8px] text-fg-disabled font-normal ml-2">클릭하여 궤도 확인</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{satPasses.map(pass => {
|
||||
@ -1048,20 +1048,20 @@ export function SatelliteRequest() {
|
||||
onClick={() => setUp42SelPass(up42SelPass === pass.id ? null : pass.id)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: up42SelPass === pass.id ? 'rgba(59,130,246,.1)' : '#161b22',
|
||||
border: up42SelPass === pass.id ? `1px solid ${pass.color}40` : '1px solid #21262d',
|
||||
background: up42SelPass === pass.id ? 'rgba(59,130,246,.1)' : 'var(--bg-elevated)',
|
||||
border: up42SelPass === pass.id ? `1px solid ${pass.color}40` : '1px solid var(--stroke-light)',
|
||||
}}
|
||||
>
|
||||
<div className="w-1.5 h-6 rounded-full shrink-0" style={{ background: pass.color }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold font-korean text-[#e2e8f0]">{pass.satellite}</span>
|
||||
<span className="text-[8px] text-[#64748b]">{pass.provider}</span>
|
||||
<span className="text-[10px] font-bold font-korean text-fg">{pass.satellite}</span>
|
||||
<span className="text-[8px] text-fg-disabled">{pass.provider}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[9px] font-bold font-mono text-[#60a5fa]">{timeStr}</span>
|
||||
<span className="text-[9px] font-bold font-mono text-color-info">{timeStr}</span>
|
||||
<span className="text-[9px] font-mono" style={{ color: pass.color }}>{pass.resolution}</span>
|
||||
<span className="text-[8px] font-mono text-[#64748b]">EL {pass.maxElevation}° · {pass.direction === 'ascending' ? '↗ 상승' : '↘ 하강'}</span>
|
||||
<span className="text-[8px] font-mono text-fg-disabled">EL {pass.maxElevation}° · {pass.direction === 'ascending' ? '↗ 상승' : '↘ 하강'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-1.5 py-px rounded text-[8px] font-bold shrink-0" style={{
|
||||
@ -1078,18 +1078,18 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-3 border-t border-[#21262d] flex items-center justify-between shrink-0">
|
||||
<div className="text-[9px] font-korean text-[#64748b]">원하는 위성을 찾지 못했나요? <span className="text-[#60a5fa] cursor-pointer">태스킹 주문 생성</span> 또는 <span className="text-[#60a5fa] cursor-pointer">자세히 보기 ↗</span></div>
|
||||
<div className="px-6 py-3 border-t border-stroke flex items-center justify-between shrink-0">
|
||||
<div className="text-[9px] font-korean text-fg-disabled">원하는 위성을 찾지 못했나요? <span className="text-color-info cursor-pointer">태스킹 주문 생성</span> 또는 <span className="text-color-info cursor-pointer">자세히 보기 ↗</span></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-korean mr-1.5 text-[#8690a6]">
|
||||
<span className="text-[11px] font-korean mr-1.5 text-fg-disabled">
|
||||
선택: {up42SelPass ? satPasses.find(p => p.id === up42SelPass)?.satellite : up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
|
||||
</span>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-4 py-2 rounded-lg border border-[#21262d] text-[11px] font-semibold cursor-pointer font-korean text-[#94a3b8] bg-[#161b22]">← 뒤로</button>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-4 py-2 rounded-lg border border-stroke text-[11px] font-semibold cursor-pointer font-korean text-fg-disabled bg-bg-elevated">← 뒤로</button>
|
||||
<button
|
||||
onClick={() => setModalPhase('none')}
|
||||
className="px-6 py-2 rounded-lg border-none text-[11px] font-bold cursor-pointer font-korean text-white transition-opacity"
|
||||
style={{
|
||||
background: up42SelSat ? 'linear-gradient(135deg,#3b82f6,#06b6d4)' : '#21262d',
|
||||
background: up42SelSat ? 'linear-gradient(135deg,#3b82f6,#06b6d4)' : 'var(--stroke-light)',
|
||||
opacity: up42SelSat ? 1 : 0.5,
|
||||
color: up42SelSat ? '#fff' : '#64748b',
|
||||
boxShadow: up42SelSat ? '0 4px 16px rgba(59,130,246,.35)' : 'none',
|
||||
|
||||
@ -305,9 +305,9 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-primary-cyan/40 text-xs font-mono animate-pulse">재구성 처리중...</div>
|
||||
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
|
||||
<div className="h-full bg-primary-cyan/40 rounded-full" style={{ width: '64%', animation: 'pulse 2s infinite' }} />
|
||||
<div className="text-color-accent/40 text-xs font-mono animate-pulse">재구성 처리중...</div>
|
||||
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
||||
<div className="h-full bg-color-accent/40 rounded-full" style={{ width: '64%', animation: 'pulse 2s infinite' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -565,7 +565,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
|
||||
<canvas ref={canvasRef} style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }} />
|
||||
|
||||
{viewMode === '3d' && !isProcessing && (
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>
|
||||
<span>0mm</span>
|
||||
<div style={{ width: '60px', height: '4px', borderRadius: '2px', background: 'linear-gradient(90deg, rgba(234,179,8,0.6), rgba(249,115,22,0.7), rgba(239,68,68,0.8))' }} />
|
||||
<span>3.2mm</span>
|
||||
@ -575,9 +575,9 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-status-red/40 text-xs font-mono animate-pulse">재구성 처리중...</div>
|
||||
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
|
||||
<div className="h-full bg-status-red/40 rounded-full" style={{ width: '52%', animation: 'pulse 2s infinite' }} />
|
||||
<div className="text-color-danger/40 text-xs font-mono animate-pulse">재구성 처리중...</div>
|
||||
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
||||
<div className="h-full bg-color-danger/40 rounded-full" style={{ width: '52%', animation: 'pulse 2s infinite' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -596,17 +596,17 @@ export function SensorAnalysis() {
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||
{/* Left Panel */}
|
||||
<div className="w-[280px] bg-bg-1 border-r border-border flex flex-col overflow-auto">
|
||||
<div className="w-[280px] bg-bg-surface border-r border-stroke flex flex-col overflow-auto">
|
||||
{/* 3D Reconstruction List */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📋 3D 재구성 완료 목록</div>
|
||||
<div className="p-2.5 px-3 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">📋 3D 재구성 완료 목록</div>
|
||||
<div className="flex gap-1 mb-2">
|
||||
<button
|
||||
onClick={() => setSubTab('vessel')}
|
||||
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
|
||||
subTab === 'vessel'
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||
: 'text-text-3 bg-bg-0 border-border'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-color-accent/20'
|
||||
: 'text-fg-disabled bg-bg-base border-stroke'
|
||||
}`}
|
||||
>
|
||||
🚢 선박
|
||||
@ -615,8 +615,8 @@ export function SensorAnalysis() {
|
||||
onClick={() => setSubTab('pollution')}
|
||||
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
|
||||
subTab === 'pollution'
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||
: 'text-text-3 bg-bg-0 border-border'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-color-accent/20'
|
||||
: 'text-fg-disabled bg-bg-base border-stroke'
|
||||
}`}
|
||||
>
|
||||
🛢️ 오염원
|
||||
@ -629,15 +629,15 @@ export function SensorAnalysis() {
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className={`flex items-center gap-2 px-2 py-2 rounded-sm cursor-pointer transition-colors border ${
|
||||
selectedItem.id === item.id
|
||||
? 'bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||
? 'bg-[rgba(6,182,212,0.08)] border-color-accent/20'
|
||||
: 'border-transparent hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-bold text-text-1 font-korean">{item.name}</div>
|
||||
<div className="text-[8px] text-text-3 font-mono">{item.id} · {item.points} pts</div>
|
||||
<div className="text-[10px] font-bold text-fg font-korean">{item.name}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-mono">{item.id} · {item.points} pts</div>
|
||||
</div>
|
||||
<span className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-status-green' : 'text-status-orange'}`}>
|
||||
<span className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-color-success' : 'text-color-warning'}`}>
|
||||
{item.status === 'complete' ? '✅ 완료' : '⏳ 처리중'}
|
||||
</span>
|
||||
</div>
|
||||
@ -647,19 +647,19 @@ export function SensorAnalysis() {
|
||||
|
||||
{/* Source Images */}
|
||||
<div className="p-2.5 px-3 flex-1 min-h-0 flex flex-col">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📹 촬영 원본</div>
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">📹 촬영 원본</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{[
|
||||
{ label: 'D-01 정면', sensor: '광학', color: 'text-primary-blue' },
|
||||
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-status-red' },
|
||||
{ label: 'D-03 우현', sensor: '광학', color: 'text-primary-purple' },
|
||||
{ label: 'D-02 상부', sensor: 'IR', color: 'text-status-red' },
|
||||
{ label: 'D-01 정면', sensor: '광학', color: 'text-color-info' },
|
||||
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-color-danger' },
|
||||
{ label: 'D-03 우현', sensor: '광학', color: 'text-color-tertiary' },
|
||||
{ label: 'D-02 상부', sensor: 'IR', color: 'text-color-danger' },
|
||||
].map((src, i) => (
|
||||
<div key={i} className="relative rounded-sm bg-bg-0 border border-border overflow-hidden aspect-square">
|
||||
<div key={i} className="relative rounded-sm bg-bg-base border border-stroke overflow-hidden aspect-square">
|
||||
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}>
|
||||
<div className="text-text-3/10 text-xs font-mono">{src.label.split(' ')[0]}</div>
|
||||
<div className="text-fg-disabled/10 text-xs font-mono">{src.label.split(' ')[0]}</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-[8px] text-text-3 font-korean">
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-[8px] text-fg-disabled font-korean">
|
||||
<span>{src.label}</span>
|
||||
<span className={src.color}>{src.sensor}</span>
|
||||
</div>
|
||||
@ -670,7 +670,7 @@ export function SensorAnalysis() {
|
||||
</div>
|
||||
|
||||
{/* Center Panel - 3D Canvas */}
|
||||
<div className="flex-1 relative bg-bg-0 border-x border-border flex items-center justify-center overflow-hidden">
|
||||
<div className="flex-1 relative bg-bg-base border-x border-stroke flex items-center justify-center overflow-hidden">
|
||||
{/* Simulated 3D viewport */}
|
||||
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
|
||||
{/* Grid floor */}
|
||||
@ -684,7 +684,7 @@ export function SensorAnalysis() {
|
||||
)}
|
||||
|
||||
{/* Axis indicator */}
|
||||
<div className="absolute bottom-16 left-4" style={{ fontSize: '9px', fontFamily: 'var(--fM)' }}>
|
||||
<div className="absolute bottom-16 left-4" style={{ fontSize: '9px', fontFamily: 'var(--font-mono)' }}>
|
||||
<div style={{ color: '#ef4444' }}>X →</div>
|
||||
<div className="text-green-500">Y ↑</div>
|
||||
<div className="text-blue-500">Z ⊙</div>
|
||||
@ -693,9 +693,9 @@ export function SensorAnalysis() {
|
||||
|
||||
{/* Title */}
|
||||
<div className="absolute top-3 left-3 z-[2]">
|
||||
<div className="text-[10px] font-bold text-text-3 uppercase tracking-wider">3D Vessel Analysis</div>
|
||||
<div className="text-[13px] font-bold text-primary-cyan my-1 font-korean">{selectedItem.name} 정밀분석</div>
|
||||
<div className="text-[9px] text-text-3 font-mono">34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}</div>
|
||||
<div className="text-[10px] font-bold text-fg-disabled uppercase tracking-wider">3D Vessel Analysis</div>
|
||||
<div className="text-[13px] font-bold text-color-accent my-1 font-korean">{selectedItem.name} 정밀분석</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}</div>
|
||||
</div>
|
||||
|
||||
{/* View Mode Buttons */}
|
||||
@ -710,8 +710,8 @@ export function SensorAnalysis() {
|
||||
onClick={() => setViewMode(m.id)}
|
||||
className={`px-2.5 py-1.5 text-[10px] font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
|
||||
viewMode === m.id
|
||||
? 'bg-[rgba(6,182,212,0.2)] border-primary-cyan/50 text-primary-cyan'
|
||||
: 'bg-black/40 border-primary-cyan/20 text-text-3 hover:bg-black/60 hover:border-primary-cyan/40'
|
||||
? 'bg-[rgba(6,182,212,0.2)] border-color-accent/50 text-color-accent'
|
||||
: 'bg-black/40 border-color-accent/20 text-fg-disabled hover:bg-black/60 hover:border-color-accent/40'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
@ -729,18 +729,18 @@ export function SensorAnalysis() {
|
||||
{ value: '0.023m', label: 'RMS오차' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="font-mono font-bold text-sm text-primary-cyan">{s.value}</div>
|
||||
<div className="text-[8px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
||||
<div className="font-mono font-bold text-sm text-color-accent">{s.value}</div>
|
||||
<div className="text-[8px] text-fg-disabled mt-0.5 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Analysis Details */}
|
||||
<div className="w-[270px] bg-bg-1 border-l border-border flex flex-col overflow-auto">
|
||||
<div className="w-[270px] bg-bg-surface border-l border-stroke flex flex-col overflow-auto">
|
||||
{/* Ship/Pollution Info */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📊 분석 정보</div>
|
||||
<div className="p-2.5 px-3 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">📊 분석 정보</div>
|
||||
<div className="flex flex-col gap-1.5 text-[10px]">
|
||||
{(selectedItem.type === 'vessel' ? [
|
||||
['대상', selectedItem.name],
|
||||
@ -761,34 +761,34 @@ export function SensorAnalysis() {
|
||||
['확산 속도', '0.3 km/h (ESE 방향)'],
|
||||
]).map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between items-start">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-text-1 text-right ml-2">{v}</span>
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-fg text-right ml-2">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Detection Results */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">🤖 AI 탐지 결과</div>
|
||||
<div className="p-2.5 px-3 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">🤖 AI 탐지 결과</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(selectedItem.type === 'vessel' ? [
|
||||
{ label: '선박 식별', confidence: 94, color: 'bg-status-green' },
|
||||
{ label: '선종 분류', confidence: 78, color: 'bg-status-yellow' },
|
||||
{ label: '손상 감지', confidence: 45, color: 'bg-status-orange' },
|
||||
{ label: '화물 분석', confidence: 62, color: 'bg-status-yellow' },
|
||||
{ label: '선박 식별', confidence: 94, color: 'bg-color-success' },
|
||||
{ label: '선종 분류', confidence: 78, color: 'bg-color-caution' },
|
||||
{ label: '손상 감지', confidence: 45, color: 'bg-color-warning' },
|
||||
{ label: '화물 분석', confidence: 62, color: 'bg-color-caution' },
|
||||
] : [
|
||||
{ label: '유막 탐지', confidence: 97, color: 'bg-status-green' },
|
||||
{ label: '유종 분류', confidence: 85, color: 'bg-status-green' },
|
||||
{ label: '두께 추정', confidence: 72, color: 'bg-status-yellow' },
|
||||
{ label: '확산 예측', confidence: 68, color: 'bg-status-orange' },
|
||||
{ label: '유막 탐지', confidence: 97, color: 'bg-color-success' },
|
||||
{ label: '유종 분류', confidence: 85, color: 'bg-color-success' },
|
||||
{ label: '두께 추정', confidence: 72, color: 'bg-color-caution' },
|
||||
{ label: '확산 예측', confidence: 68, color: 'bg-color-warning' },
|
||||
]).map((r, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex justify-between text-[9px] mb-0.5">
|
||||
<span className="text-text-3 font-korean">{r.label}</span>
|
||||
<span className="font-mono font-semibold text-text-1">{r.confidence}%</span>
|
||||
<span className="text-fg-disabled font-korean">{r.label}</span>
|
||||
<span className="font-mono font-semibold text-fg">{r.confidence}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-bg-0 rounded-full overflow-hidden">
|
||||
<div className="w-full h-1 bg-bg-base rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${r.color}`} style={{ width: `${r.confidence}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
@ -797,8 +797,8 @@ export function SensorAnalysis() {
|
||||
</div>
|
||||
|
||||
{/* Comparison / Measurements */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📐 3D 측정값</div>
|
||||
<div className="p-2.5 px-3 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">📐 3D 측정값</div>
|
||||
<div className="flex flex-col gap-1 text-[10px]">
|
||||
{(selectedItem.type === 'vessel' ? [
|
||||
['전장 (LOA)', '84.7 m'],
|
||||
@ -813,9 +813,9 @@ export function SensorAnalysis() {
|
||||
['평균 두께', '0.8 mm'],
|
||||
['최대 두께', '3.2 mm'],
|
||||
]).map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-primary-cyan">{v}</span>
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded">
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-color-accent">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -823,10 +823,10 @@ export function SensorAnalysis() {
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="p-2.5 px-3">
|
||||
<button className="w-full py-2.5 rounded-sm text-xs font-bold font-korean text-white border-none cursor-pointer mb-2" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||||
<button className="w-full py-2.5 rounded-sm text-xs font-bold font-korean text-white border-none cursor-pointer mb-2" style={{ background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' }}>
|
||||
📊 상세 보고서 생성
|
||||
</button>
|
||||
<button className="w-full py-2 border border-border bg-bg-3 text-text-2 rounded-sm text-[11px] font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
|
||||
<button className="w-full py-2 border border-stroke bg-bg-card text-fg-sub rounded-sm text-[11px] font-semibold font-korean cursor-pointer hover:bg-bg-surface-hover transition-colors">
|
||||
📥 3D 모델 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -50,7 +50,7 @@ export function WingAI() {
|
||||
>
|
||||
🤖
|
||||
</div>
|
||||
<div className="text-[12px] font-bold font-korean text-text-1">AI 탐지/분석</div>
|
||||
<div className="text-[12px] font-bold font-korean text-fg">AI 탐지/분석</div>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-bold" style={{ background: 'rgba(168,85,247,.12)', color: '#a855f7', border: '1px solid rgba(168,85,247,.25)' }}>WingAI</span>
|
||||
</div>
|
||||
<div className="flex gap-1 h-7">
|
||||
@ -62,7 +62,7 @@ export function WingAI() {
|
||||
style={
|
||||
activeTab === t.id
|
||||
? { background: 'rgba(168,85,247,.12)', borderColor: 'rgba(168,85,247,.3)', color: '#a855f7' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
@ -141,19 +141,19 @@ function DetectPanel() {
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className="bg-bg-2 border border-border rounded-md p-3.5 text-center">
|
||||
<div key={i} className="bg-bg-elevated border border-stroke rounded-md p-3.5 text-center">
|
||||
<div className="text-[22px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
|
||||
<div className="text-[10px] text-text-3 mt-1 font-korean">{s.label}</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-1 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_380px] gap-4">
|
||||
{/* 탐지 결과 지도 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden" style={{ minHeight: 480 }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="text-[11px] font-bold font-korean text-text-1">🎯 선종 불일치 탐지 지도</div>
|
||||
<div className="text-[9px] text-text-3 font-mono">{filtered.length}척 표시</div>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden" style={{ minHeight: 480 }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
|
||||
<div className="text-[11px] font-bold font-korean text-fg">🎯 선종 불일치 탐지 지도</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">{filtered.length}척 표시</div>
|
||||
</div>
|
||||
<div className="relative" style={{ height: 440 }}>
|
||||
<Map
|
||||
@ -187,7 +187,7 @@ function DetectPanel() {
|
||||
]} />
|
||||
</Map>
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-3 left-3 bg-bg-2/90 border border-border rounded px-3 py-2 backdrop-blur-sm">
|
||||
<div className="absolute bottom-3 left-3 bg-bg-elevated/90 border border-stroke rounded px-3 py-2 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{[
|
||||
{ color: '#ef4444', label: '불일치' },
|
||||
@ -197,7 +197,7 @@ function DetectPanel() {
|
||||
].map((l) => (
|
||||
<div key={l.label} className="flex items-center gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ background: l.color }} />
|
||||
<span className="text-[8px] font-korean text-text-3">{l.label}</span>
|
||||
<span className="text-[8px] font-korean text-fg-disabled">{l.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -206,11 +206,11 @@ function DetectPanel() {
|
||||
</div>
|
||||
|
||||
{/* 탐지 목록 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden flex flex-col" style={{ maxHeight: 520 }}>
|
||||
<div className="px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden flex flex-col" style={{ maxHeight: 520 }}>
|
||||
<div className="px-4 py-3 border-b border-stroke shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[11px] font-bold font-korean text-text-1">📋 MMSI 선종 검증 목록</div>
|
||||
<div className="text-[9px] text-text-3 font-mono">{filtered.length}건</div>
|
||||
<div className="text-[11px] font-bold font-korean text-fg">📋 MMSI 선종 검증 목록</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">{filtered.length}건</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{filters.map((f) => (
|
||||
@ -220,7 +220,7 @@ function DetectPanel() {
|
||||
className="px-2 py-0.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={filterStatus === f
|
||||
? { background: 'rgba(168,85,247,.12)', borderColor: 'rgba(168,85,247,.3)', color: '#a855f7' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>
|
||||
{f}
|
||||
@ -233,13 +233,13 @@ function DetectPanel() {
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => setSelectedId(selectedId === d.id ? null : d.id)}
|
||||
className="px-4 py-3 hover:bg-bg-hover/30 transition-colors cursor-pointer"
|
||||
className="px-4 py-3 hover:bg-bg-surface-hover/30 transition-colors cursor-pointer"
|
||||
style={{ background: selectedId === d.id ? 'rgba(168,85,247,.04)' : undefined }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-text-3">{d.id}</span>
|
||||
<span className="text-[10px] font-bold font-korean text-text-1">{d.vesselName}</span>
|
||||
<span className="text-[10px] font-mono text-fg-disabled">{d.id}</span>
|
||||
<span className="text-[10px] font-bold font-korean text-fg">{d.vesselName}</span>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold font-korean" style={statusStyle(d.status)}>
|
||||
{d.status}
|
||||
@ -392,7 +392,7 @@ function ChangeDetectPanel() {
|
||||
|
||||
const sourceStyle = (src: SourceConfig, active = true) => active
|
||||
? { background: `${src.color}18`, color: src.color, border: `1px solid ${src.color}40` }
|
||||
: { background: 'var(--bg3)', color: 'var(--t4)', border: '1px solid var(--bd)', opacity: 0.5 };
|
||||
: { background: 'var(--bg-card)', color: 'var(--t4)', border: '1px solid var(--stroke-default)', opacity: 0.5 };
|
||||
|
||||
const filteredChanges = sourceFilter === 'all'
|
||||
? changes
|
||||
@ -402,7 +402,7 @@ function ChangeDetectPanel() {
|
||||
<div>
|
||||
{/* 레이어 토글 바 */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-[10px] font-bold font-korean text-text-3 shrink-0">오버레이 레이어</div>
|
||||
<div className="text-[10px] font-bold font-korean text-fg-disabled shrink-0">오버레이 레이어</div>
|
||||
<div className="flex gap-1.5">
|
||||
{SOURCES.map((s) => (
|
||||
<button
|
||||
@ -422,15 +422,15 @@ function ChangeDetectPanel() {
|
||||
{/* AS-IS / 현재 시점 비교 뷰 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4" style={{ minHeight: 400 }}>
|
||||
{/* AS-IS 시점 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border" style={{ background: 'rgba(234,179,8,.05)' }}>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke" style={{ background: 'rgba(234,179,8,.05)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold font-korean" style={{ color: '#eab308' }}>AS-IS</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-bold" style={{ background: 'rgba(234,179,8,.12)', color: '#eab308', border: '1px solid rgba(234,179,8,.25)' }}>과거 시점</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" defaultValue="2026-03-14" className="text-[9px] font-mono px-2 py-1 rounded border outline-none" style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }} />
|
||||
<input type="time" defaultValue="14:00" className="text-[9px] font-mono px-2 py-1 rounded border outline-none" style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }} />
|
||||
<input type="date" defaultValue="2026-03-14" className="text-[9px] font-mono px-2 py-1 rounded border outline-none" style={{ background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }} />
|
||||
<input type="time" defaultValue="14:00" className="text-[9px] font-mono px-2 py-1 rounded border outline-none" style={{ background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 활성 레이어 표시 */}
|
||||
@ -443,12 +443,12 @@ function ChangeDetectPanel() {
|
||||
{activeCount === 0 && <span className="text-[9px] text-text-4 font-korean">레이어를 선택하세요</span>}
|
||||
</div>
|
||||
{/* 지도 플레이스홀더 */}
|
||||
<div className="flex items-center justify-center text-text-3" style={{ height: 320 }}>
|
||||
<div className="flex items-center justify-center text-fg-disabled" style={{ height: 320 }}>
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center gap-2 text-2xl mb-3 opacity-30">
|
||||
{SOURCES.filter((s) => layers[s.id]).map((s) => <span key={s.id}>{s.icon}</span>)}
|
||||
</div>
|
||||
<div className="text-[11px] font-korean text-text-3">과거 시점 복합 오버레이</div>
|
||||
<div className="text-[11px] font-korean text-fg-disabled">과거 시점 복합 오버레이</div>
|
||||
<div className="text-[9px] text-text-4 mt-1">
|
||||
{SOURCES.filter((s) => layers[s.id]).map((s) => s.label).join(' + ')} 통합 표시
|
||||
</div>
|
||||
@ -457,13 +457,13 @@ function ChangeDetectPanel() {
|
||||
</div>
|
||||
|
||||
{/* 현재 시점 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border" style={{ background: 'rgba(59,130,246,.05)' }}>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke" style={{ background: 'rgba(59,130,246,.05)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold font-korean" style={{ color: '#3b82f6' }}>현재</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-bold" style={{ background: 'rgba(59,130,246,.12)', color: '#3b82f6', border: '1px solid rgba(59,130,246,.25)' }}>NOW</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-text-3 font-mono">2026-03-16 14:23</span>
|
||||
<span className="text-[9px] text-fg-disabled font-mono">2026-03-16 14:23</span>
|
||||
</div>
|
||||
{/* 활성 레이어 표시 */}
|
||||
<div className="flex gap-1 px-3 py-2 border-b" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
|
||||
@ -475,12 +475,12 @@ function ChangeDetectPanel() {
|
||||
{activeCount === 0 && <span className="text-[9px] text-text-4 font-korean">레이어를 선택하세요</span>}
|
||||
</div>
|
||||
{/* 지도 플레이스홀더 */}
|
||||
<div className="flex items-center justify-center text-text-3" style={{ height: 320 }}>
|
||||
<div className="flex items-center justify-center text-fg-disabled" style={{ height: 320 }}>
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center gap-2 text-2xl mb-3 opacity-30">
|
||||
{SOURCES.filter((s) => layers[s.id]).map((s) => <span key={s.id}>{s.icon}</span>)}
|
||||
</div>
|
||||
<div className="text-[11px] font-korean text-text-3">현재 시점 복합 오버레이</div>
|
||||
<div className="text-[11px] font-korean text-fg-disabled">현재 시점 복합 오버레이</div>
|
||||
<div className="text-[9px] text-text-4 mt-1">
|
||||
{SOURCES.filter((s) => layers[s.id]).map((s) => s.label).join(' + ')} 실시간 통합
|
||||
</div>
|
||||
@ -490,15 +490,15 @@ function ChangeDetectPanel() {
|
||||
</div>
|
||||
|
||||
{/* 복합 변화 감지 목록 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="text-[11px] font-bold font-korean text-text-1">🔄 복합 변화 감지 타임라인</div>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
|
||||
<div className="text-[11px] font-bold font-korean text-fg">🔄 복합 변화 감지 타임라인</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setSourceFilter('all')}
|
||||
className="px-2 py-0.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={sourceFilter === 'all' ? { background: 'rgba(168,85,247,.12)', borderColor: 'rgba(168,85,247,.3)', color: '#a855f7' } : { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }}
|
||||
style={sourceFilter === 'all' ? { background: 'rgba(168,85,247,.12)', borderColor: 'rgba(168,85,247,.3)', color: '#a855f7' } : { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
@ -507,13 +507,13 @@ function ChangeDetectPanel() {
|
||||
key={s.id}
|
||||
onClick={() => setSourceFilter(s.id)}
|
||||
className="px-2 py-0.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={sourceFilter === s.id ? sourceStyle(s) : { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }}
|
||||
style={sourceFilter === s.id ? sourceStyle(s) : { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }}
|
||||
>
|
||||
{s.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-mono">{filteredChanges.length}건</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">{filteredChanges.length}건</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -525,14 +525,14 @@ function ChangeDetectPanel() {
|
||||
{/* 요약 행 */}
|
||||
<div
|
||||
onClick={() => setSelectedChange(isOpen ? null : c.id)}
|
||||
className="grid gap-0 px-4 py-3 items-center hover:bg-bg-hover/30 transition-colors cursor-pointer"
|
||||
className="grid gap-0 px-4 py-3 items-center hover:bg-bg-surface-hover/30 transition-colors cursor-pointer"
|
||||
style={{ gridTemplateColumns: '52px 1fr 100px 130px 60px', background: isOpen ? 'rgba(168,85,247,.04)' : undefined }}
|
||||
>
|
||||
<div className="text-[10px] font-mono text-text-3">{c.id}</div>
|
||||
<div className="text-[10px] font-mono text-fg-disabled">{c.id}</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-text-1 font-korean">{c.area}</span>
|
||||
<span className="text-[10px] font-semibold font-korean" style={{ color: 'var(--t3)' }}>{c.type}</span>
|
||||
<span className="text-[11px] font-semibold text-fg font-korean">{c.area}</span>
|
||||
<span className="text-[10px] font-semibold font-korean" style={{ color: 'var(--fg-disabled)' }}>{c.type}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-4 font-korean mt-0.5">{c.detail}</div>
|
||||
</div>
|
||||
@ -542,7 +542,7 @@ function ChangeDetectPanel() {
|
||||
return <span key={sid} className="px-1.5 py-0.5 rounded text-[8px] font-bold" style={sourceStyle(cfg)}>{cfg.icon}</span>;
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-text-2">
|
||||
<div className="text-[9px] font-mono text-fg-sub">
|
||||
<span style={{ color: '#eab308' }}>{c.date1} {c.time1}</span>
|
||||
<span className="text-text-4 mx-1">→</span>
|
||||
<span style={{ color: '#3b82f6' }}>{c.date2} {c.time2}</span>
|
||||
@ -575,8 +575,8 @@ function ChangeDetectPanel() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={sourceStyle(cfg)}>{cfg.icon} {cfg.label}</span>
|
||||
</div>
|
||||
<div className="text-[9px] font-korean text-text-3">{c.asIsDetail[sid] || '-'}</div>
|
||||
<div className="text-[9px] font-korean text-text-1 font-semibold">{c.nowDetail[sid] || '-'}</div>
|
||||
<div className="text-[9px] font-korean text-fg-disabled">{c.asIsDetail[sid] || '-'}</div>
|
||||
<div className="text-[9px] font-korean text-fg font-semibold">{c.nowDetail[sid] || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -872,20 +872,20 @@ function AoiPanel() {
|
||||
{ value: String(warningCount), label: '주의', color: '#eab308' },
|
||||
{ value: String(totalAlerts), label: '미확인 알림', color: '#a855f7' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="bg-bg-2 border border-border rounded-md p-3 text-center">
|
||||
<div key={i} className="bg-bg-elevated border border-stroke rounded-md p-3 text-center">
|
||||
<div className="text-[20px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
|
||||
<div className="text-[10px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_360px] gap-4">
|
||||
{/* 지도 영역 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden" style={{ minHeight: 520 }}>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden" style={{ minHeight: 520 }}>
|
||||
{/* 지도 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[11px] font-bold font-korean text-text-1">📍 연안 감시 구역</div>
|
||||
<div className="text-[11px] font-bold font-korean text-fg">📍 연안 감시 구역</div>
|
||||
{isDrawing && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-bold animate-pulse" style={{ background: 'rgba(168,85,247,.15)', color: '#a855f7', border: '1px solid rgba(168,85,247,.3)' }}>
|
||||
드로잉 모드 · 지도를 클릭하여 꼭짓점 추가 ({drawingPoints.length}점)
|
||||
@ -907,14 +907,14 @@ function AoiPanel() {
|
||||
onClick={() => setDrawingPoints((p) => p.slice(0, -1))}
|
||||
disabled={drawingPoints.length === 0}
|
||||
className="px-2 h-6 rounded-sm text-[9px] font-semibold cursor-pointer font-korean border disabled:opacity-40"
|
||||
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }}
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }}
|
||||
>
|
||||
되돌리기
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDrawing}
|
||||
className="px-2 h-6 rounded-sm text-[9px] font-semibold cursor-pointer font-korean border"
|
||||
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: '#ef4444' }}
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: '#ef4444' }}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
@ -949,13 +949,13 @@ function AoiPanel() {
|
||||
<div className="flex flex-col gap-3" style={{ maxHeight: 520, overflowY: 'auto' }}>
|
||||
{/* 등록 폼: 이름만 입력 → 바로 등록 */}
|
||||
{showForm && drawingPoints.length >= 3 && (
|
||||
<div className="bg-bg-2 border rounded-md overflow-hidden" style={{ borderColor: 'rgba(168,85,247,.3)' }}>
|
||||
<div className="bg-bg-elevated border rounded-md overflow-hidden" style={{ borderColor: 'rgba(168,85,247,.3)' }}>
|
||||
<div className="px-4 py-2.5 border-b font-korean" style={{ borderColor: 'rgba(168,85,247,.15)', background: 'rgba(168,85,247,.05)' }}>
|
||||
<div className="text-[11px] font-bold" style={{ color: '#a855f7' }}>새 감시 구역 등록</div>
|
||||
<div className="text-[9px] text-text-4 mt-0.5">폴리곤 {drawingPoints.length}점 설정 완료</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<label className="text-[9px] font-bold text-text-3 font-korean block mb-1.5">구역 이름</label>
|
||||
<label className="text-[9px] font-bold text-fg-disabled font-korean block mb-1.5">구역 이름</label>
|
||||
<input
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
@ -977,7 +977,7 @@ function AoiPanel() {
|
||||
<button
|
||||
onClick={cancelDrawing}
|
||||
className="px-4 py-2 rounded text-[10px] font-bold cursor-pointer font-korean border"
|
||||
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }}
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
@ -991,11 +991,11 @@ function AoiPanel() {
|
||||
const z = zones.find((zone) => zone.id === selectedZone);
|
||||
if (!z) return null;
|
||||
return (
|
||||
<div className="bg-bg-2 border rounded-md overflow-hidden" style={{ borderColor: `${z.color}40` }}>
|
||||
<div className="bg-bg-elevated border rounded-md overflow-hidden" style={{ borderColor: `${z.color}40` }}>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b" style={{ borderColor: `${z.color}20`, background: `${z.color}08` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-text-3">{z.id}</span>
|
||||
<span className="text-[11px] font-bold font-korean text-text-1">{z.name}</span>
|
||||
<span className="text-[10px] font-mono text-fg-disabled">{z.id}</span>
|
||||
<span className="text-[11px] font-bold font-korean text-fg">{z.name}</span>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold font-korean" style={statusStyle(z.status)}>
|
||||
{z.status}
|
||||
@ -1004,7 +1004,7 @@ function AoiPanel() {
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{/* 감시 주기 */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold text-text-3 font-korean block mb-1.5">감시 주기</label>
|
||||
<label className="text-[9px] font-bold text-fg-disabled font-korean block mb-1.5">감시 주기</label>
|
||||
<div className="flex gap-1">
|
||||
{INTERVAL_OPTIONS.map((iv) => (
|
||||
<button
|
||||
@ -1013,7 +1013,7 @@ function AoiPanel() {
|
||||
className="px-2.5 py-1 rounded text-[9px] font-bold font-mono cursor-pointer border transition-colors"
|
||||
style={z.interval === iv
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: '#3b82f6' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>
|
||||
{iv}
|
||||
@ -1024,7 +1024,7 @@ function AoiPanel() {
|
||||
|
||||
{/* 모니터링 방법 (정보원 소스) */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold text-text-3 font-korean block mb-1.5">모니터링 방법</label>
|
||||
<label className="text-[9px] font-bold text-fg-disabled font-korean block mb-1.5">모니터링 방법</label>
|
||||
<div className="space-y-1.5">
|
||||
{MONITOR_SOURCES.map((src) => {
|
||||
const active = z.sources.includes(src.id);
|
||||
@ -1035,7 +1035,7 @@ function AoiPanel() {
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded border cursor-pointer transition-all text-left"
|
||||
style={active
|
||||
? { background: `${src.color}10`, borderColor: `${src.color}40`, color: src.color }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t4)', opacity: 0.6 }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--t4)', opacity: 0.6 }
|
||||
}
|
||||
>
|
||||
<span className="text-sm shrink-0">{active ? '◉' : '○'}</span>
|
||||
@ -1060,7 +1060,7 @@ function AoiPanel() {
|
||||
className="px-3 py-1.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={z.monitoring
|
||||
? { background: 'rgba(34,197,94,.12)', borderColor: 'rgba(34,197,94,.25)', color: '#22c55e' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-disabled)' }
|
||||
}
|
||||
>
|
||||
{z.monitoring ? '◉ 감시 중' : '○ 일시정지'}
|
||||
@ -1080,23 +1080,23 @@ function AoiPanel() {
|
||||
})()}
|
||||
|
||||
{/* 감시 구역 목록 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden flex-1">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<div className="text-[11px] font-bold font-korean text-text-1">📋 등록된 감시 구역</div>
|
||||
<div className="text-[9px] text-text-3 font-mono">{zones.length}건</div>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden flex-1">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke">
|
||||
<div className="text-[11px] font-bold font-korean text-fg">📋 등록된 감시 구역</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">{zones.length}건</div>
|
||||
</div>
|
||||
<div className="divide-y" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
|
||||
{zones.map((z) => (
|
||||
<div
|
||||
key={z.id}
|
||||
onClick={() => setSelectedZone(selectedZone === z.id ? null : z.id)}
|
||||
className="px-4 py-2.5 hover:bg-bg-hover/30 transition-colors cursor-pointer"
|
||||
className="px-4 py-2.5 hover:bg-bg-surface-hover/30 transition-colors cursor-pointer"
|
||||
style={{ background: selectedZone === z.id ? `${z.color}08` : undefined, borderLeft: `3px solid ${z.monitoring ? z.color : 'transparent'}` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-text-3">{z.id}</span>
|
||||
<span className="text-[11px] font-semibold text-text-1 font-korean">{z.name}</span>
|
||||
<span className="text-[10px] font-mono text-fg-disabled">{z.id}</span>
|
||||
<span className="text-[11px] font-semibold text-fg font-korean">{z.name}</span>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold font-korean" style={statusStyle(z.status)}>
|
||||
{z.status}
|
||||
|
||||
@ -79,7 +79,7 @@ function AssetManagement() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-text-3 text-sm font-korean">방제자산 데이터를 불러오는 중...</div>
|
||||
<div className="text-fg-disabled text-sm font-korean">방제자산 데이터를 불러오는 중...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -87,14 +87,14 @@ function AssetManagement() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* View Switcher & Filters */}
|
||||
<div className="flex items-center justify-between mb-3 pb-3 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-3 pb-3 border-b border-stroke">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
📋 방제자산리스트
|
||||
@ -103,8 +103,8 @@ function AssetManagement() {
|
||||
onClick={() => setViewMode('map')}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||||
viewMode === 'map'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
🗺 지도 보기
|
||||
@ -153,7 +153,7 @@ function AssetManagement() {
|
||||
|
||||
{viewMode === 'list' ? (
|
||||
/* ── LIST VIEW ── */
|
||||
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
|
||||
<div className="flex-1 bg-bg-card border border-stroke rounded-md overflow-hidden flex flex-col">
|
||||
<div className="flex-1">
|
||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
@ -170,12 +170,12 @@ function AssetManagement() {
|
||||
<col style={{ width: '5%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-0">
|
||||
<tr className="border-b border-stroke bg-bg-base">
|
||||
{['번호', '유형', '관할청', '기관명', '주소', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '총자산'].map((h, i) => {
|
||||
const equipColMap: Record<string, number> = { vessel: 5, skimmer: 6, pump: 7, vehicle: 8, sprayer: 9 }
|
||||
const isHighlight = equipFilter !== 'all' && equipColMap[equipFilter] === i
|
||||
return (
|
||||
<th key={i} className={`px-2.5 py-2.5 text-[10px] font-bold font-korean border-b border-border ${[0,5,6,7,8,9,10].includes(i) ? 'text-center' : ''} ${isHighlight ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||
<th key={i} className={`px-2.5 py-2.5 text-[10px] font-bold font-korean border-b border-stroke ${[0,5,6,7,8,9,10].includes(i) ? 'text-center' : ''} ${isHighlight ? 'text-color-accent bg-color-accent/5' : 'text-fg-sub'}`}>
|
||||
{h}
|
||||
</th>
|
||||
)
|
||||
@ -186,7 +186,7 @@ function AssetManagement() {
|
||||
{paged.map((org, idx) => (
|
||||
<tr
|
||||
key={org.id}
|
||||
className={`border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] cursor-pointer transition-colors ${
|
||||
className={`border-b border-stroke/50 hover:bg-[rgba(255,255,255,0.02)] cursor-pointer transition-colors ${
|
||||
selectedOrg?.id === org.id ? 'bg-[rgba(6,182,212,0.03)]' : ''
|
||||
}`}
|
||||
onClick={() => { handleSelectOrg(org); setViewMode('map') }}
|
||||
@ -196,14 +196,14 @@ function AssetManagement() {
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>{org.type}</span>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold font-korean">{regionShort(org.jurisdiction)}</td>
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold text-primary-cyan font-korean cursor-pointer truncate">{org.name}</td>
|
||||
<td className="px-2.5 py-2 text-[10px] text-text-3 font-korean truncate">{org.address}</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] font-semibold ${equipFilter === 'vessel' ? 'text-primary-cyan bg-primary-cyan/5' : ''}`}>{org.vessel}척</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'skimmer' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : ''}`}>{org.skimmer}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'pump' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : ''}`}>{org.pump}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'vehicle' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : ''}`}>{org.vehicle}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'sprayer' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : ''}`}>{org.sprayer}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-bold text-primary-cyan font-mono text-[10px]">{org.totalAssets}</td>
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold text-color-accent font-korean cursor-pointer truncate">{org.name}</td>
|
||||
<td className="px-2.5 py-2 text-[10px] text-fg-disabled font-korean truncate">{org.address}</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] font-semibold ${equipFilter === 'vessel' ? 'text-color-accent bg-color-accent/5' : ''}`}>{org.vessel}척</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'skimmer' ? 'text-color-accent font-semibold bg-color-accent/5' : ''}`}>{org.skimmer}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'pump' ? 'text-color-accent font-semibold bg-color-accent/5' : ''}`}>{org.pump}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'vehicle' ? 'text-color-accent font-semibold bg-color-accent/5' : ''}`}>{org.vehicle}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'sprayer' ? 'text-color-accent font-semibold bg-color-accent/5' : ''}`}>{org.sprayer}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-bold text-color-accent font-mono text-[10px]">{org.totalAssets}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -211,8 +211,8 @@ function AssetManagement() {
|
||||
</div>
|
||||
|
||||
{/* Totals Summary */}
|
||||
<div className="flex items-center justify-end gap-4 px-4 py-2 border-t border-border bg-bg-0/80">
|
||||
<span className="text-[10px] text-text-3 font-korean font-semibold mr-auto">
|
||||
<div className="flex items-center justify-end gap-4 px-4 py-2 border-t border-stroke bg-bg-base/80">
|
||||
<span className="text-[10px] text-fg-disabled font-korean font-semibold mr-auto">
|
||||
합계 ({filtered.length}개 기관)
|
||||
</span>
|
||||
{[
|
||||
@ -225,30 +225,30 @@ function AssetManagement() {
|
||||
].map((t) => {
|
||||
const isActive = equipFilter === t.key || t.key === 'total'
|
||||
return (
|
||||
<div key={t.key} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${equipFilter === t.key ? 'bg-primary-cyan/10' : ''}`}>
|
||||
<span className={`text-[9px] font-korean ${isActive ? 'text-primary-cyan' : 'text-text-3'}`}>{t.label}</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${isActive ? 'text-primary-cyan' : 'text-text-1'}`}>{t.value.toLocaleString()}{t.unit}</span>
|
||||
<div key={t.key} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${equipFilter === t.key ? 'bg-color-accent/10' : ''}`}>
|
||||
<span className={`text-[9px] font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}>{t.label}</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}>{t.value.toLocaleString()}{t.unit}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-border bg-bg-0">
|
||||
<span className="text-[10px] text-text-3 font-korean">
|
||||
전체 <span className="font-semibold text-text-2">{filtered.length}</span>건 중{' '}
|
||||
<span className="font-semibold text-text-2">{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}</span>
|
||||
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-stroke bg-bg-base">
|
||||
<span className="text-[10px] text-fg-disabled font-korean">
|
||||
전체 <span className="font-semibold text-fg-sub">{filtered.length}</span>건 중{' '}
|
||||
<span className="font-semibold text-fg-sub">{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={safePage <= 1}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>«</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage <= 1}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>‹</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||||
<button
|
||||
@ -256,27 +256,27 @@ function AssetManagement() {
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className={`w-6 h-6 text-[10px] font-bold rounded transition-colors cursor-pointer ${
|
||||
p === safePage
|
||||
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
|
||||
: 'border border-border bg-bg-3 text-text-3 hover:bg-bg-hover'
|
||||
? 'bg-color-accent/20 text-color-accent border border-color-accent/40'
|
||||
: 'border border-stroke bg-bg-card text-fg-disabled hover:bg-bg-surface-hover'
|
||||
}`}
|
||||
>{p}</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage >= totalPages}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>›</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={safePage >= totalPages}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── MAP VIEW ── */
|
||||
<div className="flex-1 flex overflow-hidden rounded-md border border-border">
|
||||
<div className="flex-1 flex overflow-hidden rounded-md border border-stroke">
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<AssetMap
|
||||
@ -290,24 +290,24 @@ function AssetManagement() {
|
||||
|
||||
{/* Right Detail Panel */}
|
||||
{selectedOrg && (
|
||||
<aside className="w-[340px] min-w-[340px] bg-bg-1 border-l border-border flex flex-col">
|
||||
<aside className="w-[340px] min-w-[340px] bg-bg-surface border-l border-stroke flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="p-4 border-b border-stroke">
|
||||
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
|
||||
<div className="text-[11px] text-text-2 font-semibold font-korean mb-1">{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}</div>
|
||||
<div className="text-[11px] text-text-3 font-korean">{selectedOrg.address}</div>
|
||||
<div className="text-[11px] text-fg-sub font-semibold font-korean mb-1">{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean">{selectedOrg.address}</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
<div className="flex border-b border-stroke">
|
||||
{(['equip', 'material', 'contact'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setDetailTab(t)}
|
||||
className={`flex-1 py-2.5 text-center text-[11px] font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
|
||||
detailTab === t
|
||||
? 'text-primary-cyan border-primary-cyan'
|
||||
: 'text-text-3 border-transparent hover:text-text-2'
|
||||
? 'text-color-accent border-color-accent'
|
||||
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{t === 'equip' ? '장비' : t === 'material' ? '자재' : '연락처'}
|
||||
@ -324,9 +324,9 @@ function AssetManagement() {
|
||||
{ value: `${selectedOrg.skimmer}대`, label: '유회수기' },
|
||||
{ value: String(selectedOrg.totalAssets), label: '총 자산' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="bg-bg-3 border border-border rounded-sm p-2.5 text-center">
|
||||
<div className="text-lg font-bold text-primary-cyan font-mono">{s.value}</div>
|
||||
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
||||
<div key={i} className="bg-bg-card border border-stroke rounded-sm p-2.5 text-center">
|
||||
<div className="text-lg font-bold text-color-accent font-mono">{s.value}</div>
|
||||
<div className="text-[9px] text-fg-disabled mt-0.5 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -342,15 +342,15 @@ function AssetManagement() {
|
||||
}
|
||||
const unit = unitMap[cat.category] || '개'
|
||||
return (
|
||||
<div key={ci} className="flex items-center justify-between px-2.5 py-2 bg-bg-3 border border-border rounded-sm hover:bg-bg-hover transition-colors">
|
||||
<div key={ci} className="flex items-center justify-between px-2.5 py-2 bg-bg-card border border-stroke rounded-sm hover:bg-bg-surface-hover transition-colors">
|
||||
<span className="text-[11px] font-semibold flex items-center gap-1.5 font-korean">
|
||||
{cat.icon} {cat.category}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold font-mono"><span className="text-primary-cyan">{cat.count}</span><span className="text-text-3 font-normal ml-0.5">{unit}</span></span>
|
||||
<span className="text-[11px] font-bold font-mono"><span className="text-color-accent">{cat.count}</span><span className="text-fg-disabled font-normal ml-0.5">{unit}</span></span>
|
||||
</div>
|
||||
)
|
||||
}) : (
|
||||
<div className="text-center text-text-3 text-xs py-8 font-korean">상세 장비 데이터가 없습니다.</div>
|
||||
<div className="text-center text-fg-disabled text-xs py-8 font-korean">상세 장비 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -365,9 +365,9 @@ function AssetManagement() {
|
||||
['살포장치', `${selectedOrg.sprayer}대`],
|
||||
['총 자산', `${selectedOrg.totalAssets}건`],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2.5 py-2 bg-bg-0 rounded text-[11px]">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-text-1">{v}</span>
|
||||
<div key={i} className="flex justify-between px-2.5 py-2 bg-bg-base rounded text-[11px]">
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-fg">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -376,8 +376,8 @@ function AssetManagement() {
|
||||
{detailTab === 'contact' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 기관 기본 정보 */}
|
||||
<div className="bg-bg-3 border border-border rounded-sm p-3">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 font-korean">기관 정보</div>
|
||||
<div className="bg-bg-card border border-stroke rounded-sm p-3">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 font-korean">기관 정보</div>
|
||||
{[
|
||||
['기관명', selectedOrg.name],
|
||||
['유형', selectedOrg.type],
|
||||
@ -385,17 +385,17 @@ function AssetManagement() {
|
||||
['주소', selectedOrg.address],
|
||||
...(selectedOrg.phone ? [['대표 연락처', selectedOrg.phone]] : []),
|
||||
].map(([k, v], j) => (
|
||||
<div key={j} className="flex justify-between py-1.5 text-[11px] border-b border-border/30 last:border-b-0">
|
||||
<span className="text-text-3 font-korean shrink-0 mr-2">{k}</span>
|
||||
<span className={`text-text-1 text-right ${k === '대표 연락처' ? 'font-mono font-semibold text-primary-cyan' : 'font-korean'}`}>{v}</span>
|
||||
<div key={j} className="flex justify-between py-1.5 text-[11px] border-b border-stroke/30 last:border-b-0">
|
||||
<span className="text-fg-disabled font-korean shrink-0 mr-2">{k}</span>
|
||||
<span className={`text-fg text-right ${k === '대표 연락처' ? 'font-mono font-semibold text-color-accent' : 'font-korean'}`}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 담당자 목록 */}
|
||||
{selectedOrg.contacts.length > 0 && (
|
||||
<div className="bg-bg-3 border border-border rounded-sm p-3">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 font-korean">담당자</div>
|
||||
<div className="bg-bg-card border border-stroke rounded-sm p-3">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 font-korean">담당자</div>
|
||||
{selectedOrg.contacts.map((c, i) => (
|
||||
<div key={i} className="mb-2.5 last:mb-0">
|
||||
{[
|
||||
@ -403,12 +403,12 @@ function AssetManagement() {
|
||||
['담당자', c.name],
|
||||
['연락처', c.phone],
|
||||
].filter(([, v]) => v).map(([k, v], j) => (
|
||||
<div key={j} className="flex justify-between py-1.5 text-[11px] border-b border-border/30 last:border-b-0">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className={`text-text-1 ${k === '연락처' ? 'font-mono font-semibold text-primary-cyan' : 'font-korean'}`}>{v}</span>
|
||||
<div key={j} className="flex justify-between py-1.5 text-[11px] border-b border-stroke/30 last:border-b-0">
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className={`text-fg ${k === '연락처' ? 'font-mono font-semibold text-color-accent' : 'font-korean'}`}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
{i < selectedOrg.contacts.length - 1 && <div className="border-t border-border mt-1" />}
|
||||
{i < selectedOrg.contacts.length - 1 && <div className="border-t border-stroke mt-1" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -418,11 +418,11 @@ function AssetManagement() {
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="p-3.5 border-t border-border flex gap-2">
|
||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }} >
|
||||
<div className="p-3.5 border-t border-stroke flex gap-2">
|
||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' }} >
|
||||
📥 다운로드
|
||||
</button>
|
||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">
|
||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">
|
||||
✏ 수정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -136,8 +136,8 @@ function AssetMap({
|
||||
onClick={() => onRegionFilterChange(r.value)}
|
||||
className={`px-2.5 py-1.5 text-[10px] font-bold rounded font-korean transition-colors ${
|
||||
regionFilter === r.value
|
||||
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
|
||||
: 'bg-bg-0/80 text-text-2 border border-border hover:bg-bg-hover/80'
|
||||
? 'bg-color-accent/20 text-color-accent border border-color-accent/40'
|
||||
: 'bg-bg-base/80 text-fg-sub border border-stroke hover:bg-bg-surface-hover/80'
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
@ -146,8 +146,8 @@ function AssetMap({
|
||||
</div>
|
||||
|
||||
{/* Legend overlay */}
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-0/90 border border-border rounded-sm p-2.5 backdrop-blur-sm">
|
||||
<div className="text-[9px] text-text-3 font-bold mb-1.5 font-korean">범례</div>
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-base/90 border border-stroke rounded-sm p-2.5 backdrop-blur-sm">
|
||||
<div className="text-[9px] text-fg-disabled font-bold mb-1.5 font-korean">범례</div>
|
||||
{[
|
||||
{ color: '#06b6d4', label: '해경관할' },
|
||||
{ color: '#3b82f6', label: '해경경찰서' },
|
||||
@ -163,7 +163,7 @@ function AssetMap({
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 mb-0.5 last:mb-0">
|
||||
<span className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0" style={{ background: item.color }} />
|
||||
<span className="text-[10px] text-text-2 font-korean">{item.label}</span>
|
||||
<span className="text-[10px] text-fg-sub font-korean">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -18,7 +18,7 @@ interface TheorySection {
|
||||
|
||||
const THEORY_SECTIONS: TheorySection[] = [
|
||||
{
|
||||
icon: '🚢', title: '방제선 성능 기준', color: 'var(--blue)', bgTint: 'rgba(59,130,246,.08)',
|
||||
icon: '🚢', title: '방제선 성능 기준', color: 'var(--color-info)', bgTint: 'rgba(59,130,246,.08)',
|
||||
items: [
|
||||
{
|
||||
title: '해양경찰청 방제선 성능기준 고시',
|
||||
@ -53,7 +53,7 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '⚙️', title: '방제자원 배치·동원 이론', color: 'var(--purple)', bgTint: 'rgba(168,85,247,.08)',
|
||||
icon: '⚙️', title: '방제자원 배치·동원 이론', color: 'var(--color-tertiary)', bgTint: 'rgba(168,85,247,.08)',
|
||||
dividerAfter: 2, dividerLabel: '📐 최적화 수리모델 참고문헌',
|
||||
items: [
|
||||
{
|
||||
@ -78,9 +78,9 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
desc: '혼합정수계획법으로 응급 방제자원 거점 위치 선택 + 자원 할당 동시 최적화. 비용·응답시간 트레이드오프 파레토 분석.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: 'MIP 수리모델', color: 'var(--purple)' },
|
||||
{ label: '자원 위치 선택', color: 'var(--blue)' },
|
||||
{ label: '북극해 적용', color: 'var(--cyan)' },
|
||||
{ label: 'MIP 수리모델', color: 'var(--color-tertiary)' },
|
||||
{ label: '자원 위치 선택', color: 'var(--color-info)' },
|
||||
{ label: '북극해 적용', color: 'var(--color-accent)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -89,7 +89,7 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
desc: 'GA(유전알고리즘)로 방제자원 배치 최적화 및 시뮬레이션 분포도 분석. 국내 해역 실정에 맞는 자원 배치 패턴 도출.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: 'GA 메타휴리스틱', color: 'var(--purple)' },
|
||||
{ label: 'GA 메타휴리스틱', color: 'var(--color-tertiary)' },
|
||||
{ label: '국내 연구', color: 'var(--green, #22c55e)' },
|
||||
{ label: '배치 분포도 분석', color: 'var(--boom, #f59e0b)' },
|
||||
],
|
||||
@ -100,8 +100,8 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
desc: '확률적 MILP 2단계 프레임워크로 불확실성 포함 최적 자원 배치. 환경민감구역 가중치 반영.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: '확률적 MILP', color: 'var(--purple)' },
|
||||
{ label: '2단계 최적화', color: 'var(--blue)' },
|
||||
{ label: '확률적 MILP', color: 'var(--color-tertiary)' },
|
||||
{ label: '2단계 최적화', color: 'var(--color-info)' },
|
||||
{ label: '환경민감구역', color: 'var(--green, #22c55e)' },
|
||||
],
|
||||
},
|
||||
@ -111,7 +111,7 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
desc: '동적 최적화(MINLP/MILP) 프레임워크로 오일스필 대응 스케줄링 + 오일 풍화·거동 물리모델 통합.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: 'MINLP 동적 최적화', color: 'var(--purple)' },
|
||||
{ label: 'MINLP 동적 최적화', color: 'var(--color-tertiary)' },
|
||||
{ label: '오일 풍화 모델 통합', color: 'var(--boom, #f59e0b)' },
|
||||
],
|
||||
},
|
||||
@ -135,9 +135,9 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
]
|
||||
|
||||
const TAG_COLORS: Record<string, { bg: string; bd: string; fg: string }> = {
|
||||
'var(--purple)': { bg: 'rgba(168,85,247,0.08)', bd: 'rgba(168,85,247,0.2)', fg: '#a855f7' },
|
||||
'var(--blue)': { bg: 'rgba(59,130,246,0.08)', bd: 'rgba(59,130,246,0.2)', fg: '#3b82f6' },
|
||||
'var(--cyan)': { bg: 'rgba(6,182,212,0.08)', bd: 'rgba(6,182,212,0.2)', fg: '#06b6d4' },
|
||||
'var(--color-tertiary)': { bg: 'rgba(168,85,247,0.08)', bd: 'rgba(168,85,247,0.2)', fg: '#a855f7' },
|
||||
'var(--color-info)': { bg: 'rgba(59,130,246,0.08)', bd: 'rgba(59,130,246,0.2)', fg: '#3b82f6' },
|
||||
'var(--color-accent)': { bg: 'rgba(6,182,212,0.08)', bd: 'rgba(6,182,212,0.2)', fg: '#06b6d4' },
|
||||
'var(--green, #22c55e)': { bg: 'rgba(34,197,94,0.08)', bd: 'rgba(34,197,94,0.2)', fg: '#22c55e' },
|
||||
'var(--boom, #f59e0b)': { bg: 'rgba(245,158,11,0.08)', bd: 'rgba(245,158,11,0.2)', fg: '#f59e0b' },
|
||||
}
|
||||
@ -145,9 +145,9 @@ const TAG_COLORS: Record<string, { bg: string; bd: string; fg: string }> = {
|
||||
function TheoryCard({ section }: { section: TheorySection }) {
|
||||
const badgeBg = section.bgTint.replace(/[\d.]+\)$/, '0.15)')
|
||||
return (
|
||||
<div className="bg-bg-3 border border-border rounded-md overflow-hidden">
|
||||
<div className="bg-bg-card border border-stroke rounded-md overflow-hidden">
|
||||
{/* Section Header */}
|
||||
<div className="px-4 py-3 border-b border-border flex items-center gap-2" style={{ background: section.bgTint }}>
|
||||
<div className="px-4 py-3 border-b border-stroke flex items-center gap-2" style={{ background: section.bgTint }}>
|
||||
<span className="text-sm">{section.icon}</span>
|
||||
<span className="text-xs font-bold" style={{ color: section.color }}>
|
||||
{section.title}
|
||||
@ -160,13 +160,13 @@ function TheoryCard({ section }: { section: TheorySection }) {
|
||||
<div key={i}>
|
||||
{/* Divider */}
|
||||
{section.dividerAfter !== undefined && i === section.dividerAfter + 1 && (
|
||||
<div className="mt-1 mb-3 pt-2" style={{ borderTop: '1px dashed var(--bd)' }}>
|
||||
<div className="mt-1 mb-3 pt-2" style={{ borderTop: '1px dashed var(--stroke-default)' }}>
|
||||
<div className="text-[8px] font-bold mb-1.5 opacity-70" style={{ color: section.color }}>
|
||||
{section.dividerLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2 px-2.5 py-2 bg-bg-0 rounded-md" style={{
|
||||
<div className="grid gap-2 px-2.5 py-2 bg-bg-base rounded-md" style={{
|
||||
gridTemplateColumns: '24px 1fr',
|
||||
borderLeft: item.highlight ? `2px solid ${section.color}` : undefined,
|
||||
}}>
|
||||
@ -183,7 +183,7 @@ function TheoryCard({ section }: { section: TheorySection }) {
|
||||
<div className="font-bold mb-0.5">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="text-text-3 leading-[1.6]">
|
||||
<div className="text-fg-disabled leading-[1.6]">
|
||||
{item.source}
|
||||
</div>
|
||||
{/* Tags */}
|
||||
@ -201,7 +201,7 @@ function TheoryCard({ section }: { section: TheorySection }) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 text-text-2">
|
||||
<div className="mt-0.5 text-fg-sub">
|
||||
{item.desc}
|
||||
</div>
|
||||
</div>
|
||||
@ -219,7 +219,7 @@ function AssetTheory() {
|
||||
<div className="text-[18px] font-bold mb-1">
|
||||
📚 방제자원 이론
|
||||
</div>
|
||||
<div className="text-xs text-text-3 mb-6">
|
||||
<div className="text-xs text-fg-disabled mb-6">
|
||||
방제자산 운용 기준·성능 이론 및 관련 법령·고시 근거 문헌
|
||||
</div>
|
||||
|
||||
|
||||
@ -25,18 +25,18 @@ function AssetUpload() {
|
||||
<div className="text-[13px] font-bold mb-3.5 font-korean">📤 자산 데이터 업로드</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div className="border-2 border-dashed border-border-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-primary-cyan/40 transition-colors">
|
||||
<div className="border-2 border-dashed border-stroke-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-color-accent/40 transition-colors">
|
||||
<div className="text-4xl mb-2.5 opacity-50">📁</div>
|
||||
<div className="text-sm font-semibold mb-1.5 font-korean">파일을 드래그하거나 클릭하여 업로드</div>
|
||||
<div className="text-[11px] text-text-3 mb-4 font-korean">엑셀(.xlsx), CSV 파일 지원 · 최대 10MB</div>
|
||||
<button className="px-7 py-2.5 text-[13px] font-semibold rounded-sm text-white border-none cursor-pointer font-korean" style={{ background: 'linear-gradient(135deg, var(--blue), #2563eb)' }}>
|
||||
<div className="text-[11px] text-fg-disabled mb-4 font-korean">엑셀(.xlsx), CSV 파일 지원 · 최대 10MB</div>
|
||||
<button className="px-7 py-2.5 text-[13px] font-semibold rounded-sm text-white border-none cursor-pointer font-korean" style={{ background: 'linear-gradient(135deg, var(--color-info), #2563eb)' }}>
|
||||
파일 선택
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Asset Classification */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">자산 분류</label>
|
||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">자산 분류</label>
|
||||
<select className="prd-i w-full">
|
||||
<option>장비자재</option>
|
||||
<option>방제선</option>
|
||||
@ -49,7 +49,7 @@ function AssetUpload() {
|
||||
|
||||
{/* Jurisdiction */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">업로드 대상 관할</label>
|
||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">업로드 대상 관할</label>
|
||||
<select className="prd-i w-full">
|
||||
<option>남해청 - 여수서</option>
|
||||
<option>남해청 - 부산서</option>
|
||||
@ -63,8 +63,8 @@ function AssetUpload() {
|
||||
|
||||
{/* Upload Mode */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">업로드 방식</label>
|
||||
<div className="flex gap-4 text-xs text-text-2 font-korean">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">업로드 방식</label>
|
||||
<div className="flex gap-4 text-xs text-fg-sub font-korean">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" checked={uploadMode === 'add'} onChange={() => setUploadMode('add')} className="accent-primary-blue" />
|
||||
추가 (기존 + 신규)
|
||||
@ -81,10 +81,10 @@ function AssetUpload() {
|
||||
onClick={handleUpload}
|
||||
className={`w-full py-3.5 rounded-sm text-sm font-bold font-korean border-none cursor-pointer transition-all ${
|
||||
uploaded
|
||||
? 'bg-[rgba(34,197,94,0.2)] text-status-green border border-status-green'
|
||||
? 'bg-[rgba(34,197,94,0.2)] text-color-success border border-status-green'
|
||||
: 'text-white'
|
||||
}`}
|
||||
style={!uploaded ? { background: 'linear-gradient(135deg, var(--blue), #2563eb)' } : undefined}
|
||||
style={!uploaded ? { background: 'linear-gradient(135deg, var(--color-info), #2563eb)' } : undefined}
|
||||
>
|
||||
{uploaded ? '✅ 업로드 완료!' : '📤 업로드 실행'}
|
||||
</button>
|
||||
@ -96,16 +96,16 @@ function AssetUpload() {
|
||||
<div className="text-[13px] font-bold mb-3.5 font-korean">🔐 수정 권한 체계</div>
|
||||
<div className="flex flex-col gap-2 mb-7">
|
||||
{[
|
||||
{ icon: '👑', role: '본청 관리자', desc: '전체 자산 조회·수정·삭제·업로드', color: 'text-status-red', bg: 'rgba(239,68,68,0.15)' },
|
||||
{ icon: '🏛', role: '지방청 담당자', desc: '소속 지방청 및 하위 해경서 자산 수정·업로드', color: 'text-status-orange', bg: 'rgba(249,115,22,0.15)' },
|
||||
{ icon: '⚓', role: '해경서 담당자', desc: '소속 해경서 자산 수정·업로드', color: 'text-primary-blue', bg: 'rgba(59,130,246,0.15)' },
|
||||
{ icon: '👤', role: '일반 사용자', desc: '조회·다운로드만 가능', color: 'text-text-2', bg: 'rgba(100,116,139,0.15)' },
|
||||
{ icon: '👑', role: '본청 관리자', desc: '전체 자산 조회·수정·삭제·업로드', color: 'text-color-danger', bg: 'rgba(239,68,68,0.15)' },
|
||||
{ icon: '🏛', role: '지방청 담당자', desc: '소속 지방청 및 하위 해경서 자산 수정·업로드', color: 'text-color-warning', bg: 'rgba(249,115,22,0.15)' },
|
||||
{ icon: '⚓', role: '해경서 담당자', desc: '소속 해경서 자산 수정·업로드', color: 'text-color-info', bg: 'rgba(59,130,246,0.15)' },
|
||||
{ icon: '👤', role: '일반 사용자', desc: '조회·다운로드만 가능', color: 'text-fg-sub', bg: 'rgba(100,116,139,0.15)' },
|
||||
].map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3.5 px-4 bg-bg-3 border border-border rounded-sm">
|
||||
<div key={i} className="flex items-center gap-3 p-3.5 px-4 bg-bg-card border border-stroke rounded-sm">
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center text-base" style={{ background: p.bg }}>{p.icon}</div>
|
||||
<div>
|
||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
||||
<div className="text-[10px] text-text-3 font-korean">{p.desc}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -115,12 +115,12 @@ function AssetUpload() {
|
||||
<div className="text-[13px] font-bold mb-3.5 font-korean">📋 최근 업로드 이력</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{uploadHistory.map((h) => (
|
||||
<div key={h.logSn} className="flex justify-between items-center p-3.5 px-4 bg-bg-3 border border-border rounded-sm">
|
||||
<div key={h.logSn} className="flex justify-between items-center p-3.5 px-4 bg-bg-card border border-stroke rounded-sm">
|
||||
<div>
|
||||
<div className="text-xs font-semibold font-korean">{h.fileNm}</div>
|
||||
<div className="text-[10px] text-text-3 mt-0.5 font-korean">{new Date(h.regDtm).toLocaleString('ko-KR')} · {h.uploaderNm} · {h.uploadCnt}건</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5 font-korean">{new Date(h.regDtm).toLocaleString('ko-KR')} · {h.uploaderNm} · {h.uploadCnt}건</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[rgba(34,197,94,0.15)] text-status-green">완료</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[rgba(34,197,94,0.15)] text-color-success">완료</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -15,9 +15,9 @@ export function AssetsView() {
|
||||
useFeatureTracking(`assets:${activeTab}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex flex-col h-full w-full bg-bg-base">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-bg-1 shrink-0">
|
||||
<div className="flex items-center justify-between border-b border-stroke bg-bg-surface shrink-0">
|
||||
<div className="flex">
|
||||
{([
|
||||
{ id: 'management' as const, icon: '🗂', label: '자산 관리' },
|
||||
@ -30,15 +30,15 @@ export function AssetsView() {
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-5 py-3.5 text-xs font-semibold transition-all font-korean border-b-2 ${
|
||||
activeTab === tab.id
|
||||
? 'text-primary-cyan border-primary-cyan'
|
||||
: 'text-text-3 border-transparent hover:text-text-2'
|
||||
? 'text-color-accent border-color-accent'
|
||||
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3.5 py-1.5 border rounded-full text-[11px] text-primary-blue font-korean mr-4" style={{ borderColor: 'rgba(59,130,246,0.3)' }}>
|
||||
<div className="flex items-center gap-1.5 px-3.5 py-1.5 border rounded-full text-[11px] text-color-info font-korean mr-4" style={{ borderColor: 'rgba(59,130,246,0.3)' }}>
|
||||
👤 남해청_방제과 (수정 권한 ✅)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -56,7 +56,7 @@ function ShipInsurance() {
|
||||
return (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold" style={{
|
||||
background: isY ? 'rgba(34,197,94,.15)' : 'rgba(100,116,139,.1)',
|
||||
color: isY ? 'var(--green)' : 'var(--text-3)',
|
||||
color: isY ? 'var(--color-success)' : 'var(--text-3)',
|
||||
}}>
|
||||
{isY ? 'Y' : 'N'}
|
||||
</span>
|
||||
@ -132,27 +132,27 @@ function ShipInsurance() {
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-bold"
|
||||
style={{
|
||||
background: total > 0 ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
||||
color: total > 0 ? 'var(--green)' : 'var(--red)',
|
||||
color: total > 0 ? 'var(--color-success)' : 'var(--color-danger)',
|
||||
border: `1px solid ${total > 0 ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
|
||||
}}>
|
||||
<span className="w-1.5 h-1.5 rounded-full inline-block" style={{ background: total > 0 ? 'var(--green)' : 'var(--red)' }} />
|
||||
<span className="w-1.5 h-1.5 rounded-full inline-block" style={{ background: total > 0 ? 'var(--color-success)' : 'var(--color-danger)' }} />
|
||||
{total > 0 ? `${total.toLocaleString()}건` : '데이터 없음'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-text-3">해양수산부 해운항만물류정보 공공데이터 기반</div>
|
||||
<div className="text-xs text-fg-disabled">해양수산부 해운항만물류정보 공공데이터 기반</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => window.open('https://www.haewoon.or.kr', '_blank', 'noopener')}
|
||||
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
||||
style={{ background: 'rgba(59,130,246,.12)', color: 'var(--blue)', border: '1px solid rgba(59,130,246,.3)' }}
|
||||
style={{ background: 'rgba(59,130,246,.12)', color: 'var(--color-info)', border: '1px solid rgba(59,130,246,.3)' }}
|
||||
>
|
||||
한국해운조합 API
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open('https://new.portmis.go.kr', '_blank', 'noopener')}
|
||||
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
||||
style={{ background: 'rgba(168,85,247,.12)', color: 'var(--purple)', border: '1px solid rgba(168,85,247,.3)' }}
|
||||
style={{ background: 'rgba(168,85,247,.12)', color: 'var(--color-tertiary)', border: '1px solid rgba(168,85,247,.3)' }}
|
||||
>
|
||||
PortMIS
|
||||
</button>
|
||||
@ -160,28 +160,28 @@ function ShipInsurance() {
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="bg-bg-3 border border-border rounded-md px-5 py-4 mb-4">
|
||||
<div className="bg-bg-card border border-stroke rounded-md px-5 py-4 mb-4">
|
||||
<div className="flex gap-2.5 items-end flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">검색 (선박명/호출부호/IMO/선주)</label>
|
||||
<label className="block text-[10px] font-semibold text-fg-disabled mb-1">검색 (선박명/호출부호/IMO/선주)</label>
|
||||
<input
|
||||
type="text" value={search} onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="선박명, 호출부호, IMO, 선주명"
|
||||
className="w-full px-3.5 py-2 bg-bg-0 border border-border rounded-sm text-xs outline-none box-border"
|
||||
className="w-full px-3.5 py-2 bg-bg-base border border-stroke rounded-sm text-xs outline-none box-border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">선박종류</label>
|
||||
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="prd-i min-w-[120px] border-border">
|
||||
<label className="block text-[10px] font-semibold text-fg-disabled mb-1">선박종류</label>
|
||||
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="prd-i min-w-[120px] border-stroke">
|
||||
<option value="">전체</option>
|
||||
<option value="일반선박">일반선박</option>
|
||||
<option value="유조선">유조선</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">발급기관</label>
|
||||
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="prd-i min-w-[160px] border-border">
|
||||
<label className="block text-[10px] font-semibold text-fg-disabled mb-1">발급기관</label>
|
||||
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="prd-i min-w-[160px] border-stroke">
|
||||
<option value="">전체</option>
|
||||
<option>부산지방해양수산청</option>
|
||||
<option>인천지방해양수산청</option>
|
||||
@ -197,44 +197,44 @@ function ShipInsurance() {
|
||||
<option>포항지방해양수산청</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleSearch} className="px-5 py-2 text-white border-none rounded-sm text-xs font-bold cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>조회</button>
|
||||
<button onClick={handleReset} className="px-4 py-2 bg-bg-0 text-text-2 border border-border rounded-sm text-xs cursor-pointer">초기화</button>
|
||||
<button onClick={handleDownload} disabled={total === 0} className="px-4 py-2 text-xs font-bold cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)', border: '1px solid rgba(34,197,94,.3)' }}>엑셀 다운로드</button>
|
||||
<button onClick={handleSearch} className="px-5 py-2 text-white border-none rounded-sm text-xs font-bold cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' }}>조회</button>
|
||||
<button onClick={handleReset} className="px-4 py-2 bg-bg-base text-fg-sub border border-stroke rounded-sm text-xs cursor-pointer">초기화</button>
|
||||
<button onClick={handleDownload} disabled={total === 0} className="px-4 py-2 text-xs font-bold cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--color-success)', border: '1px solid rgba(34,197,94,.3)' }}>엑셀 다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center p-16 bg-bg-3 border border-border rounded-md">
|
||||
<div className="w-9 h-9 rounded-full mb-3.5" style={{ border: '3px solid var(--bd)', borderTopColor: 'var(--cyan)', animation: 'spin 0.8s linear infinite' }} />
|
||||
<div className="text-[13px] text-text-2">보험 데이터 조회 중...</div>
|
||||
<div className="flex flex-col items-center justify-center p-16 bg-bg-card border border-stroke rounded-md">
|
||||
<div className="w-9 h-9 rounded-full mb-3.5" style={{ border: '3px solid var(--stroke-default)', borderTopColor: 'var(--color-accent)', animation: 'spin 0.8s linear infinite' }} />
|
||||
<div className="text-[13px] text-fg-sub">보험 데이터 조회 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-3 border border-border rounded-md">
|
||||
<div className="text-sm font-bold text-status-red mb-2">조회 실패</div>
|
||||
<div className="text-xs text-text-3">{error}</div>
|
||||
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-card border border-stroke rounded-md">
|
||||
<div className="text-sm font-bold text-color-danger mb-2">조회 실패</div>
|
||||
<div className="text-xs text-fg-disabled">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<div className="bg-bg-3 border border-border rounded-md overflow-hidden mb-3">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="bg-bg-card border border-stroke rounded-md overflow-hidden mb-3">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
|
||||
<div className="text-xs font-bold">
|
||||
조회 결과 <span className="text-primary-cyan">{total.toLocaleString()}</span>건
|
||||
{totalPages > 1 && <span className="text-text-3 font-normal ml-2">({page}/{totalPages} 페이지)</span>}
|
||||
조회 결과 <span className="text-color-accent">{total.toLocaleString()}</span>건
|
||||
{totalPages > 1 && <span className="text-fg-disabled font-normal ml-2">({page}/{totalPages} 페이지)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="bg-bg-0">
|
||||
<tr className="bg-bg-base">
|
||||
{['No', '선박명', '호출부호', 'IMO', '선박종류', '선주', '총톤수', '보험사', '책임', '유류', '연료유', '난파물', '유효기간', '발급기관', '상태'].map((h, i) => (
|
||||
<th key={i} className="px-3 py-2.5 font-bold text-text-2 border-b border-border text-center">{h}</th>
|
||||
<th key={i} className="px-3 py-2.5 font-bold text-fg-sub border-b border-stroke text-center">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@ -245,14 +245,14 @@ function ShipInsurance() {
|
||||
const isSoon = st === 'soon'
|
||||
const rowNum = (page - 1) * PAGE_SIZE + i + 1
|
||||
return (
|
||||
<tr key={r.insSn} className="border-b border-border" style={{ background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
|
||||
<td className="px-3 py-2 text-center text-text-3 font-mono">{rowNum}</td>
|
||||
<tr key={r.insSn} className="border-b border-stroke" style={{ background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
|
||||
<td className="px-3 py-2 text-center text-fg-disabled font-mono">{rowNum}</td>
|
||||
<td className="px-3 py-2 font-semibold">{r.shipNm}</td>
|
||||
<td className="px-3 py-2 text-center font-mono">{r.callSign || '—'}</td>
|
||||
<td className="px-3 py-2 text-center font-mono">{r.imoNo || '—'}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="text-[10px]">{r.shipTp}</span>
|
||||
{r.shipTpDetail && <span className="text-text-3 text-[9px] ml-1">({r.shipTpDetail})</span>}
|
||||
{r.shipTpDetail && <span className="text-fg-disabled text-[9px] ml-1">({r.shipTpDetail})</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[150px] truncate">{r.ownerNm}</td>
|
||||
<td className="px-3 py-2 text-right font-mono">{r.grossTon ? Number(r.grossTon).toLocaleString() : '—'}</td>
|
||||
@ -261,14 +261,14 @@ function ShipInsurance() {
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.oilPollutionYn)}</td>
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.fuelOilYn)}</td>
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.wreckRemovalYn)}</td>
|
||||
<td className="px-3 py-2 text-center font-mono text-[10px]" style={{ color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>
|
||||
<td className="px-3 py-2 text-center font-mono text-[10px]" style={{ color: isExp ? 'var(--color-danger)' : isSoon ? 'var(--color-caution)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>
|
||||
{r.validStart} ~ {r.validEnd}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-[10px]">{r.issueOrg}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="px-2 py-0.5 rounded-full text-[9px] font-semibold" style={{
|
||||
background: isExp ? 'rgba(239,68,68,.15)' : isSoon ? 'rgba(234,179,8,.15)' : 'rgba(34,197,94,.15)',
|
||||
color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : 'var(--green)',
|
||||
color: isExp ? 'var(--color-danger)' : isSoon ? 'var(--color-caution)' : 'var(--color-success)',
|
||||
}}>
|
||||
{isExp ? '만료' : isSoon ? '만료임박' : '유효'}
|
||||
</span>
|
||||
@ -286,7 +286,7 @@ function ShipInsurance() {
|
||||
<div className="flex items-center justify-center gap-1.5 mb-4">
|
||||
<button
|
||||
onClick={() => loadData(page - 1)} disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-[11px] rounded-sm border border-border bg-bg-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
className="px-3 py-1.5 text-[11px] rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
@ -299,9 +299,9 @@ function ShipInsurance() {
|
||||
key={p} onClick={() => loadData(p)}
|
||||
className="w-8 h-8 text-[11px] rounded-sm border cursor-pointer font-mono"
|
||||
style={{
|
||||
background: p === page ? 'var(--cyan)' : 'var(--bg-0)',
|
||||
background: p === page ? 'var(--color-accent)' : 'var(--bg-0)',
|
||||
color: p === page ? '#fff' : 'var(--text-2)',
|
||||
borderColor: p === page ? 'var(--cyan)' : 'var(--bd)',
|
||||
borderColor: p === page ? 'var(--color-accent)' : 'var(--stroke-default)',
|
||||
fontWeight: p === page ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
@ -311,7 +311,7 @@ function ShipInsurance() {
|
||||
})}
|
||||
<button
|
||||
onClick={() => loadData(page + 1)} disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-[11px] rounded-sm border border-border bg-bg-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
className="px-3 py-1.5 text-[11px] rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
@ -321,10 +321,10 @@ function ShipInsurance() {
|
||||
)}
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="mt-auto px-4 py-3 bg-bg-3 border border-border rounded-sm">
|
||||
<div className="text-[10px] text-text-3 leading-[1.7]">
|
||||
<span className="text-text-2 font-bold">데이터 출처:</span> 해양수산부 해운항만물류정보 · 유류오염보장계약관리 공공데이터<br />
|
||||
<span className="text-text-2 font-bold">보장항목:</span> 책임보험, 유류오염, 연료유오염, 난파물제거비용, 선원손해, 여객손해, 선체손해, 부두손상
|
||||
<div className="mt-auto px-4 py-3 bg-bg-card border border-stroke rounded-sm">
|
||||
<div className="text-[10px] text-fg-disabled leading-[1.7]">
|
||||
<span className="text-fg-sub font-bold">데이터 출처:</span> 해양수산부 해운항만물류정보 · 유류오염보장계약관리 공공데이터<br />
|
||||
<span className="text-fg-sub font-bold">보장항목:</span> 책임보험, 유류오염, 연료유오염, 난파물제거비용, 선원손해, 여객손해, 선체손해, 부두손상
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -34,12 +34,12 @@ export interface InsuranceRow {
|
||||
}
|
||||
|
||||
export const typeTagCls = (type: string) => {
|
||||
if (type === '해경관할') return 'bg-[rgba(239,68,68,0.1)] text-status-red'
|
||||
if (type === '해경경찰서') return 'bg-[rgba(59,130,246,0.1)] text-primary-blue'
|
||||
if (type === '파출소') return 'bg-[rgba(34,197,94,0.1)] text-status-green'
|
||||
if (type === '관련기관') return 'bg-[rgba(168,85,247,0.1)] text-primary-purple'
|
||||
if (type === '해양환경공단') return 'bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
||||
if (type === '업체') return 'bg-[rgba(245,158,11,0.1)] text-status-orange'
|
||||
if (type === '해경관할') return 'bg-[rgba(239,68,68,0.1)] text-color-danger'
|
||||
if (type === '해경경찰서') return 'bg-[rgba(59,130,246,0.1)] text-color-info'
|
||||
if (type === '파출소') return 'bg-[rgba(34,197,94,0.1)] text-color-success'
|
||||
if (type === '관련기관') return 'bg-[rgba(168,85,247,0.1)] text-color-tertiary'
|
||||
if (type === '해양환경공단') return 'bg-[rgba(6,182,212,0.1)] text-color-accent'
|
||||
if (type === '업체') return 'bg-[rgba(245,158,11,0.1)] text-color-warning'
|
||||
if (type === '지자체') return 'bg-[rgba(236,72,153,0.1)] text-[#ec4899]'
|
||||
if (type === '기름저장시설') return 'bg-[rgba(139,92,246,0.1)] text-[#8b5cf6]'
|
||||
if (type === '정유사') return 'bg-[rgba(20,184,166,0.1)] text-[#14b8a6]'
|
||||
|
||||
@ -52,8 +52,8 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
|
||||
if (isLoading || !post) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0 items-center justify-center">
|
||||
<p className="text-text-3 text-sm">게시글을 불러오는 중...</p>
|
||||
<div className="flex flex-col h-full bg-bg-base items-center justify-center">
|
||||
<p className="text-fg-disabled text-sm">게시글을 불러오는 중...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -62,12 +62,12 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
const isAuthor = user?.id === post.authorId
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-text-2 hover:text-text-1 transition-colors"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-fg-sub hover:text-fg transition-colors"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>목록으로</span>
|
||||
@ -76,7 +76,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-elevated text-fg border border-stroke hover:bg-bg-card transition-colors"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
@ -94,7 +94,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto px-8 py-8">
|
||||
{/* 게시글 헤더 */}
|
||||
<div className="pb-6 border-b border-border">
|
||||
<div className="pb-6 border-b border-stroke">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}>
|
||||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||
@ -105,9 +105,9 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-1 mb-4">{post.title}</h1>
|
||||
<div className="flex items-center gap-4 text-sm text-text-3">
|
||||
<span>작성자: <span className="text-text-2 font-semibold">{post.authorName}</span></span>
|
||||
<h1 className="text-2xl font-bold text-fg mb-4">{post.title}</h1>
|
||||
<div className="flex items-center gap-4 text-sm text-fg-disabled">
|
||||
<span>작성자: <span className="text-fg-sub font-semibold">{post.authorName}</span></span>
|
||||
<span>|</span>
|
||||
<span>작성일: {new Date(post.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||
<span>|</span>
|
||||
@ -124,16 +124,16 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
{/* 본문 */}
|
||||
<div className="py-8">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="text-text-1 text-[15px] leading-relaxed whitespace-pre-wrap">
|
||||
<div className="text-fg text-[15px] leading-relaxed whitespace-pre-wrap">
|
||||
{post.content || '(내용 없음)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 댓글 섹션 (향후 구현 예정) */}
|
||||
<div className="py-6 border-t border-border">
|
||||
<div className="py-6 border-t border-stroke">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-text-3 text-sm">댓글 기능은 향후 업데이트 예정입니다.</p>
|
||||
<p className="text-fg-disabled text-sm">댓글 기능은 향후 업데이트 예정입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -95,9 +95,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* Header with Search and Write Button */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-2">
|
||||
{CATEGORY_FILTER.map((cat) => (
|
||||
@ -106,8 +106,8 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
onClick={() => handleCategoryChange(cat.code)}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
|
||||
selectedCategory === cat.code
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||
? 'bg-color-accent text-bg-0'
|
||||
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
@ -123,13 +123,13 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
className="px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none w-64"
|
||||
/>
|
||||
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={onWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-color-accent text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>글쓰기</span>
|
||||
@ -142,19 +142,19 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">불러오는 중...</p>
|
||||
<p className="text-fg-disabled text-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
||||
<tr className="border-b-2 border-stroke">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-20">번호</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-32">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-32">작성자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-32">작성일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-24">조회수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -162,9 +162,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<tr
|
||||
key={post.sn}
|
||||
onClick={() => onPostClick(post.sn)}
|
||||
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
||||
className="border-b border-stroke hover:bg-bg-elevated cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-text-1">
|
||||
<td className="px-4 py-4 text-sm text-fg">
|
||||
{post.pinnedYn === 'Y' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지
|
||||
@ -185,15 +185,15 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
|
||||
} hover:text-primary-cyan transition-colors`}
|
||||
post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'
|
||||
} hover:text-color-accent transition-colors`}
|
||||
>
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-sub">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-disabled">{formatDate(post.regDtm)}</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-disabled">{post.viewCnt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -201,7 +201,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">검색 결과가 없습니다.</p>
|
||||
<p className="text-fg-disabled text-sm">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -210,11 +210,11 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-stroke bg-bg-surface">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
@ -224,8 +224,8 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 text-sm rounded ${
|
||||
page === p
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
|
||||
? 'bg-color-accent text-bg-0 font-semibold'
|
||||
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
@ -234,7 +234,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -204,23 +204,23 @@ export function BoardView() {
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b" style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b" style={{ borderColor: 'var(--stroke-default)', background: 'var(--bg-surface)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📘</span>
|
||||
<span className="text-[15px] font-bold">해경매뉴얼</span>
|
||||
<span className="text-[10px] ml-1 text-text-3">총 {filteredManuals.length}건</span>
|
||||
<span className="text-[10px] ml-1 text-fg-disabled">총 {filteredManuals.length}건</span>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-4">
|
||||
{manualCategories.map(cat => (
|
||||
<button key={cat} onClick={() => setManualCategory(cat)}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all"
|
||||
style={{
|
||||
background: manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg3)',
|
||||
border: manualCategory === cat ? '1px solid rgba(6,182,212,.3)' : '1px solid var(--bd)',
|
||||
color: manualCategory === cat ? 'var(--cyan)' : 'var(--t3)',
|
||||
background: manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg-card)',
|
||||
border: manualCategory === cat ? '1px solid rgba(6,182,212,.3)' : '1px solid var(--stroke-default)',
|
||||
color: manualCategory === cat ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}>
|
||||
{cat}
|
||||
</button>
|
||||
@ -229,7 +229,7 @@ export function BoardView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="text" placeholder="매뉴얼 검색..." value={manualSearch} onChange={e => setManualSearch(e.target.value)}
|
||||
className="px-4 py-2 text-sm rounded w-64" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', outline: 'none' }} />
|
||||
className="px-4 py-2 text-sm rounded w-64" style={{ background: 'var(--bg-elevated)', border: '1px solid var(--stroke-default)', outline: 'none' }} />
|
||||
<button onClick={() => setShowUploadModal(true)}
|
||||
className="px-5 py-2 text-[12px] font-semibold rounded-md transition-all flex items-center gap-1.5"
|
||||
style={{ background: 'rgba(6,182,212,.15)', border: '1px solid rgba(6,182,212,.3)', color: '#22d3ee', cursor: 'pointer' }}>
|
||||
@ -242,7 +242,7 @@ export function BoardView() {
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
{manualLoading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-sm text-text-3">로딩 중...</p>
|
||||
<p className="text-sm text-fg-disabled">로딩 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
|
||||
@ -250,11 +250,11 @@ export function BoardView() {
|
||||
const cc = catColor(file.catgNm)
|
||||
return (
|
||||
<div key={file.manualSn} className="rounded-xl p-4 transition-all" style={{
|
||||
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--stroke-default)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(6,182,212,.4)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--bd)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--stroke-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold" style={{ background: cc.bg, color: cc.text }}>
|
||||
@ -272,7 +272,7 @@ export function BoardView() {
|
||||
<span style={{ fontSize: 12 }}>📄</span>
|
||||
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileTp || 'PDF'}</span>
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>{file.fileSz}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 mb-2">
|
||||
<button onClick={(e) => {
|
||||
@ -309,13 +309,13 @@ export function BoardView() {
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-3 text-[10px] text-text-3">
|
||||
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--stroke-default)' }}>
|
||||
<div className="flex items-center gap-3 text-[10px] text-fg-disabled">
|
||||
<span>{file.authorNm}</span>
|
||||
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||
<span className="text-[10px]" style={{ color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>
|
||||
⬇ {file.dwnldCnt}
|
||||
</span>
|
||||
<button onClick={async (e) => {
|
||||
@ -367,7 +367,7 @@ export function BoardView() {
|
||||
{!manualLoading && filteredManuals.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>📘</div>
|
||||
<p className="text-sm text-text-3">검색 결과가 없습니다.</p>
|
||||
<p className="text-sm text-fg-disabled">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -377,19 +377,19 @@ export function BoardView() {
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,.55)' }}
|
||||
onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}>
|
||||
<div className="w-[480px] bg-bg-1 border border-border rounded-xl overflow-hidden"
|
||||
<div className="w-[480px] bg-bg-surface border border-stroke rounded-xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-border flex items-center justify-between">
|
||||
<div className="px-5 py-4 border-b border-stroke flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{editingManualId ? '✏️' : '📤'}</span>
|
||||
<span className="text-sm font-bold">{editingManualId ? '매뉴얼 수정' : '매뉴얼 업로드'}</span>
|
||||
</div>
|
||||
<span onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
|
||||
className="cursor-pointer text-text-3 text-base leading-none">✕</span>
|
||||
className="cursor-pointer text-fg-disabled text-base leading-none">✕</span>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">카테고리</label>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">카테고리</label>
|
||||
<div className="flex gap-1.5">
|
||||
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map(cat => {
|
||||
const cc = catColor(cat)
|
||||
@ -398,9 +398,9 @@ export function BoardView() {
|
||||
<button key={cat} onClick={() => setUploadForm(prev => ({ ...prev, category: cat }))}
|
||||
className="flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? cc.bg : 'var(--bg3)',
|
||||
border: isActive ? `1px solid ${cc.text}40` : '1px solid var(--bd)',
|
||||
color: isActive ? cc.text : 'var(--t3)',
|
||||
background: isActive ? cc.bg : 'var(--bg-card)',
|
||||
border: isActive ? `1px solid ${cc.text}40` : '1px solid var(--stroke-default)',
|
||||
color: isActive ? cc.text : 'var(--fg-disabled)',
|
||||
}}>
|
||||
{cat}
|
||||
</button>
|
||||
@ -409,20 +409,20 @@ export function BoardView() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">매뉴얼 제목</label>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">매뉴얼 제목</label>
|
||||
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
|
||||
onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-2 border border-border outline-none" style={{ boxSizing: 'border-box' }} />
|
||||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-elevated border border-stroke outline-none" style={{ boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">버전</label>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">버전</label>
|
||||
<input type="text" placeholder="예: v1.0" value={uploadForm.version}
|
||||
onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-2 border border-border outline-none" style={{ boxSizing: 'border-box' }} />
|
||||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-elevated border border-stroke outline-none" style={{ boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">파일 첨부</label>
|
||||
<div className="border-2 border-dashed border-border rounded-md py-6 px-4 text-center bg-bg-2 cursor-pointer relative"
|
||||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">파일 첨부</label>
|
||||
<div className="border-2 border-dashed border-stroke rounded-md py-6 px-4 text-center bg-bg-elevated cursor-pointer relative"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
@ -441,24 +441,24 @@ export function BoardView() {
|
||||
<span className="text-xl">📄</span>
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">{uploadForm.fileName}</div>
|
||||
<div className="text-[10px] text-text-3 font-mono">{uploadForm.fileSize}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono">{uploadForm.fileSize}</div>
|
||||
</div>
|
||||
<span onClick={(e) => { e.stopPropagation(); setUploadForm(prev => ({ ...prev, fileName: '', fileSize: '' })) }}
|
||||
className="text-xs text-text-3 cursor-pointer ml-2">✕</span>
|
||||
className="text-xs text-fg-disabled cursor-pointer ml-2">✕</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[28px] opacity-30 mb-1.5">📁</div>
|
||||
<div className="text-[11px] text-text-3">클릭하여 파일을 선택하세요</div>
|
||||
<div className="text-[9px] text-text-3 font-mono mt-1">PDF, DOC, HWP, XLSX (최대 100MB)</div>
|
||||
<div className="text-[11px] text-fg-disabled">클릭하여 파일을 선택하세요</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono mt-1">PDF, DOC, HWP, XLSX (최대 100MB)</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||||
<div className="px-5 py-3 border-t border-stroke flex justify-end gap-2">
|
||||
<button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
|
||||
className="px-5 py-2 rounded-md text-xs font-semibold bg-bg-3 border border-border text-text-3 cursor-pointer">
|
||||
className="px-5 py-2 rounded-md text-xs font-semibold bg-bg-card border border-stroke text-fg-disabled cursor-pointer">
|
||||
취소
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
@ -545,11 +545,11 @@ export function BoardView() {
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="text-sm text-text-3">
|
||||
총 <span className="text-text-1 font-semibold">{totalCount}</span>건
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||
<div className="text-sm text-fg-disabled">
|
||||
총 <span className="text-fg font-semibold">{totalCount}</span>건
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@ -558,12 +558,12 @@ export function BoardView() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
className="px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none w-64"
|
||||
/>
|
||||
{hasPermission(getWriteResource(), 'CREATE') && (
|
||||
<button
|
||||
onClick={handleWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity"
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-color-accent text-bg-0 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
✏️ 글쓰기
|
||||
</button>
|
||||
@ -575,28 +575,28 @@ export function BoardView() {
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">로딩 중...</p>
|
||||
<p className="text-fg-disabled text-sm">로딩 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">번호</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">작성자</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-28">작성일</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">조회</th>
|
||||
<tr className="border-b-2 border-stroke">
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-16">번호</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-24">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub">제목</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-24">작성자</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-28">작성일</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-16">조회</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr
|
||||
key={post.sn}
|
||||
className="border-b border-border hover:bg-bg-2 transition-colors"
|
||||
className="border-b border-stroke hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-text-1 text-center">{post.sn}</td>
|
||||
<td className="px-4 py-4 text-sm text-fg text-center">{post.sn}</td>
|
||||
<td className="px-4 py-4 text-center">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}>
|
||||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||
@ -606,15 +606,15 @@ export function BoardView() {
|
||||
className="px-4 py-4 cursor-pointer"
|
||||
onClick={() => handlePostClick(post.sn)}
|
||||
>
|
||||
<span className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'} hover:text-primary-cyan transition-colors`}>
|
||||
<span className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'} hover:text-color-accent transition-colors`}>
|
||||
{post.pinnedYn === 'Y' && '📌 '}{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2 text-center">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3 text-center">
|
||||
<td className="px-4 py-4 text-sm text-fg-sub text-center">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-disabled text-center">
|
||||
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3 text-center">{post.viewCnt}</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-disabled text-center">{post.viewCnt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -622,7 +622,7 @@ export function BoardView() {
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">게시글이 없습니다.</p>
|
||||
<p className="text-fg-disabled text-sm">게시글이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -631,15 +631,15 @@ export function BoardView() {
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-stroke bg-bg-surface">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
p === page
|
||||
? 'bg-primary-cyan/20 text-primary-cyan font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||
? 'bg-color-accent/20 text-color-accent font-semibold'
|
||||
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
|
||||
@ -112,17 +112,17 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0 items-center justify-center">
|
||||
<p className="text-text-3 text-sm">게시글을 불러오는 중...</p>
|
||||
<div className="flex flex-col h-full bg-bg-base items-center justify-center">
|
||||
<p className="text-fg-disabled text-sm">게시글을 불러오는 중...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<h2 className="text-lg font-semibold text-text-1">
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||
<h2 className="text-lg font-semibold text-fg">
|
||||
{isEditMode ? '게시글 수정' : '게시글 작성'}
|
||||
</h2>
|
||||
</div>
|
||||
@ -133,14 +133,14 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* 분류 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">
|
||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
||||
분류 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={categoryCd}
|
||||
onChange={(e) => setCategoryCd(e.target.value)}
|
||||
disabled={isEditMode}
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none disabled:opacity-50"
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{CATEGORY_OPTIONS.map(opt => (
|
||||
<option key={opt.code} value={opt.code}>{opt.label}</option>
|
||||
@ -150,7 +150,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">
|
||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -159,13 +159,13 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="제목을 입력하세요"
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none"
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">
|
||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
||||
내용 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
@ -174,13 +174,13 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
maxLength={10000}
|
||||
placeholder="내용을 입력하세요"
|
||||
rows={15}
|
||||
className="w-full px-4 py-3 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none resize-none"
|
||||
className="w-full px-4 py-3 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 첨부 (향후 API 연동 예정) */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">첨부파일</label>
|
||||
<label className="block text-sm font-semibold text-fg-sub mb-2">첨부파일</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
@ -192,29 +192,29 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 cursor-pointer transition-colors"
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-elevated text-fg border border-stroke hover:bg-bg-card cursor-pointer transition-colors"
|
||||
>
|
||||
파일 선택
|
||||
</label>
|
||||
<span className="text-sm text-text-3">선택된 파일 없음</span>
|
||||
<span className="text-sm text-fg-disabled">선택된 파일 없음</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-end gap-3 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<div className="flex items-center justify-end gap-3 px-8 py-4 border-t border-stroke bg-bg-surface">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-bg-elevated text-fg border border-stroke hover:bg-bg-card transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-color-accent text-bg-0 hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
|
||||
</button>
|
||||
|
||||
@ -15,10 +15,10 @@ const RISK_LABEL: Record<string, string> = {
|
||||
}
|
||||
|
||||
const RISK_STYLE: Record<string, { bg: string; color: string }> = {
|
||||
CRITICAL: { bg: 'rgba(239,68,68,0.2)', color: 'var(--red)' },
|
||||
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--red)' },
|
||||
MEDIUM: { bg: 'rgba(249,115,22,0.15)', color: 'var(--orange)' },
|
||||
LOW: { bg: 'rgba(34,197,94,0.15)', color: 'var(--green)' },
|
||||
CRITICAL: { bg: 'rgba(239,68,68,0.2)', color: 'var(--color-danger)' },
|
||||
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
||||
MEDIUM: { bg: 'rgba(249,115,22,0.15)', color: 'var(--color-warning)' },
|
||||
LOW: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' },
|
||||
}
|
||||
|
||||
function formatDate(dtm: string | null, mode: 'full' | 'date') {
|
||||
@ -98,15 +98,15 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
}, [loadData])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-border flex justify-between items-center bg-bg-1">
|
||||
<div className="px-5 py-4 border-b border-stroke flex justify-between items-center bg-bg-surface">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-base font-bold flex items-center gap-2">
|
||||
<span className="text-[18px]">📋</span>
|
||||
HNS 대기확산 분석 목록
|
||||
</div>
|
||||
<span className="text-[10px] text-text-3 bg-bg-3 font-mono px-[10px] py-1 rounded-xl">
|
||||
<span className="text-[10px] text-fg-disabled bg-bg-card font-mono px-[10px] py-1 rounded-xl">
|
||||
총 {analyses.length}건
|
||||
</span>
|
||||
</div>
|
||||
@ -114,11 +114,11 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
className="bg-bg-3 border border-border rounded-sm text-[11px] px-3 py-2 w-[200px]"
|
||||
className="bg-bg-card border border-stroke rounded-sm text-[11px] px-3 py-2 w-[200px]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onTabChange('analysis')}
|
||||
className="text-status-orange text-[11px] font-semibold cursor-pointer flex items-center gap-1 px-4 py-2 rounded-sm"
|
||||
className="text-color-warning text-[11px] font-semibold cursor-pointer flex items-center gap-1 px-4 py-2 rounded-sm"
|
||||
style={{
|
||||
border: '1px solid rgba(249,115,22,0.3)',
|
||||
background: 'linear-gradient(135deg, rgba(249,115,22,0.15), rgba(239,68,68,0.1))',
|
||||
@ -132,41 +132,41 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="text-center text-text-3 text-[12px] py-20">로딩 중...</div>
|
||||
<div className="text-center text-fg-disabled text-[12px] py-20">로딩 중...</div>
|
||||
) : (
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-bg-2 border-b border-border">
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[50px]">번호</th>
|
||||
<th className="text-left text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[180px]">분석명</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[100px]">물질</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[130px]">사고일시</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[100px]">분석날짜</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[120px]">사고지점</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[90px]">유출량</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[80px]">알고리즘</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[70px]">예측시간</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[70px]">
|
||||
<tr className="bg-bg-elevated border-b border-stroke">
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 w-[50px]">번호</th>
|
||||
<th className="text-left text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[180px]">분석명</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[100px]">물질</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[130px]">사고일시</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[100px]">분석날짜</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[120px]">사고지점</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[90px]">유출량</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[80px]">알고리즘</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 w-[70px]">예측시간</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 w-[70px]">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span>AEGL-3</span>
|
||||
<span className="text-[8px] text-text-3">생명위협</span>
|
||||
<span className="text-[8px] text-fg-disabled">생명위협</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[70px]">
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 w-[70px]">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span>AEGL-2</span>
|
||||
<span className="text-[8px] text-text-3">건강피해</span>
|
||||
<span className="text-[8px] text-fg-disabled">건강피해</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[70px]">
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 w-[70px]">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span>AEGL-1</span>
|
||||
<span className="text-[8px] text-text-3">불쾌감</span>
|
||||
<span className="text-[8px] text-fg-disabled">불쾌감</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[80px]">위험등급</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[90px]">피해반경</th>
|
||||
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[120px]">분석자</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[80px]">위험등급</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[90px]">피해반경</th>
|
||||
<th className="text-center text-[10px] font-semibold text-fg-sub px-4 py-3 min-w-[120px]">분석자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -174,7 +174,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
const rslt = item.rsltData as Record<string, unknown> | null
|
||||
const isLocal = !!(item as HnsAnalysisItem & { _isLocal?: boolean })._isLocal
|
||||
const riskLabel = RISK_LABEL[item.riskCd || ''] || item.riskCd || '—'
|
||||
const riskStyle = RISK_STYLE[item.riskCd || ''] || { bg: 'rgba(100,100,100,0.1)', color: 'var(--t3)' }
|
||||
const riskStyle = RISK_STYLE[item.riskCd || ''] || { bg: 'rgba(100,100,100,0.1)', color: 'var(--fg-disabled)' }
|
||||
const aegl3 = rslt?.aegl3 as boolean | undefined
|
||||
const aegl2 = rslt?.aegl2 as boolean | undefined
|
||||
const aegl1 = rslt?.aegl1 as boolean | undefined
|
||||
@ -184,44 +184,44 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
return (
|
||||
<tr
|
||||
key={item.hnsAnlysSn}
|
||||
className="border-b border-border cursor-pointer"
|
||||
className="border-b border-stroke cursor-pointer"
|
||||
onClick={() => onSelectAnalysis?.(item.hnsAnlysSn, isLocal && rslt ? rslt : undefined)}
|
||||
style={{
|
||||
transition: 'background 0.15s',
|
||||
background: index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg2)'}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-elevated)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'}
|
||||
>
|
||||
<td className="text-center text-text-3 font-mono px-4 py-3">{item.hnsAnlysSn}</td>
|
||||
<td className="text-center text-fg-disabled font-mono px-4 py-3">{item.hnsAnlysSn}</td>
|
||||
<td className="font-medium px-4 py-3">{item.anlysNm}</td>
|
||||
<td className="text-center px-4 py-3">
|
||||
<span
|
||||
className="text-[9px] font-semibold text-status-orange px-2 py-1 rounded"
|
||||
className="text-[9px] font-semibold text-color-warning px-2 py-1 rounded"
|
||||
style={{ background: 'rgba(249,115,22,0.12)' }}
|
||||
>
|
||||
{substanceTag(item.sbstNm)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center text-text-2 font-mono text-[10px] px-4 py-3">{formatDate(item.acdntDtm, 'full')}</td>
|
||||
<td className="text-center text-text-3 font-mono text-[10px] px-4 py-3">{formatDate(item.regDtm, 'date')}</td>
|
||||
<td className="text-center text-text-2 px-4 py-3">{item.locNm || '—'}</td>
|
||||
<td className="text-center text-text-2 font-mono px-4 py-3">{amount}</td>
|
||||
<td className="text-center text-fg-sub font-mono text-[10px] px-4 py-3">{formatDate(item.acdntDtm, 'full')}</td>
|
||||
<td className="text-center text-fg-disabled font-mono text-[10px] px-4 py-3">{formatDate(item.regDtm, 'date')}</td>
|
||||
<td className="text-center text-fg-sub px-4 py-3">{item.locNm || '—'}</td>
|
||||
<td className="text-center text-fg-sub font-mono px-4 py-3">{amount}</td>
|
||||
<td className="text-center px-4 py-3">
|
||||
<span
|
||||
className="text-[9px] font-semibold text-primary-cyan px-2 py-1 rounded"
|
||||
className="text-[9px] font-semibold text-color-accent px-2 py-1 rounded"
|
||||
style={{ background: 'rgba(6,182,212,0.12)' }}
|
||||
>
|
||||
{item.algoCd || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center text-text-2 font-mono px-4 py-3">{item.fcstHr ? `${item.fcstHr}H` : '—'}</td>
|
||||
<td className="text-center text-fg-sub font-mono px-4 py-3">{item.fcstHr ? `${item.fcstHr}H` : '—'}</td>
|
||||
<td className="text-center px-4 py-3">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full mx-auto"
|
||||
style={{
|
||||
background: aegl3 ? 'rgba(239,68,68,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
border: aegl3 ? 'none' : '1px solid var(--bd)'
|
||||
border: aegl3 ? 'none' : '1px solid var(--stroke-default)'
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
@ -230,7 +230,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
className="w-6 h-6 rounded-full mx-auto"
|
||||
style={{
|
||||
background: aegl2 ? 'rgba(249,115,22,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
border: aegl2 ? 'none' : '1px solid var(--bd)'
|
||||
border: aegl2 ? 'none' : '1px solid var(--stroke-default)'
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
@ -239,7 +239,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
className="w-6 h-6 rounded-full mx-auto"
|
||||
style={{
|
||||
background: aegl1 ? 'rgba(234,179,8,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
border: aegl1 ? 'none' : '1px solid var(--bd)'
|
||||
border: aegl1 ? 'none' : '1px solid var(--stroke-default)'
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
@ -251,8 +251,8 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
{riskLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center text-text-2 font-mono px-4 py-3">{damageRadius}</td>
|
||||
<td className="text-center text-text-3 text-[10px] px-4 py-3">{item.analystNm || '—'}</td>
|
||||
<td className="text-center text-fg-sub font-mono px-4 py-3">{damageRadius}</td>
|
||||
<td className="text-center text-fg-disabled text-[10px] px-4 py-3">{item.analystNm || '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
@ -261,7 +261,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
||||
)}
|
||||
|
||||
{!loading && analyses.length === 0 && (
|
||||
<div className="text-center text-text-3 text-[12px] py-20">분석 데이터가 없습니다.</div>
|
||||
<div className="text-center text-fg-disabled text-[12px] py-20">분석 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -191,9 +191,9 @@ export function HNSLeftPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 min-w-[320px] flex flex-col h-full bg-bg-1 border-r border-border overflow-hidden">
|
||||
<div className="w-80 min-w-[320px] flex flex-col h-full bg-bg-surface border-r border-stroke overflow-hidden">
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-0">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-base">
|
||||
{activeSubTab === 'analysis' && (
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
@ -207,10 +207,10 @@ export function HNSLeftPanel({
|
||||
}}
|
||||
>🧪</div>
|
||||
<div>
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean">
|
||||
<div className="text-[13px] font-bold text-fg-sub font-korean">
|
||||
HNS 대기확산 예측
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3">
|
||||
<div className="text-[10px] text-fg-disabled">
|
||||
ALOHA/CAMEO 기반 대기확산 시뮬레이션
|
||||
</div>
|
||||
</div>
|
||||
@ -222,7 +222,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 사고 기본정보 */}
|
||||
<div>
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||
<div className="text-[13px] font-bold text-fg-sub font-korean mb-3 flex items-center gap-1.5">
|
||||
📋 사고 기본정보
|
||||
</div>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
@ -249,7 +249,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 사고 발생 일시 */}
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">사고 발생 일시</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">사고 발생 일시</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<input
|
||||
className="prd-i"
|
||||
@ -296,8 +296,8 @@ export function HNSLeftPanel({
|
||||
</div>
|
||||
|
||||
{/* DMS 표시 */}
|
||||
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
||||
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
|
||||
<div className="text-[9px] text-fg-disabled font-mono border border-stroke bg-bg-base"
|
||||
style={{ padding: '4px 8px', borderRadius: 'var(--radius-sm)' }}>
|
||||
{incidentCoord ? `${toDMS(incidentCoord.lat, 'lat')} / ${toDMS(incidentCoord.lon, 'lon')}` : '지도에서 위치를 선택하세요'}
|
||||
</div>
|
||||
|
||||
@ -369,7 +369,7 @@ export function HNSLeftPanel({
|
||||
{/* 알고리즘 선택 */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">예측 알고리즘</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">예측 알고리즘</label>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value={algorithm}
|
||||
@ -383,7 +383,7 @@ export function HNSLeftPanel({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">확산 등급 기준</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">확산 등급 기준</label>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value={criteriaModel}
|
||||
@ -402,7 +402,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 모델 파라미터 & 물질 정보 */}
|
||||
<div>
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||
<div className="text-[13px] font-bold text-fg-sub font-korean mb-3 flex items-center gap-1.5">
|
||||
🧪 {releaseType === '연속 유출' ? 'Plume' : releaseType === '순간 유출' ? 'Puff' : 'Dense Gas'} 파라미터
|
||||
</div>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
@ -417,7 +417,7 @@ export function HNSLeftPanel({
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">배출률 (g/s)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
@ -428,7 +428,7 @@ export function HNSLeftPanel({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">지속시간 (s)</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">지속시간 (s)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
@ -440,7 +440,7 @@ export function HNSLeftPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
@ -457,7 +457,7 @@ export function HNSLeftPanel({
|
||||
{releaseType === '순간 유출' && (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">총 누출량 (g)</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">총 누출량 (g)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
@ -468,7 +468,7 @@ export function HNSLeftPanel({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
@ -486,7 +486,7 @@ export function HNSLeftPanel({
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">배출률 (g/s)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
@ -497,7 +497,7 @@ export function HNSLeftPanel({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">풀 반경 (m)</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">풀 반경 (m)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
@ -509,7 +509,7 @@ export function HNSLeftPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
@ -523,7 +523,7 @@ export function HNSLeftPanel({
|
||||
)}
|
||||
|
||||
{/* 모델 설명 */}
|
||||
<div className="text-[9px] text-text-3 mt-1 leading-[1.4]">
|
||||
<div className="text-[9px] text-fg-disabled mt-1 leading-[1.4]">
|
||||
{releaseType === '연속 유출' && '정상상태 연속 배출. 바람 방향으로 플룸이 형성됩니다.'}
|
||||
{releaseType === '순간 유출' && '한 번에 전량 방출. 시간에 따라 구름이 이동하며 확산됩니다.'}
|
||||
{releaseType === '풀(Pool) 증발' && '고밀도 가스가 지표면을 따라 확산됩니다 (Britter-McQuaid 모델).'}
|
||||
@ -535,25 +535,25 @@ export function HNSLeftPanel({
|
||||
className="p-2 rounded-sm mt-0.5"
|
||||
style={{ background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)' }}
|
||||
>
|
||||
<div className="text-[10px] font-bold text-status-orange mb-1">
|
||||
<div className="text-[10px] font-bold text-color-warning mb-1">
|
||||
⚠ 물질 위험 특성
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-[3px] text-[9px]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">분자량</span>
|
||||
<span className="text-fg-disabled">분자량</span>
|
||||
<span className="font-mono">{tox.mw} g/mol</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">가스밀도</span>
|
||||
<span className="text-fg-disabled">가스밀도</span>
|
||||
<span className="font-mono">{tox.densityGas} kg/m³</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">증기압</span>
|
||||
<span className="text-fg-disabled">증기압</span>
|
||||
<span className="font-mono">{tox.vaporPressure} mmHg</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">IDLH</span>
|
||||
<span className="text-status-red font-semibold font-mono">{tox.idlh} ppm</span>
|
||||
<span className="text-fg-disabled">IDLH</span>
|
||||
<span className="text-color-danger font-semibold font-mono">{tox.idlh} ppm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -563,21 +563,21 @@ export function HNSLeftPanel({
|
||||
className="p-2 rounded-sm"
|
||||
style={{ background: 'rgba(168,85,247,0.05)', border: '1px solid rgba(168,85,247,0.12)' }}
|
||||
>
|
||||
<div className="text-[10px] font-bold text-primary-purple mb-1">
|
||||
<div className="text-[10px] font-bold text-color-tertiary mb-1">
|
||||
📊 확산 등급 기준 (AEGL)
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-[9px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(239,68,68,0.7)' }}></div>
|
||||
<span className="text-text-3">AEGL-3 (생명위협) — {tox.aegl3} ppm</span>
|
||||
<span className="text-fg-disabled">AEGL-3 (생명위협) — {tox.aegl3} ppm</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(249,115,22,0.7)' }}></div>
|
||||
<span className="text-text-3">AEGL-2 (건강피해) — {tox.aegl2} ppm</span>
|
||||
<span className="text-fg-disabled">AEGL-2 (건강피해) — {tox.aegl2} ppm</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(234,179,8,0.7)' }}></div>
|
||||
<span className="text-text-3">AEGL-1 (불쾌감) — {tox.aegl1} ppm</span>
|
||||
<span className="text-fg-disabled">AEGL-1 (불쾌감) — {tox.aegl1} ppm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -618,24 +618,24 @@ export function HNSLeftPanel({
|
||||
}}
|
||||
>📋</div>
|
||||
<div>
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean">
|
||||
<div className="text-[13px] font-bold text-fg-sub font-korean">
|
||||
분석 목록
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3">
|
||||
<div className="text-[10px] text-fg-disabled">
|
||||
저장된 대기확산 예측 결과
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 섹션 */}
|
||||
<div className="bg-bg-3 border border-border rounded-md p-[14px] mb-3">
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-[14px] mb-3">
|
||||
<div className="text-[13px] font-bold text-fg-sub font-korean mb-3 flex items-center gap-1.5">
|
||||
🔍 필터
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 기간 선택 */}
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">기간</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">기간</label>
|
||||
<ComboBox
|
||||
value="최근 7일"
|
||||
onChange={() => {}}
|
||||
@ -650,7 +650,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 물질 분류 */}
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">물질 분류</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">물질 분류</label>
|
||||
<ComboBox
|
||||
value="전체"
|
||||
onChange={() => {}}
|
||||
@ -666,7 +666,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 위험도 */}
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">위험도</label>
|
||||
<label className="text-[10px] text-fg-disabled block mb-0.5">위험도</label>
|
||||
<ComboBox
|
||||
value="전체"
|
||||
onChange={() => {}}
|
||||
@ -682,22 +682,22 @@ export function HNSLeftPanel({
|
||||
</div>
|
||||
|
||||
{/* 통계 요약 */}
|
||||
<div className="bg-bg-3 border border-border rounded-md p-[14px]">
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-[14px]">
|
||||
<div className="text-[13px] font-bold text-fg-sub font-korean mb-3 flex items-center gap-1.5">
|
||||
📊 통계
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center p-2 bg-bg-0 rounded">
|
||||
<span className="text-[10px] text-text-3">전체 분석</span>
|
||||
<span className="text-sm font-bold text-primary-cyan font-mono">8건</span>
|
||||
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
||||
<span className="text-[10px] text-fg-disabled">전체 분석</span>
|
||||
<span className="text-sm font-bold text-color-accent font-mono">8건</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-bg-0 rounded">
|
||||
<span className="text-[10px] text-text-3">고위험 (AEGL-3)</span>
|
||||
<span className="text-sm font-bold text-status-red font-mono">3건</span>
|
||||
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
||||
<span className="text-[10px] text-fg-disabled">고위험 (AEGL-3)</span>
|
||||
<span className="text-sm font-bold text-color-danger font-mono">3건</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-bg-0 rounded">
|
||||
<span className="text-[10px] text-text-3">중위험 (AEGL-2)</span>
|
||||
<span className="text-sm font-bold text-status-orange font-mono">5건</span>
|
||||
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
||||
<span className="text-[10px] text-fg-disabled">중위험 (AEGL-2)</span>
|
||||
<span className="text-sm font-bold text-color-warning font-mono">5건</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -76,11 +76,11 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)' }}
|
||||
>
|
||||
<div
|
||||
className="w-[380px] bg-bg-1 border border-border rounded-[14px] overflow-hidden flex flex-col"
|
||||
className="w-[380px] bg-bg-surface border border-stroke rounded-[14px] overflow-hidden flex flex-col"
|
||||
style={{ boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-border flex items-center gap-3">
|
||||
<div className="px-5 py-4 border-b border-stroke flex items-center gap-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] border border-[rgba(249,115,22,0.3)] flex items-center justify-center text-base shrink-0"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(239,68,68,0.15))' }}
|
||||
@ -89,13 +89,13 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-[15px] font-bold m-0">대기확산 재계산</h2>
|
||||
<div className="text-[10px] text-text-3 mt-0.5">
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5">
|
||||
조건을 변경하여 재계산합니다
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-md border border-border bg-bg-3 text-text-3 text-xs cursor-pointer flex items-center justify-center shrink-0"
|
||||
className="w-7 h-7 rounded-md border border-stroke bg-bg-card text-fg-disabled text-xs cursor-pointer flex items-center justify-center shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@ -104,7 +104,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
{/* Content */}
|
||||
<div
|
||||
className="px-5 py-4 flex flex-col gap-3"
|
||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
|
||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
||||
>
|
||||
{/* HNS 물질 */}
|
||||
<FG label="HNS 물질">
|
||||
@ -180,10 +180,10 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-[14px] border-t border-border flex gap-2">
|
||||
<div className="px-5 py-[14px] border-t border-stroke flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2.5 text-xs font-semibold rounded-md cursor-pointer bg-bg-3 border border-border text-text-2"
|
||||
className="flex-1 py-2.5 text-xs font-semibold rounded-md cursor-pointer bg-bg-card border border-stroke text-fg-sub"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
@ -192,7 +192,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
className="flex-[2] py-2.5 text-xs font-bold rounded-md text-white"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, var(--orange), #ef4444)',
|
||||
background: 'linear-gradient(135deg, var(--color-warning), #ef4444)',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
@ -207,7 +207,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
function FG({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t2)', marginBottom: '6px' }}>
|
||||
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--fg-sub)', marginBottom: '6px' }}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@ -35,8 +35,8 @@ export function HNSRightPanel({
|
||||
}: HNSRightPanelProps) {
|
||||
if (!dispersionResult) {
|
||||
return (
|
||||
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto">
|
||||
<div className="flex flex-col gap-3 items-center justify-center h-full text-text-3 text-xs">
|
||||
<div className="w-[300px] bg-bg-surface border-l border-stroke p-4 overflow-auto">
|
||||
<div className="flex flex-col gap-3 items-center justify-center h-full text-fg-disabled text-xs">
|
||||
<div style={{ fontSize: '32px', opacity: 0.3 }}>📊</div>
|
||||
<div>예측 실행 후 결과가 표시됩니다</div>
|
||||
</div>
|
||||
@ -54,7 +54,7 @@ export function HNSRightPanel({
|
||||
: 'ALOHA';
|
||||
|
||||
return (
|
||||
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto flex flex-col gap-4">
|
||||
<div className="w-[300px] bg-bg-surface border-l border-stroke p-4 overflow-auto flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
@ -62,14 +62,14 @@ export function HNSRightPanel({
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--orange)',
|
||||
background: 'var(--color-warning)',
|
||||
animation: 'pulse 1.5s infinite'
|
||||
}}></div>
|
||||
<h3 className="text-[13px] font-bold m-0">
|
||||
예측 결과
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3 font-mono">
|
||||
<div className="text-[10px] text-fg-disabled font-mono">
|
||||
{dispersionResult.substance} · {modelLabel}
|
||||
</div>
|
||||
</div>
|
||||
@ -77,28 +77,28 @@ export function HNSRightPanel({
|
||||
{/* KPI Cards */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 최대 농도 */}
|
||||
<div className="p-3 bg-bg-3 border border-[rgba(239,68,68,0.2)] rounded-[var(--rS)]">
|
||||
<div className="text-[10px] text-text-3 mb-1.5">
|
||||
<div className="p-3 bg-bg-card border border-[rgba(239,68,68,0.2)] rounded-[var(--radius-sm)]">
|
||||
<div className="text-[10px] text-fg-disabled mb-1.5">
|
||||
최대 농도
|
||||
</div>
|
||||
<div className="text-[20px] font-bold font-mono text-status-red">
|
||||
<div className="text-[20px] font-bold font-mono text-color-danger">
|
||||
{maxConc > 0 ? maxConc.toFixed(1) : '—'} <span className="text-[10px] font-medium">ppm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확산 면적 */}
|
||||
<div className="p-3 bg-bg-3 border border-[rgba(6,182,212,0.2)] rounded-[var(--rS)]">
|
||||
<div className="text-[10px] text-text-3 mb-1.5">
|
||||
<div className="p-3 bg-bg-card border border-[rgba(6,182,212,0.2)] rounded-[var(--radius-sm)]">
|
||||
<div className="text-[10px] text-fg-disabled mb-1.5">
|
||||
AEGL-1 확산 면적
|
||||
</div>
|
||||
<div className="text-[20px] font-bold font-mono text-primary-cyan">
|
||||
<div className="text-[20px] font-bold font-mono text-color-accent">
|
||||
{area > 0 ? area.toFixed(2) : '—'} <span className="text-[10px] font-medium">km²</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 풍속 */}
|
||||
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
|
||||
<div className="text-[10px] text-text-3 mb-1.5">
|
||||
<div className="p-3 bg-bg-card border border-stroke rounded-[var(--radius-sm)]">
|
||||
<div className="text-[10px] text-fg-disabled mb-1.5">
|
||||
풍속
|
||||
</div>
|
||||
<div className="text-[20px] font-bold font-mono">
|
||||
@ -107,8 +107,8 @@ export function HNSRightPanel({
|
||||
</div>
|
||||
|
||||
{/* 풍향 */}
|
||||
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
|
||||
<div className="text-[10px] text-text-3 mb-1.5">
|
||||
<div className="p-3 bg-bg-card border border-stroke rounded-[var(--radius-sm)]">
|
||||
<div className="text-[10px] text-fg-disabled mb-1.5">
|
||||
풍향
|
||||
</div>
|
||||
<div className="text-[20px] font-bold font-mono">
|
||||
@ -119,22 +119,22 @@ export function HNSRightPanel({
|
||||
|
||||
{/* AEGL Zone Details */}
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-text-2 mt-0 mb-2.5">
|
||||
<h4 className="text-[11px] font-semibold text-fg-sub mt-0 mb-2.5">
|
||||
AEGL 구역 상세
|
||||
</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* AEGL-3 */}
|
||||
<div
|
||||
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
||||
className="py-2.5 px-3 bg-bg-elevated rounded-[var(--radius-sm)]"
|
||||
style={{ borderLeft: '3px solid rgba(239,68,68,1)' }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[11px] font-semibold">AEGL-3 (생명위협)</span>
|
||||
<span className="text-[10px] font-mono text-text-3">
|
||||
<span className="text-[10px] font-mono text-fg-disabled">
|
||||
{computedResult?.aeglDistances.aegl3 || 0}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-text-3">
|
||||
<div className="flex justify-between text-[10px] text-fg-disabled">
|
||||
<span>{dispersionResult.concentration['AEGL-3']}</span>
|
||||
<span className="font-mono">{computedResult?.aeglAreas.aegl3 ?? 0} km²</span>
|
||||
</div>
|
||||
@ -142,16 +142,16 @@ export function HNSRightPanel({
|
||||
|
||||
{/* AEGL-2 */}
|
||||
<div
|
||||
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
||||
className="py-2.5 px-3 bg-bg-elevated rounded-[var(--radius-sm)]"
|
||||
style={{ borderLeft: '3px solid rgba(249,115,22,1)' }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[11px] font-semibold">AEGL-2 (건강피해)</span>
|
||||
<span className="text-[10px] font-mono text-text-3">
|
||||
<span className="text-[10px] font-mono text-fg-disabled">
|
||||
{computedResult?.aeglDistances.aegl2 || 0}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-text-3">
|
||||
<div className="flex justify-between text-[10px] text-fg-disabled">
|
||||
<span>{dispersionResult.concentration['AEGL-2']}</span>
|
||||
<span className="font-mono">{computedResult?.aeglAreas.aegl2 ?? 0} km²</span>
|
||||
</div>
|
||||
@ -159,16 +159,16 @@ export function HNSRightPanel({
|
||||
|
||||
{/* AEGL-1 */}
|
||||
<div
|
||||
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
||||
className="py-2.5 px-3 bg-bg-elevated rounded-[var(--radius-sm)]"
|
||||
style={{ borderLeft: '3px solid rgba(234,179,8,1)' }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[11px] font-semibold">AEGL-1 (불쾌감)</span>
|
||||
<span className="text-[10px] font-mono text-text-3">
|
||||
<span className="text-[10px] font-mono text-fg-disabled">
|
||||
{computedResult?.aeglDistances.aegl1 || 0}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-text-3">
|
||||
<div className="flex justify-between text-[10px] text-fg-disabled">
|
||||
<span>{dispersionResult.concentration['AEGL-1']}</span>
|
||||
<span className="font-mono">{computedResult?.aeglAreas.aegl1 ?? 0} km²</span>
|
||||
</div>
|
||||
@ -178,11 +178,11 @@ export function HNSRightPanel({
|
||||
|
||||
{/* 시간 정보 (puff/dense_gas) */}
|
||||
{computedResult && computedResult.modelType !== 'plume' && (
|
||||
<div className="p-2.5 bg-bg-3 border border-border rounded-[var(--rS)]">
|
||||
<div className="text-[10px] text-text-3 mb-1">현재 시뮬레이션 시간</div>
|
||||
<div className="text-[14px] font-bold font-mono text-primary-cyan">
|
||||
<div className="p-2.5 bg-bg-card border border-stroke rounded-[var(--radius-sm)]">
|
||||
<div className="text-[10px] text-fg-disabled mb-1">현재 시뮬레이션 시간</div>
|
||||
<div className="text-[14px] font-bold font-mono text-color-accent">
|
||||
t = {computedResult.timeStep}s
|
||||
<span className="text-[10px] font-normal text-text-3 ml-1.5">
|
||||
<span className="text-[10px] font-normal text-fg-disabled ml-1.5">
|
||||
({(computedResult.timeStep / 60).toFixed(1)}분)
|
||||
</span>
|
||||
</div>
|
||||
@ -190,16 +190,16 @@ export function HNSRightPanel({
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="mt-auto pt-3 border-t border-border text-[10px] text-text-3 font-mono">
|
||||
<div className="mt-auto pt-3 border-t border-stroke text-[10px] text-fg-disabled font-mono">
|
||||
예측 시각: {new Date(dispersionResult.timestamp).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Buttons */}
|
||||
<div className="flex gap-1.5 pt-3 border-t border-border">
|
||||
<div className="flex gap-1.5 pt-3 border-t border-stroke">
|
||||
<button onClick={onSave} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-gradient-to-r from-boom to-[#d97706] text-black font-korean">
|
||||
💾 저장
|
||||
</button>
|
||||
<button onClick={onOpenRecalc} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-[rgba(249,115,22,0.1)] border border-[rgba(249,115,22,0.3)] text-status-orange font-korean">
|
||||
<button onClick={onOpenRecalc} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-[rgba(249,115,22,0.1)] border border-[rgba(249,115,22,0.3)] text-color-warning font-korean">
|
||||
🔄 재계산
|
||||
</button>
|
||||
<button onClick={onOpenReport} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-gradient-to-r from-primary-cyan to-primary-blue text-white font-korean">
|
||||
|
||||
@ -130,16 +130,16 @@ export function HNSScenarioView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full h-full overflow-hidden bg-bg-0">
|
||||
<div className="flex flex-col flex-1 w-full h-full overflow-hidden bg-bg-base">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between shrink-0 border-b border-border px-5 py-[14px] bg-bg-1">
|
||||
<div className="flex items-center justify-between shrink-0 border-b border-stroke px-5 py-[14px] bg-bg-surface">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-base">📊</span>
|
||||
<div>
|
||||
<div className="text-sm font-bold">
|
||||
HNS 대기확산 시나리오 관리
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3">
|
||||
<div className="text-[10px] text-fg-disabled">
|
||||
시간·조건별 대기확산 예측 시나리오 비교·검토 및 대응 의사결정 지원
|
||||
</div>
|
||||
</div>
|
||||
@ -161,7 +161,7 @@ export function HNSScenarioView() {
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="cursor-pointer whitespace-nowrap font-bold text-status-orange text-[11px] px-[14px] py-1.5 rounded-sm"
|
||||
className="cursor-pointer whitespace-nowrap font-bold text-color-warning text-[11px] px-[14px] py-1.5 rounded-sm"
|
||||
style={{
|
||||
background: 'rgba(249,115,22,0.12)',
|
||||
border: '1px solid rgba(249,115,22,0.3)',
|
||||
@ -175,16 +175,16 @@ export function HNSScenarioView() {
|
||||
{/* Body: Left list + Right detail */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ── Left: Scenario List ── */}
|
||||
<div className="flex flex-col overflow-hidden shrink-0 border-r border-border bg-bg-1" style={{ width: '370px', minWidth: '370px' }}>
|
||||
<div className="flex items-center justify-between border-b border-border px-[14px] py-2.5">
|
||||
<span className="text-[11px] font-bold text-text-3">
|
||||
<div className="flex flex-col overflow-hidden shrink-0 border-r border-stroke bg-bg-surface" style={{ width: '370px', minWidth: '370px' }}>
|
||||
<div className="flex items-center justify-between border-b border-stroke px-[14px] py-2.5">
|
||||
<span className="text-[11px] font-bold text-fg-disabled">
|
||||
시나리오 목록 — 톨루엔 대기확산
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{['시간순', '위험도순'].map((label, i) => (
|
||||
<button key={i} className="cursor-pointer px-2 py-[3px] text-[9px] font-semibold rounded-sm border border-border" style={{
|
||||
background: i === 0 ? 'rgba(249,115,22,0.08)' : 'var(--bg3)',
|
||||
color: i === 0 ? 'var(--orange)' : 'var(--t3)',
|
||||
<button key={i} className="cursor-pointer px-2 py-[3px] text-[9px] font-semibold rounded-sm border border-stroke" style={{
|
||||
background: i === 0 ? 'rgba(249,115,22,0.08)' : 'var(--bg-card)',
|
||||
color: i === 0 ? 'var(--color-warning)' : 'var(--fg-disabled)',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
@ -193,7 +193,7 @@ export function HNSScenarioView() {
|
||||
</div>
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-1.5 p-2" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-1.5 p-2" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
{scenarios.map((scn, idx) => {
|
||||
const sev = SEVERITY_STYLE[scn.severity]
|
||||
const isSel = selectedIdx === idx
|
||||
@ -211,7 +211,7 @@ export function HNSScenarioView() {
|
||||
checked={checked.has(idx)}
|
||||
onChange={() => toggleCheck(idx)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ accentColor: 'var(--orange)' }}
|
||||
style={{ accentColor: 'var(--color-warning)' }}
|
||||
/>
|
||||
<span className="text-[12px] font-bold">
|
||||
{scn.id} {scn.name}
|
||||
@ -224,11 +224,11 @@ export function HNSScenarioView() {
|
||||
|
||||
{/* Time row */}
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<span className="font-bold font-mono text-status-orange text-[9px] px-1.5 py-[2px] rounded-[3px]" style={{ background: 'rgba(249,115,22,0.1)' }}>
|
||||
<span className="font-bold font-mono text-color-warning text-[9px] px-1.5 py-[2px] rounded-[3px]" style={{ background: 'rgba(249,115,22,0.1)' }}>
|
||||
{scn.timeStep}
|
||||
</span>
|
||||
<span className="text-[9px] text-text-3 font-mono">{scn.datetime}</span>
|
||||
<span className="ml-auto text-text-3 text-[8px]">{scn.wind}</span>
|
||||
<span className="text-[9px] text-fg-disabled font-mono">{scn.datetime}</span>
|
||||
<span className="ml-auto text-fg-disabled text-[8px]">{scn.wind}</span>
|
||||
</div>
|
||||
|
||||
{/* Metrics grid */}
|
||||
@ -239,15 +239,15 @@ export function HNSScenarioView() {
|
||||
{ label: 'ERPG-2', value: scn.erpg2, color: '#f97316' },
|
||||
{ label: '영향인구', value: scn.population, color: '#f87171' },
|
||||
].map((m, i) => (
|
||||
<div key={i} className="text-center p-[3px] bg-bg-0 rounded-[3px]">
|
||||
<div className="text-text-3 text-[7px]">{m.label}</div>
|
||||
<div key={i} className="text-center p-[3px] bg-bg-base rounded-[3px]">
|
||||
<div className="text-fg-disabled text-[7px]">{m.label}</div>
|
||||
<div className="font-bold" style={{ color: m.color }}>{m.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-text-2 mt-1.5 text-[8px] leading-[1.4]">
|
||||
<div className="text-fg-sub mt-1.5 text-[8px] leading-[1.4]">
|
||||
{scn.description}
|
||||
</div>
|
||||
</div>
|
||||
@ -256,10 +256,10 @@ export function HNSScenarioView() {
|
||||
</div>
|
||||
|
||||
{/* Bottom buttons */}
|
||||
<div className="flex gap-2 border-t border-border px-[14px] py-2.5">
|
||||
<div className="flex gap-2 border-t border-stroke px-[14px] py-2.5">
|
||||
<button
|
||||
onClick={() => setActiveView(1)}
|
||||
className="flex-1 cursor-pointer font-bold text-status-orange text-[11px] p-2 rounded-sm"
|
||||
className="flex-1 cursor-pointer font-bold text-color-warning text-[11px] p-2 rounded-sm"
|
||||
style={{
|
||||
background: 'rgba(249,115,22,0.1)',
|
||||
border: '1px solid rgba(249,115,22,0.3)',
|
||||
@ -267,7 +267,7 @@ export function HNSScenarioView() {
|
||||
>
|
||||
📊 선택 시나리오 비교
|
||||
</button>
|
||||
<button className="cursor-pointer font-semibold text-text-2 text-[11px] px-[14px] py-2 rounded-sm bg-bg-3 border border-border">
|
||||
<button className="cursor-pointer font-semibold text-fg-sub text-[11px] px-[14px] py-2 rounded-sm bg-bg-card border border-stroke">
|
||||
📄 보고서
|
||||
</button>
|
||||
</div>
|
||||
@ -276,7 +276,7 @@ export function HNSScenarioView() {
|
||||
{/* ── Right: Detail Views ── */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{/* View Tabs */}
|
||||
<div className="flex border-b border-border shrink-0 px-4 bg-bg-1">
|
||||
<div className="flex border-b border-stroke shrink-0 px-4 bg-bg-surface">
|
||||
{['📋 시나리오 상세', '📊 비교 차트', '🗺 확산범위 오버레이'].map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
@ -338,7 +338,7 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
}}>
|
||||
{scenario.severity}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-text-3 font-mono">
|
||||
<span className="ml-auto text-[10px] text-fg-disabled font-mono">
|
||||
{scenario.datetime}
|
||||
</span>
|
||||
</div>
|
||||
@ -347,12 +347,12 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
{ label: '최대농도', value: d.maxConc, color: '#f87171' },
|
||||
{ label: 'IDLH 반경', value: d.idlhRadius, color: '#f87171' },
|
||||
{ label: 'ERPG-2', value: d.erpg2, color: '#f97316' },
|
||||
{ label: '풍향/풍속', value: `${d.windDir}\n${d.windSpeed}`, color: 'var(--cyan)' },
|
||||
{ label: '풍향/풍속', value: `${d.windDir}\n${d.windSpeed}`, color: 'var(--color-accent)' },
|
||||
{ label: '영향인구', value: d.population, color: '#f87171' },
|
||||
{ label: '유출량', value: d.spillAmount, color: 'var(--orange)' },
|
||||
{ label: '유출량', value: d.spillAmount, color: 'var(--color-warning)' },
|
||||
].map((m, i) => (
|
||||
<div key={i} className="text-center rounded-sm p-2" style={{ background: 'rgba(0,0,0,0.15)' }}>
|
||||
<div className="text-text-3 text-[8px]">{m.label}</div>
|
||||
<div className="text-fg-disabled text-[8px]">{m.label}</div>
|
||||
<div className="text-base font-bold font-mono whitespace-pre-line mt-[2px]" style={{ color: m.color, lineHeight: 1.2 }}>{m.value}</div>
|
||||
</div>
|
||||
))}
|
||||
@ -362,7 +362,7 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
{/* Two-column section */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Threat Zones */}
|
||||
<div className="rounded-md border border-border bg-bg-2 p-[14px]">
|
||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
||||
<h4 className="text-[12px] font-bold mb-2.5">
|
||||
⚠️ 위험 구역
|
||||
</h4>
|
||||
@ -373,8 +373,8 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
{ label: 'ERPG-1 (주의권고)', value: scenario.zones.erpg1, color: '#fbbf24' },
|
||||
{ label: 'TWA (작업허용)', value: scenario.zones.twa, color: '#22c55e' },
|
||||
].map((z, i) => (
|
||||
<div key={i} className="flex justify-between items-center bg-bg-0 rounded-sm" style={{ padding: '6px 8px', borderLeft: `3px solid ${z.color}` }}>
|
||||
<span className="text-[10px] text-text-2">{z.label}</span>
|
||||
<div key={i} className="flex justify-between items-center bg-bg-base rounded-sm" style={{ padding: '6px 8px', borderLeft: `3px solid ${z.color}` }}>
|
||||
<span className="text-[10px] text-fg-sub">{z.label}</span>
|
||||
<span className="text-[11px] font-bold font-mono" style={{ color: z.color }}>{z.value}</span>
|
||||
</div>
|
||||
))}
|
||||
@ -382,14 +382,14 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="rounded-md border border-border bg-bg-2 p-[14px]">
|
||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
||||
<h4 className="text-[12px] font-bold mb-2.5">
|
||||
🛡 대응 권고 사항
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{scenario.actions.map((action, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[10px] text-text-2 bg-bg-0 rounded-sm leading-[1.4] py-[5px] px-2">
|
||||
<span className="text-status-orange font-bold shrink-0">•</span>
|
||||
<div key={i} className="flex items-start gap-1.5 text-[10px] text-fg-sub bg-bg-base rounded-sm leading-[1.4] py-[5px] px-2">
|
||||
<span className="text-color-warning font-bold shrink-0">•</span>
|
||||
{action}
|
||||
</div>
|
||||
))}
|
||||
@ -398,7 +398,7 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
</div>
|
||||
|
||||
{/* Weather */}
|
||||
<div className="rounded-md border border-border bg-bg-2 p-[14px]">
|
||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
||||
<h4 className="text-[12px] font-bold mb-2.5">
|
||||
🌊 기상 조건
|
||||
</h4>
|
||||
@ -411,10 +411,10 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
{ label: '습도', value: scenario.weather.humidity, icon: '💧' },
|
||||
{ label: '혼합층', value: scenario.weather.mixHeight, icon: '📏' },
|
||||
].map((w, i) => (
|
||||
<div key={i} className="text-center p-2 rounded-sm bg-bg-0">
|
||||
<div key={i} className="text-center p-2 rounded-sm bg-bg-base">
|
||||
<div className="text-sm mb-0.5">{w.icon}</div>
|
||||
<div className="text-[12px] font-bold font-mono">{w.value}</div>
|
||||
<div className="text-text-3 mt-0.5 text-[8px]">{w.label}</div>
|
||||
<div className="text-fg-disabled mt-0.5 text-[8px]">{w.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -456,14 +456,14 @@ function ScenarioComparison() {
|
||||
const barX = [30, 70, 110, 150, 190]
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-3.5 px-5 py-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-3.5 px-5 py-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
{/* Title */}
|
||||
<div className="text-[13px] font-bold mb-0.5">
|
||||
📊 시나리오 비교 — 시간대별 대기확산 지표 추이
|
||||
</div>
|
||||
|
||||
{/* ── Chart 1: 최대 지표면 농도 추이 (Line + Area) ── */}
|
||||
<div className="rounded-md border border-border bg-bg-3 p-[14px]">
|
||||
<div className="rounded-md border border-stroke bg-bg-card p-[14px]">
|
||||
<div className="text-[11px] font-bold mb-2.5">
|
||||
최대 지표면 농도 (ppm) 변화 추이
|
||||
</div>
|
||||
@ -479,9 +479,9 @@ function ScenarioComparison() {
|
||||
<line x1="50" y1="120" x2="480" y2="120" stroke="#21262d" strokeWidth={0.5} />
|
||||
{/* Threshold lines */}
|
||||
<line x1="50" y1={10 + (1 - 500 / concMax) * 110} x2="480" y2={10 + (1 - 500 / concMax) * 110} stroke="rgba(239,68,68,.2)" strokeWidth={0.5} strokeDasharray="4,3" />
|
||||
<text x="5" y={10 + (1 - 500 / concMax) * 110 + 4} fill="#f87171" fontSize="7" fontFamily="var(--fM)">500 IDLH</text>
|
||||
<text x="5" y={10 + (1 - 500 / concMax) * 110 + 4} fill="#f87171" fontSize="7" fontFamily="var(--font-mono)">500 IDLH</text>
|
||||
<line x1="50" y1={10 + (1 - 300 / concMax) * 110} x2="480" y2={10 + (1 - 300 / concMax) * 110} stroke="rgba(249,115,22,.2)" strokeWidth={0.5} strokeDasharray="4,3" />
|
||||
<text x="5" y={10 + (1 - 300 / concMax) * 110 + 4} fill="#fb923c" fontSize="7" fontFamily="var(--fM)">300 ERPG2</text>
|
||||
<text x="5" y={10 + (1 - 300 / concMax) * 110 + 4} fill="#fb923c" fontSize="7" fontFamily="var(--font-mono)">300 ERPG2</text>
|
||||
{/* Area fill */}
|
||||
<polygon points={concArea} fill="url(#hnsGrad)" />
|
||||
{/* Line */}
|
||||
@ -490,8 +490,8 @@ function ScenarioComparison() {
|
||||
{D.map((d, i) => (
|
||||
<g key={d.id}>
|
||||
<circle cx={concX[i]} cy={concY[i]} r={4} fill={SEV_COLOR[d.severity]} stroke="#0d1117" strokeWidth={2} />
|
||||
<text x={concX[i] - 6} y={concY[i] - 8} fill={SEV_COLOR[d.severity]} fontSize="7" textAnchor="end" fontFamily="var(--fM)">{d.conc}</text>
|
||||
<text x={concX[i]} y={134} fill="#8b949e" fontSize="7" textAnchor="middle" fontFamily="var(--fM)">{d.label}</text>
|
||||
<text x={concX[i] - 6} y={concY[i] - 8} fill={SEV_COLOR[d.severity]} fontSize="7" textAnchor="end" fontFamily="var(--font-mono)">{d.conc}</text>
|
||||
<text x={concX[i]} y={134} fill="#8b949e" fontSize="7" textAnchor="middle" fontFamily="var(--font-mono)">{d.label}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
@ -500,7 +500,7 @@ function ScenarioComparison() {
|
||||
{/* ── Charts 2 & 3: 2-column grid ── */}
|
||||
<div className="grid grid-cols-2 gap-[14px]">
|
||||
{/* Chart 2: 위험 반경 변화 (Multi-line) */}
|
||||
<div className="rounded-md border border-border bg-bg-3 p-[14px]">
|
||||
<div className="rounded-md border border-stroke bg-bg-card p-[14px]">
|
||||
<div className="text-[11px] font-bold mb-2.5">
|
||||
위험 반경 (km) 변화
|
||||
</div>
|
||||
@ -517,20 +517,20 @@ function ScenarioComparison() {
|
||||
return (
|
||||
<g key={d.id}>
|
||||
<circle cx={radX[i]} cy={idlhY[i]} r={3} fill={c} stroke="#0d1117" strokeWidth={1.5} />
|
||||
<text x={radX[i]} y={96} fill="#8b949e" fontSize="6" textAnchor="middle" fontFamily="var(--fM)">{d.label.replace('+', '+')}</text>
|
||||
<text x={radX[i]} y={96} fill="#8b949e" fontSize="6" textAnchor="middle" fontFamily="var(--font-mono)">{d.label.replace('+', '+')}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* Legend */}
|
||||
<line x1="170" y1="14" x2="185" y2="14" stroke="#ef4444" strokeWidth={1.5} />
|
||||
<text x="188" y="17" fill="#ef4444" fontSize="6" fontFamily="var(--fK)">IDLH</text>
|
||||
<text x="188" y="17" fill="#ef4444" fontSize="6" fontFamily="var(--font-korean)">IDLH</text>
|
||||
<line x1="170" y1="24" x2="185" y2="24" stroke="#f97316" strokeWidth={1.5} strokeDasharray="4,2" />
|
||||
<text x="188" y="27" fill="#f97316" fontSize="6" fontFamily="var(--fK)">ERPG-2</text>
|
||||
<text x="188" y="27" fill="#f97316" fontSize="6" fontFamily="var(--font-korean)">ERPG-2</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Chart 3: 영향 인구 변화 (Bar) */}
|
||||
<div className="rounded-md border border-border bg-bg-3 p-[14px]">
|
||||
<div className="rounded-md border border-stroke bg-bg-card p-[14px]">
|
||||
<div className="text-[11px] font-bold mb-2.5">
|
||||
영향 인구 (명) 변화
|
||||
</div>
|
||||
@ -545,10 +545,10 @@ function ScenarioComparison() {
|
||||
return (
|
||||
<g key={d.id}>
|
||||
<rect x={barX[i]} y={y} width={barW} height={h} rx={2} fill={barColor} />
|
||||
<text x={barX[i] + barW / 2} y={y - 3} fill={color} fontSize="6" textAnchor="middle" fontFamily="var(--fM)">
|
||||
<text x={barX[i] + barW / 2} y={y - 3} fill={color} fontSize="6" textAnchor="middle" fontFamily="var(--font-mono)">
|
||||
{d.pop > 0 ? d.pop.toLocaleString() : '0'}
|
||||
</text>
|
||||
<text x={barX[i] + barW / 2} y={96} fill="#8b949e" fontSize="6" textAnchor="middle" fontFamily="var(--fM)">{d.label.replace('+', '+')}</text>
|
||||
<text x={barX[i] + barW / 2} y={96} fill="#8b949e" fontSize="6" textAnchor="middle" fontFamily="var(--font-mono)">{d.label.replace('+', '+')}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
@ -557,15 +557,15 @@ function ScenarioComparison() {
|
||||
</div>
|
||||
|
||||
{/* ── Chart 4: 시나리오 비교표 ── */}
|
||||
<div className="rounded-md border border-border overflow-x-auto bg-bg-3 p-[14px]">
|
||||
<div className="rounded-md border border-stroke overflow-x-auto bg-bg-card p-[14px]">
|
||||
<div className="text-[11px] font-bold mb-2.5">
|
||||
📋 시나리오 비교표
|
||||
</div>
|
||||
<table className="w-full text-[10px] border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-0">
|
||||
<tr className="bg-bg-base">
|
||||
{['지표', ...D.map(d => `${d.id} (${d.label})`)].map((h, i) => (
|
||||
<th key={i} className="text-text-3 border-b border-border text-[9px] px-[10px] py-2" style={{ textAlign: i === 0 ? 'left' : 'center' }}>
|
||||
<th key={i} className="text-fg-disabled border-b border-stroke text-[9px] px-[10px] py-2" style={{ textAlign: i === 0 ? 'left' : 'center' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
@ -573,43 +573,43 @@ function ScenarioComparison() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* 최대농도 */}
|
||||
<tr className="border-b border-border">
|
||||
<td className="text-text-2 px-[10px] py-1.5">최대농도 (ppm)</td>
|
||||
<tr className="border-b border-stroke">
|
||||
<td className="text-fg-sub px-[10px] py-1.5">최대농도 (ppm)</td>
|
||||
{D.map(d => (
|
||||
<td key={d.id} className="text-center font-mono font-semibold p-1.5" style={{ color: SEV_COLOR[d.severity] }}>{d.conc}</td>
|
||||
))}
|
||||
</tr>
|
||||
{/* IDLH 반경 */}
|
||||
<tr className="border-b border-border">
|
||||
<td className="text-text-2 px-[10px] py-1.5">IDLH 반경 (km)</td>
|
||||
<tr className="border-b border-stroke">
|
||||
<td className="text-fg-sub px-[10px] py-1.5">IDLH 반경 (km)</td>
|
||||
{D.map(d => (
|
||||
<td key={d.id} className="text-center font-mono font-semibold p-1.5" style={{ color: d.idlh > 0 ? '#f87171' : '#22c55e' }}>{d.idlh || 0}</td>
|
||||
))}
|
||||
</tr>
|
||||
{/* ERPG-2 반경 */}
|
||||
<tr className="border-b border-border">
|
||||
<td className="text-text-2 px-[10px] py-1.5">ERPG-2 반경 (km)</td>
|
||||
<tr className="border-b border-stroke">
|
||||
<td className="text-fg-sub px-[10px] py-1.5">ERPG-2 반경 (km)</td>
|
||||
{D.map(d => (
|
||||
<td key={d.id} className="text-center font-mono font-semibold p-1.5" style={{ color: d.erpg2 > 0 ? '#f97316' : '#22c55e' }}>{d.erpg2 || 0}</td>
|
||||
))}
|
||||
</tr>
|
||||
{/* 영향인구 */}
|
||||
<tr className="border-b border-border">
|
||||
<td className="text-text-2 px-[10px] py-1.5">영향인구 (명)</td>
|
||||
<tr className="border-b border-stroke">
|
||||
<td className="text-fg-sub px-[10px] py-1.5">영향인구 (명)</td>
|
||||
{D.map(d => (
|
||||
<td key={d.id} className="text-center font-mono font-semibold p-1.5" style={{ color: SEV_COLOR[d.severity] }}>{d.pop.toLocaleString()}</td>
|
||||
))}
|
||||
</tr>
|
||||
{/* 풍향/풍속 */}
|
||||
<tr className="border-b border-border">
|
||||
<td className="text-text-2 px-[10px] py-1.5">풍향 / 풍속</td>
|
||||
<tr className="border-b border-stroke">
|
||||
<td className="text-fg-sub px-[10px] py-1.5">풍향 / 풍속</td>
|
||||
{D.map(d => (
|
||||
<td key={d.id} className="text-center font-mono text-primary-cyan p-1.5">{d.wind}</td>
|
||||
<td key={d.id} className="text-center font-mono text-color-accent p-1.5">{d.wind}</td>
|
||||
))}
|
||||
</tr>
|
||||
{/* 위험 등급 */}
|
||||
<tr>
|
||||
<td className="text-text-2 px-[10px] py-1.5">위험 등급</td>
|
||||
<td className="text-fg-sub px-[10px] py-1.5">위험 등급</td>
|
||||
{D.map(d => (
|
||||
<td key={d.id} className="text-center font-bold p-1.5" style={{ color: SEV_COLOR[d.severity] }}>{d.severity}</td>
|
||||
))}
|
||||
@ -625,7 +625,7 @@ function ScenarioComparison() {
|
||||
function ScenarioMapOverlay() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center flex-col gap-4">
|
||||
<div className="flex items-center justify-center rounded-md border border-border text-text-3 text-[13px] bg-bg-2" style={{
|
||||
<div className="flex items-center justify-center rounded-md border border-stroke text-fg-disabled text-[13px] bg-bg-elevated" style={{
|
||||
width: '80%', maxWidth: '600px', height: '300px',
|
||||
}}>
|
||||
[시나리오별 확산범위 오버레이 지도]
|
||||
@ -639,7 +639,7 @@ function ScenarioMapOverlay() {
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full opacity-50" style={{ background: item.color }} />
|
||||
<span className="text-[10px] text-text-2">{item.label}</span>
|
||||
<span className="text-[10px] text-fg-sub">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -685,9 +685,9 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
|
||||
return (
|
||||
<div ref={backdropRef} className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)' }}>
|
||||
<div className="flex flex-col overflow-hidden rounded-[14px] bg-bg-1 border border-border w-[520px] max-h-[calc(100vh-80px)]" style={{ boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
|
||||
<div className="flex flex-col overflow-hidden rounded-[14px] bg-bg-surface border border-stroke w-[520px] max-h-[calc(100vh-80px)]" style={{ boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-3 border-b border-stroke px-5 py-4">
|
||||
<div className="flex items-center justify-center text-base w-9 h-9 rounded-[10px]" style={{
|
||||
background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(239,68,68,0.15))',
|
||||
border: '1px solid rgba(249,115,22,0.3)',
|
||||
@ -696,11 +696,11 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
<h2 className="text-[15px] font-bold m-0">
|
||||
신규 HNS 대기확산 시나리오
|
||||
</h2>
|
||||
<div className="text-[10px] text-text-3 mt-0.5">
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5">
|
||||
물질·기상·유출조건을 설정하여 새 시나리오를 생성합니다
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="flex items-center justify-center w-7 h-7 cursor-pointer text-text-3 text-[12px] rounded-sm border border-border bg-bg-3">✕</button>
|
||||
<button onClick={onClose} className="flex items-center justify-center w-7 h-7 cursor-pointer text-fg-disabled text-[12px] rounded-sm border border-stroke bg-bg-card">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
@ -743,8 +743,8 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
{ label: 'ERPG-2', value: mat.erpg2 },
|
||||
].map((p, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="text-text-3 text-[8px]">{p.label}</div>
|
||||
<div className="text-[10px] font-bold text-status-orange font-mono">{p.value}</div>
|
||||
<div className="text-fg-disabled text-[8px]">{p.label}</div>
|
||||
<div className="text-[10px] font-bold text-color-warning font-mono">{p.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -803,11 +803,11 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-2 border-t border-border px-5 py-[14px]">
|
||||
<button onClick={onClose} className="flex-1 text-[12px] font-semibold cursor-pointer rounded-md text-text-2 p-[10px] bg-bg-3 border border-border">취소</button>
|
||||
<div className="flex gap-2 border-t border-stroke px-5 py-[14px]">
|
||||
<button onClick={onClose} className="flex-1 text-[12px] font-semibold cursor-pointer rounded-md text-fg-sub p-[10px] bg-bg-card border border-stroke">취소</button>
|
||||
<button onClick={handleSubmit} className="cursor-pointer rounded-md text-[12px] font-bold text-white p-[10px]" style={{
|
||||
flex: 2,
|
||||
background: 'linear-gradient(135deg, var(--orange), #ef4444)',
|
||||
background: 'linear-gradient(135deg, var(--color-warning), #ef4444)',
|
||||
border: 'none',
|
||||
opacity: name.trim() ? 1 : 0.5,
|
||||
}}>
|
||||
@ -823,7 +823,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
function ModalSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-status-orange mb-2 pb-1" style={{ borderBottom: '1px solid rgba(249,115,22,0.15)' }}>{title}</div>
|
||||
<div className="text-[11px] font-bold text-color-warning mb-2 pb-1" style={{ borderBottom: '1px solid rgba(249,115,22,0.15)' }}>{title}</div>
|
||||
<div className="flex flex-col gap-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
@ -832,7 +832,7 @@ function ModalSection({ title, children }: { title: string; children: React.Reac
|
||||
function ModalField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[9px] font-semibold text-text-3 mb-1">{label}</div>
|
||||
<div className="text-[9px] font-semibold text-fg-disabled mb-1">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -25,41 +25,41 @@ function HNSManualViewer() {
|
||||
const card = 'rounded-md p-4 mb-3'
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-bg-0" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-base" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
<div className="px-5 py-4 max-w-[1200px] mx-auto">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="text-base font-bold">📖 해양 HNS 대응 매뉴얼</div>
|
||||
<div className="text-[10px] text-text-3 mt-0.5">Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo 2024 한국어판)</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5">Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo 2024 한국어판)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목차 카드 그리드 */}
|
||||
<div className="grid mb-5" style={{ gridTemplateColumns: 'repeat(4,1fr)', gap: '10px' }}>
|
||||
{[
|
||||
{ icon: '📘', title: '1. 서론', desc: 'HNS 정의 · OPRC-HNS 의정서 · HNS 협약 범위 및 목적', color: 'var(--cyan)' },
|
||||
{ icon: '⚖️', title: '2. IMO 협약·의정서·규칙', desc: 'SOLAS · MARPOL · IBC Code · IMDG Code · IGC Code', color: 'var(--cyan)' },
|
||||
{ icon: '🔬', title: '3. HNS 거동 및 유해요소', desc: 'SEBC 거동분류 · MSDS · GESAMP · 물리화학적 특성', color: 'var(--purple)' },
|
||||
{ icon: '🛡️', title: '4. 대비', desc: '위험 평가 · 비상 계획 · 교육훈련 · 장비 비축', color: 'var(--orange)' },
|
||||
{ icon: '🚨', title: '5. 대응', desc: '최초 조치 · 안전구역 · PPE · 모니터링 · 대응 기술', color: 'var(--red)' },
|
||||
{ icon: '🔄', title: '6. 유출 후 관리', desc: '비용 문서화 · 환경 회복 · 사고 검토 · 교훈', color: 'var(--cyan)' },
|
||||
{ icon: '📋', title: '7. 사례연구', desc: '실제 HNS 해양사고 사례 분석 및 교훈', color: 'var(--cyan)' },
|
||||
{ icon: '📊', title: '8. 자료표', desc: '물질별 데이터시트 · AEGL · 노출 한계값', color: 'var(--cyan)' },
|
||||
{ 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-tertiary)' },
|
||||
{ icon: '🛡️', title: '4. 대비', desc: '위험 평가 · 비상 계획 · 교육훈련 · 장비 비축', color: 'var(--color-warning)' },
|
||||
{ icon: '🚨', title: '5. 대응', desc: '최초 조치 · 안전구역 · PPE · 모니터링 · 대응 기술', color: 'var(--color-danger)' },
|
||||
{ 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 => (
|
||||
<div key={ch.title} className={card} style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', cursor: 'pointer', transition: '.2s' }}>
|
||||
<div key={ch.title} className={card} style={{ background: 'var(--bg-card)', border: '1px solid var(--stroke-default)', cursor: 'pointer', transition: '.2s' }}>
|
||||
<div className="text-[20px] mb-1.5">{ch.icon}</div>
|
||||
<div className="text-[11px] font-bold">{ch.title}</div>
|
||||
<div className="text-[9px] text-text-3 mt-1 leading-[1.4]">{ch.desc}</div>
|
||||
<div className="text-[9px] text-fg-disabled mt-1 leading-[1.4]">{ch.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SEBC 거동 분류 */}
|
||||
<div className={`${card} bg-bg-3 border border-border`}>
|
||||
<div className={`${card} bg-bg-card border border-stroke`}>
|
||||
<div className="text-[13px] font-bold mb-2.5">SEBC 거동 분류 (Standard European Behaviour Classification)</div>
|
||||
<div className="text-[9px] text-text-3 mb-2.5 leading-normal">물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요 범주 + 7가지 하위 범주로 분류</div>
|
||||
<div className="text-[9px] text-fg-disabled mb-2.5 leading-normal">물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요 범주 + 7가지 하위 범주로 분류</div>
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(5,1fr)' }}>
|
||||
{[
|
||||
{ icon: '💨', label: 'G — 가스', desc: '대기 중 확산\n증기압 > 101.3kPa\n예: 암모니아, 염소', color: 'rgba(139,92,246' },
|
||||
@ -71,14 +71,14 @@ function HNSManualViewer() {
|
||||
<div key={s.label} className="text-center px-[6px] py-[10px] rounded-sm" style={{ background: `${s.color},.08)`, border: `1px solid ${s.color},.2)` }}>
|
||||
<div className="text-[22px] mb-1">{s.icon}</div>
|
||||
<div className="text-[11px] font-bold" style={{ color: `${s.color},1)` }}>{s.label}</div>
|
||||
<div className="text-text-3 whitespace-pre-line text-[8px] mt-[3px] leading-[1.3]">{s.desc}</div>
|
||||
<div className="text-fg-disabled whitespace-pre-line text-[8px] mt-[3px] leading-[1.3]">{s.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출처 */}
|
||||
<div className="text-text-3 rounded-sm bg-bg-3 p-[10px] text-[8px] leading-[1.5]">
|
||||
<div className="text-fg-disabled rounded-sm bg-bg-card p-[10px] text-[8px] leading-[1.5]">
|
||||
<b>출처:</b> Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo Project, 2024 한국어판)<br />
|
||||
번역: 원해민, 이시연, 양보경, 강성길, 이성엽 — KRISO 선박해양플랜트연구소 / NOWPAP MERRAC<br />
|
||||
원본: Alcaro L., Brandt J., Giraud W., Mannozzi M., Nicolas-Kopec A. (2021) ISBN: 978-2-87893-147-1
|
||||
@ -131,8 +131,8 @@ function DispersionTimeSlider({
|
||||
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-[9px]">
|
||||
<span className="text-primary-cyan font-mono font-bold">t = {currentTime}s</span>
|
||||
<span className="text-text-3 font-mono">{endTime}s</span>
|
||||
<span className="text-color-accent font-mono font-bold">t = {currentTime}s</span>
|
||||
<span className="text-fg-disabled font-mono">{endTime}s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
@ -140,12 +140,12 @@ function DispersionTimeSlider({
|
||||
max={totalFrames - 1}
|
||||
value={currentFrame}
|
||||
onChange={(e) => onFrameChange(parseInt(e.target.value))}
|
||||
className="w-full h-1 accent-[var(--cyan)]"
|
||||
className="w-full h-1 accent-[var(--color-accent)]"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-[9px] text-text-3 font-mono whitespace-nowrap">
|
||||
<div className="text-[9px] text-fg-disabled font-mono whitespace-nowrap">
|
||||
{currentFrame + 1}/{totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -51,9 +51,9 @@ function getZoneIndex(distanceNm: number): number {
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: Status }) {
|
||||
if (status === 'forbidden') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(239,68,68,0.15)', color: '#ef4444' }}>배출불가</span>
|
||||
if (status === 'allowed') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(34,197,94,0.15)', color: '#22c55e' }}>배출가능</span>
|
||||
return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(234,179,8,0.15)', color: '#eab308' }}>조건부</span>
|
||||
if (status === 'forbidden') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}>배출불가</span>
|
||||
if (status === 'allowed') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' }}>배출가능</span>
|
||||
return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' }}>조건부</span>
|
||||
}
|
||||
|
||||
interface DischargeZonePanelProps {
|
||||
@ -75,8 +75,8 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
style={{
|
||||
width: 320,
|
||||
maxHeight: 'calc(100% - 32px)',
|
||||
background: 'rgba(13,17,23,0.95)',
|
||||
border: '1px solid #30363d',
|
||||
background: 'var(--bg-base)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
@ -86,25 +86,25 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid #30363d',
|
||||
background: 'linear-gradient(135deg, #1c2333, #161b22)',
|
||||
borderBottom: '1px solid var(--stroke-default)',
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-[#f0f6fc] font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-[8px] text-[#8b949e] font-korean">해양환경관리법 제22조</div>
|
||||
<div className="text-[11px] font-bold text-fg font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-[8px] text-fg-sub font-korean">해양환경관리법 제22조</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="text-[14px] cursor-pointer text-[#8b949e] hover:text-[#f0f6fc]">✕</span>
|
||||
<span onClick={onClose} className="text-[14px] cursor-pointer text-fg-sub hover:text-fg">✕</span>
|
||||
</div>
|
||||
|
||||
{/* Location Info */}
|
||||
<div className="shrink-0" style={{ padding: '8px 14px', borderBottom: '1px solid #21262d' }}>
|
||||
<div className="shrink-0" style={{ padding: '8px 14px', borderBottom: '1px solid var(--stroke-light)' }}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[9px] text-[#8b949e] font-korean">선택 위치</span>
|
||||
<span className="text-[9px] text-[#c9d1d9] font-mono">{lat.toFixed(4)}°N, {lon.toFixed(4)}°E</span>
|
||||
<span className="text-[9px] text-fg-sub font-korean">선택 위치</span>
|
||||
<span className="text-[9px] text-fg font-mono">{lat.toFixed(4)}°N, {lon.toFixed(4)}°E</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[9px] text-[#8b949e] font-korean">영해기선 거리 (추정)</span>
|
||||
<span className="text-[9px] text-fg-sub font-korean">영해기선 거리 (추정)</span>
|
||||
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
||||
{distanceNm.toFixed(1)} NM
|
||||
</span>
|
||||
@ -119,9 +119,9 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
padding: '3px 0',
|
||||
fontSize: 8,
|
||||
fontWeight: i === zoneIdx ? 700 : 400,
|
||||
color: i === zoneIdx ? '#fff' : '#8b949e',
|
||||
background: i === zoneIdx ? ZONE_COLORS[i] : 'rgba(255,255,255,0.04)',
|
||||
border: i === zoneIdx ? 'none' : '1px solid #21262d',
|
||||
color: i === zoneIdx ? 'var(--fg-default)' : 'var(--fg-sub)',
|
||||
background: i === zoneIdx ? ZONE_COLORS[i] : 'var(--hover-overlay)',
|
||||
border: i === zoneIdx ? 'none' : '1px solid var(--stroke-light)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@ -131,7 +131,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#30363d transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-default) transparent' }}>
|
||||
{categories.map(cat => {
|
||||
const catRules = RULES.filter(r => r.category === cat)
|
||||
const isExpanded = expandedCat === cat
|
||||
@ -140,7 +140,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
const summaryColor = allForbidden ? '#ef4444' : allAllowed ? '#22c55e' : '#eab308'
|
||||
|
||||
return (
|
||||
<div key={cat} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<div key={cat} style={{ borderBottom: '1px solid var(--stroke-light)' }}>
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setExpandedCat(isExpanded ? null : cat)}
|
||||
@ -148,13 +148,13 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }} />
|
||||
<span className="text-[10px] font-bold text-[#c9d1d9] font-korean">{cat}</span>
|
||||
<span className="text-[10px] font-bold text-fg font-korean">{cat}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[8px] font-semibold" style={{ color: summaryColor }}>
|
||||
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
|
||||
</span>
|
||||
<span className="text-[9px] text-[#8b949e]">{isExpanded ? '▾' : '▸'}</span>
|
||||
<span className="text-[9px] text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -167,18 +167,18 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
marginBottom: 2,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
background: 'var(--hover-overlay)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] text-[#c9d1d9] font-korean">{rule.item}</span>
|
||||
<span className="text-[9px] text-fg font-korean">{rule.item}</span>
|
||||
<StatusBadge status={rule.zones[zoneIdx]} />
|
||||
</div>
|
||||
))}
|
||||
{catRules.some(r => r.condition && r.zones[zoneIdx] !== 'forbidden') && (
|
||||
<div className="mt-1" style={{ padding: '4px 8px' }}>
|
||||
{catRules.filter(r => r.condition && r.zones[zoneIdx] !== 'forbidden').map((r, i) => (
|
||||
<div key={i} className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
|
||||
<div key={i} className="text-[7px] text-fg-sub font-korean leading-relaxed">
|
||||
💡 {r.item}: {r.condition}
|
||||
</div>
|
||||
))}
|
||||
@ -192,9 +192,9 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0" style={{ padding: '6px 14px', borderTop: '1px solid #21262d' }}>
|
||||
<div className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
|
||||
※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있을 수 있습니다.
|
||||
<div className="shrink-0" style={{ padding: '6px 14px', borderTop: '1px solid var(--stroke-light)' }}>
|
||||
<div className="text-[7px] text-fg-sub font-korean leading-relaxed">
|
||||
※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,15 +18,15 @@ export function IncidentTable() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-text-1">유출유 확산 예측 목록</h1>
|
||||
<p className="text-sm text-text-3 mt-1">총 {filteredIncidents.length}건</p>
|
||||
<h1 className="text-xl font-bold text-fg">유출유 확산 예측 목록</h1>
|
||||
<p className="text-sm text-fg-disabled mt-1">총 {filteredIncidents.length}건</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-4 py-2 text-sm font-semibold border border-border rounded-md bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1 transition-all">
|
||||
<button className="px-4 py-2 text-sm font-semibold border border-stroke rounded-md bg-bg-card text-fg-sub hover:bg-bg-surface-hover hover:text-fg transition-all">
|
||||
시간별 검색
|
||||
</button>
|
||||
<div className="relative">
|
||||
@ -35,10 +35,10 @@ export function IncidentTable() {
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-64 px-4 py-2 text-sm bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none"
|
||||
className="w-64 px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
|
||||
<button className="px-4 py-2 text-sm font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
|
||||
+ 새 분석
|
||||
</button>
|
||||
</div>
|
||||
@ -47,47 +47,47 @@ export function IncidentTable() {
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-bg-1 border-b border-border z-10">
|
||||
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">번호</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고시각</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">선박유형</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">유종</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider">유출량</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고유형</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">분석자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">번호</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">사고명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">사고시각</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">선박유형</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">유종</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-fg-disabled uppercase tracking-wider">유출량</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">사고유형</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">분석자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredIncidents.map((incident) => (
|
||||
<tr
|
||||
key={incident.acdntSn}
|
||||
className="hover:bg-bg-2 transition-colors cursor-pointer group"
|
||||
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{incident.acdntSn}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{incident.acdntSn}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-status-red animate-pulse" />
|
||||
<span className="text-sm font-semibold text-text-1 group-hover:text-primary-cyan transition-colors">
|
||||
<span className="w-2 h-2 rounded-full bg-color-danger animate-pulse" />
|
||||
<span className="text-sm font-semibold text-fg group-hover:text-color-accent transition-colors">
|
||||
{incident.acdntNm}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{incident.occrnDtm}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{incident.vesselTp ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{incident.oilTpCd ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold">
|
||||
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{incident.occrnDtm}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub">{incident.vesselTp ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub">{incident.oilTpCd ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg font-mono text-right font-semibold">
|
||||
{incident.spilQty != null ? incident.spilQty.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{incident.acdntTpCd}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub">{incident.acdntTpCd}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-md bg-[rgba(168,85,247,0.15)] text-purple-400">
|
||||
{incident.phaseCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{incident.analystNm ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub">{incident.analystNm ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -170,9 +170,9 @@ export function IncidentsLeftPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-bg-1 border-r border-border overflow-hidden shrink-0 w-[360px]">
|
||||
<div className="flex flex-col bg-bg-surface border-r border-stroke overflow-hidden shrink-0 w-[360px]">
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="px-4 py-3 border-b border-stroke shrink-0">
|
||||
<div className="relative">
|
||||
<span className="absolute left-[10px] top-1/2 -translate-y-1/2 text-xs">🔍</span>
|
||||
<input
|
||||
@ -180,43 +180,43 @@ export function IncidentsLeftPanel({
|
||||
placeholder="사고명, 선박명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); resetPage() }}
|
||||
className="w-full py-2 pr-3 pl-8 bg-bg-0 border border-border text-xs outline-none"
|
||||
style={{ borderRadius: 'var(--rS)' }}
|
||||
className="w-full py-2 pr-3 pl-8 bg-bg-base border border-stroke text-xs outline-none"
|
||||
style={{ borderRadius: 'var(--radius-sm)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke shrink-0">
|
||||
<input type="date" value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setSelectedPeriod(''); resetPage() }}
|
||||
className="bg-bg-0 border border-border font-mono text-[11px] outline-none flex-1"
|
||||
style={{ padding: '5px 8px', borderRadius: 'var(--rS)' }}
|
||||
className="bg-bg-base border border-stroke font-mono text-[11px] outline-none flex-1"
|
||||
style={{ padding: '5px 8px', borderRadius: 'var(--radius-sm)' }}
|
||||
/>
|
||||
<span className="text-text-3 text-[11px]">~</span>
|
||||
<span className="text-fg-disabled text-[11px]">~</span>
|
||||
<input type="date" value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setSelectedPeriod(''); resetPage() }}
|
||||
className="bg-bg-0 border border-border font-mono text-[11px] outline-none flex-1"
|
||||
style={{ padding: '5px 8px', borderRadius: 'var(--rS)' }}
|
||||
className="bg-bg-base border border-stroke font-mono text-[11px] outline-none flex-1"
|
||||
style={{ padding: '5px 8px', borderRadius: 'var(--radius-sm)' }}
|
||||
/>
|
||||
<button onClick={resetPage} className="text-[11px] font-semibold cursor-pointer whitespace-nowrap text-white border-none" style={{ padding: '5px 12px', background: 'linear-gradient(135deg,var(--cyan),var(--blue))', borderRadius: 'var(--rS)' }}>조회</button>
|
||||
<button onClick={resetPage} className="text-[11px] font-semibold cursor-pointer whitespace-nowrap text-white border-none" style={{ padding: '5px 12px', background: 'linear-gradient(135deg,var(--color-accent),var(--color-info))', borderRadius: 'var(--radius-sm)' }}>조회</button>
|
||||
</div>
|
||||
|
||||
{/* Period Presets */}
|
||||
<div className="flex gap-1 px-4 py-1.5 border-b border-border shrink-0">
|
||||
<div className="flex gap-1 px-4 py-1.5 border-b border-stroke shrink-0">
|
||||
{PERIOD_PRESETS.map(p => (
|
||||
<button key={p} onClick={() => handlePeriodClick(p)} className="text-[10px] font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '3px 8px', borderRadius: '14px', border: selectedPeriod === p ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
|
||||
padding: '3px 8px', borderRadius: '14px', border: selectedPeriod === p ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--stroke-default)',
|
||||
background: selectedPeriod === p ? 'rgba(6,182,212,0.1)' : 'transparent',
|
||||
color: selectedPeriod === p ? 'var(--cyan)' : 'var(--t3)',
|
||||
color: selectedPeriod === p ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Today Summary */}
|
||||
<div className="px-4 py-2.5 border-b border-border shrink-0" style={{ background: 'rgba(6,182,212,0.03)' }}>
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2" style={{ letterSpacing: '0.8px' }}>
|
||||
<div className="px-4 py-2.5 border-b border-stroke shrink-0" style={{ background: 'rgba(6,182,212,0.03)' }}>
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2" style={{ letterSpacing: '0.8px' }}>
|
||||
📅 오늘 ({todayLabel}) 사고 현황
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
@ -226,13 +226,13 @@ export function IncidentsLeftPanel({
|
||||
return (
|
||||
<button key={r} onClick={() => { setSelectedRegion(r); resetPage() }} className="text-[11px] cursor-pointer"
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 'var(--rS)',
|
||||
background: isActive ? 'rgba(6,182,212,0.1)' : 'var(--bg3)',
|
||||
border: isActive ? '1px solid rgba(6,182,212,0.25)' : '1px solid var(--bd)',
|
||||
color: isActive ? 'var(--cyan)' : 'var(--t2)', fontWeight: isActive ? 700 : 400,
|
||||
padding: '4px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isActive ? 'rgba(6,182,212,0.1)' : 'var(--bg-card)',
|
||||
border: isActive ? '1px solid rgba(6,182,212,0.25)' : '1px solid var(--stroke-default)',
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--fg-sub)', fontWeight: isActive ? 700 : 400,
|
||||
}}>
|
||||
{r === '전체' ? '전체 ' : `${r} `}
|
||||
<span className="font-bold font-mono" style={{ color: isActive ? 'var(--cyan)' : undefined }}>
|
||||
<span className="font-bold font-mono" style={{ color: isActive ? 'var(--color-accent)' : undefined }}>
|
||||
{r === '전체' ? count : `(${count})`}
|
||||
</span>
|
||||
</button>
|
||||
@ -242,18 +242,18 @@ export function IncidentsLeftPanel({
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex gap-[5px] px-4 py-2 border-b border-border shrink-0">
|
||||
<div className="flex gap-[5px] px-4 py-2 border-b border-stroke shrink-0">
|
||||
{[
|
||||
{ id: '전체', label: '전체', dot: '' },
|
||||
{ id: 'active', label: `대응중 (${statusCounts.active})`, dot: 'var(--red)' },
|
||||
{ id: 'investigating', label: `조사중 (${statusCounts.investigating})`, dot: 'var(--orange)' },
|
||||
{ id: 'closed', label: `종료 (${statusCounts.closed})`, dot: 'var(--t3)' },
|
||||
{ id: 'active', label: `대응중 (${statusCounts.active})`, dot: 'var(--color-danger)' },
|
||||
{ id: 'investigating', label: `조사중 (${statusCounts.investigating})`, dot: 'var(--color-warning)' },
|
||||
{ id: 'closed', label: `종료 (${statusCounts.closed})`, dot: 'var(--fg-disabled)' },
|
||||
].map(s => (
|
||||
<button key={s.id} onClick={() => { setSelectedStatus(s.id); resetPage() }}
|
||||
className="flex items-center gap-1 text-[10px] font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '12px', border: selectedStatus === s.id ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
|
||||
background: 'transparent', color: selectedStatus === s.id ? 'var(--t2)' : 'var(--t3)',
|
||||
padding: '4px 10px', borderRadius: '12px', border: selectedStatus === s.id ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--stroke-default)',
|
||||
background: 'transparent', color: selectedStatus === s.id ? 'var(--fg-sub)' : 'var(--fg-disabled)',
|
||||
}}>
|
||||
{s.dot && <span style={{ width: '6px', height: '6px', borderRadius: '50%', background: s.dot }} />}
|
||||
{s.label}
|
||||
@ -262,39 +262,39 @@ export function IncidentsLeftPanel({
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<div className="px-4 py-1.5 text-[11px] text-text-3 shrink-0 border-b border-border">
|
||||
<div className="px-4 py-1.5 text-[11px] text-fg-disabled shrink-0 border-b border-stroke">
|
||||
총 {filteredIncidents.length}건
|
||||
</div>
|
||||
|
||||
{/* Incident List */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin' as const, scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin' as const, scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
{pagedIncidents.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-text-3 text-[11px]">
|
||||
<div className="px-4 py-10 text-center text-fg-disabled text-[11px]">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : pagedIncidents.map(inc => {
|
||||
const isSel = selectedIncidentId === inc.id
|
||||
const dotStyle: Record<string, string> = {
|
||||
active: 'var(--red)', investigating: 'var(--orange)', closed: 'var(--t3)',
|
||||
active: 'var(--color-danger)', investigating: 'var(--color-warning)', closed: 'var(--fg-disabled)',
|
||||
}
|
||||
const dotShadow: Record<string, string> = {
|
||||
active: '0 0 6px var(--red)', investigating: '0 0 6px var(--orange)', closed: 'none',
|
||||
active: '0 0 6px var(--color-danger)', investigating: '0 0 6px var(--color-warning)', closed: 'none',
|
||||
}
|
||||
const stBg: Record<string, string> = {
|
||||
active: 'rgba(239,68,68,0.15)', investigating: 'rgba(249,115,22,0.15)', closed: 'rgba(100,116,139,0.15)',
|
||||
}
|
||||
const stColor: Record<string, string> = {
|
||||
active: 'var(--red)', investigating: 'var(--orange)', closed: 'var(--t3)',
|
||||
active: 'var(--color-danger)', investigating: 'var(--color-warning)', closed: 'var(--fg-disabled)',
|
||||
}
|
||||
const stLabel: Record<string, string> = {
|
||||
active: '대응중', investigating: '조사중', closed: '종료',
|
||||
}
|
||||
return (
|
||||
<div key={inc.id} onClick={() => onIncidentSelect(isSel ? null : inc.id)}
|
||||
className="px-4 py-3 border-b border-border cursor-pointer"
|
||||
className="px-4 py-3 border-b border-stroke cursor-pointer"
|
||||
style={{
|
||||
background: isSel ? 'rgba(6,182,212,0.04)' : undefined,
|
||||
borderLeft: isSel ? '3px solid var(--cyan)' : '3px solid transparent',
|
||||
borderLeft: isSel ? '3px solid var(--color-accent)' : '3px solid transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSel) e.currentTarget.style.background = 'rgba(255,255,255,0.02)' }}
|
||||
@ -311,7 +311,7 @@ export function IncidentsLeftPanel({
|
||||
</span>
|
||||
</div>
|
||||
{/* Row 2: meta */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-text-3 mb-[5px]">
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-disabled mb-[5px]">
|
||||
<span>📅 {inc.date} {inc.time}</span>
|
||||
<span>🏛 {inc.office}</span>
|
||||
</div>
|
||||
@ -319,17 +319,17 @@ export function IncidentsLeftPanel({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{inc.causeType && (
|
||||
<span className="text-[10px] font-medium text-text-2" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(100,116,139,0.08)', border: '1px solid rgba(100,116,139,0.2)' }}>
|
||||
<span className="text-[10px] font-medium text-fg-sub" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(100,116,139,0.08)', border: '1px solid rgba(100,116,139,0.2)' }}>
|
||||
{inc.causeType}
|
||||
</span>
|
||||
)}
|
||||
{inc.oilType && (
|
||||
<span className="text-[10px] font-medium text-status-orange" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.2)' }}>
|
||||
<span className="text-[10px] font-medium text-color-warning" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.2)' }}>
|
||||
{inc.oilType}
|
||||
</span>
|
||||
)}
|
||||
{inc.prediction && (
|
||||
<span className="text-[10px] font-medium text-status-green" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.2)' }}>
|
||||
<span className="text-[10px] font-medium text-color-success" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.2)' }}>
|
||||
{inc.prediction}
|
||||
</span>
|
||||
)}
|
||||
@ -372,8 +372,8 @@ export function IncidentsLeftPanel({
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between bg-bg-1 shrink-0 border-t border-border px-3 py-2">
|
||||
<div className="text-[9px] text-text-3">
|
||||
<div className="flex items-center justify-between bg-bg-surface shrink-0 border-t border-stroke px-3 py-2">
|
||||
<div className="text-[9px] text-fg-disabled">
|
||||
총 <b>{filteredIncidents.length}</b>건 중 {(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filteredIncidents.length)}
|
||||
</div>
|
||||
<div className="flex items-center gap-[3px]">
|
||||
@ -386,7 +386,7 @@ export function IncidentsLeftPanel({
|
||||
<PgBtn label="⏭" disabled={safePage >= totalPages} onClick={() => setCurrentPage(totalPages)} />
|
||||
</div>
|
||||
<select onChange={(e) => { /* page size change placeholder */ void e }}
|
||||
className="bg-bg-0 border border-border text-text-2 text-[9px] outline-none rounded px-1.5 py-[3px]">
|
||||
className="bg-bg-base border border-stroke text-fg-sub text-[9px] outline-none rounded px-1.5 py-[3px]">
|
||||
<option>6건</option><option>10건</option><option>20건</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -400,9 +400,9 @@ function PgBtn({ label, active, disabled, onClick }: { label: string; active?: b
|
||||
className="flex items-center justify-center font-mono text-[9px]"
|
||||
style={{
|
||||
minWidth: '24px', height: '24px', padding: '0 5px', borderRadius: '4px', fontWeight: active ? 700 : 600,
|
||||
background: active ? 'rgba(6,182,212,0.15)' : 'var(--bg3)',
|
||||
border: active ? '1px solid rgba(6,182,212,0.4)' : '1px solid var(--bd)',
|
||||
color: active ? 'var(--cyan)' : 'var(--t3)',
|
||||
background: active ? 'rgba(6,182,212,0.15)' : 'var(--bg-card)',
|
||||
border: active ? '1px solid rgba(6,182,212,0.4)' : '1px solid var(--stroke-default)',
|
||||
color: active ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
opacity: disabled ? 0.4 : 1, pointerEvents: disabled ? 'none' : undefined,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
}}>{label}</button>
|
||||
@ -419,23 +419,23 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
}>(({ data, position, onClose }, ref) => {
|
||||
const forecast = data?.forecast ?? []
|
||||
return (
|
||||
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
|
||||
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-stroke bg-bg-surface" style={{
|
||||
zIndex: 9990, width: 280,
|
||||
top: position.top, left: position.left,
|
||||
borderColor: 'rgba(59,130,246,0.3)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.5)', backdropFilter: 'blur(12px)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-3.5 py-2.5"
|
||||
<div className="flex items-center justify-between border-b border-stroke px-3.5 py-2.5"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(6,182,212,0.04))' }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🌤</span>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||||
<div className="text-text-3 font-mono text-[8px]">{data?.obsDtm || '-'}</div>
|
||||
<div className="text-fg-disabled font-mono text-[8px]">{data?.obsDtm || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="cursor-pointer text-text-3 text-sm p-0.5">✕</span>
|
||||
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-sm p-0.5">✕</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@ -445,7 +445,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
<div className="text-[28px]">{data?.icon || '❓'}</div>
|
||||
<div>
|
||||
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</div>
|
||||
<div className="text-text-3 text-[9px]">{data?.weatherDc || '-'}</div>
|
||||
<div className="text-fg-disabled text-[9px]">{data?.weatherDc || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -465,25 +465,25 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
style={{ background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.1)' }}>
|
||||
<span className="text-xs">⬆</span>
|
||||
<div>
|
||||
<div className="text-text-3 text-[7px]">고조 (만조)</div>
|
||||
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data?.highTide || '-'}</div>
|
||||
<div className="text-fg-disabled text-[7px]">고조 (만조)</div>
|
||||
<div className="font-bold font-mono text-[10px] text-color-info">{data?.highTide || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
||||
style={{ background: 'rgba(6,182,212,0.06)', border: '1px solid rgba(6,182,212,0.1)' }}>
|
||||
<span className="text-xs">⬇</span>
|
||||
<div>
|
||||
<div className="text-text-3 text-[7px]">저조 (간조)</div>
|
||||
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data?.lowTide || '-'}</div>
|
||||
<div className="text-fg-disabled text-[7px]">저조 (간조)</div>
|
||||
<div className="text-color-accent font-bold font-mono text-[10px]">{data?.lowTide || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 24h Forecast */}
|
||||
<div className="bg-bg-0 mt-2.5 px-2.5 py-2 rounded-md">
|
||||
<div className="font-bold text-text-3 text-[8px] mb-1.5">24h 예보</div>
|
||||
<div className="bg-bg-base mt-2.5 px-2.5 py-2 rounded-md">
|
||||
<div className="font-bold text-fg-disabled text-[8px] mb-1.5">24h 예보</div>
|
||||
{forecast.length > 0 ? (
|
||||
<div className="flex justify-between font-mono text-text-2 text-[8px]">
|
||||
<div className="flex justify-between font-mono text-fg-sub text-[8px]">
|
||||
{forecast.map((f, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div>{f.hour}</div>
|
||||
@ -493,7 +493,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-3 text-center text-[8px] py-1">예보 데이터 없음</div>
|
||||
<div className="text-fg-disabled text-center text-[8px] py-1">예보 데이터 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -502,8 +502,8 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
padding: '6px 10px',
|
||||
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)',
|
||||
}}>
|
||||
<div className="font-bold text-status-orange text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
||||
<div className="text-text-2 text-[8px] leading-[1.5]">{data?.impactDc || '-'}</div>
|
||||
<div className="font-bold text-color-warning text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
||||
<div className="text-fg-sub text-[8px] leading-[1.5]">{data?.impactDc || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -513,10 +513,10 @@ WeatherPopup.displayName = 'WeatherPopup'
|
||||
|
||||
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
|
||||
return (
|
||||
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
|
||||
<div className="flex items-center bg-bg-base rounded gap-[6px] py-1.5 px-2">
|
||||
<span className="text-[12px]">{icon}</span>
|
||||
<div>
|
||||
<div className="text-text-3 text-[7px]">{label}</div>
|
||||
<div className="text-fg-disabled text-[7px]">{label}</div>
|
||||
<div className="font-semibold font-mono">{value || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -218,8 +218,8 @@ export function IncidentsRightPanel({
|
||||
|
||||
if (!incident) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-bg-1 border-l border-border w-[280px] min-w-[280px]">
|
||||
<div className="text-center text-text-3 text-[11px]">
|
||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px]">
|
||||
<div className="text-center text-fg-disabled text-[11px]">
|
||||
<div className="text-[32px] mb-2 opacity-30">📊</div>
|
||||
좌측에서 사고를 선택하면<br />통합분석 조회가 표시됩니다
|
||||
</div>
|
||||
@ -228,26 +228,26 @@ export function IncidentsRightPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden h-full w-[280px] min-w-[280px]">
|
||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-[280px] min-w-[280px]">
|
||||
{/* Header */}
|
||||
<div className="px-[14px] py-2.5 border-b border-border shrink-0">
|
||||
<div className="px-[14px] py-2.5 border-b border-stroke shrink-0">
|
||||
<div className="text-xs font-bold mb-0.5">
|
||||
🔬 통합분석 조회
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3">
|
||||
선택: <b className="text-primary-cyan">{incident.name}</b>
|
||||
<div className="text-[9px] text-fg-disabled">
|
||||
선택: <b className="text-color-accent">{incident.name}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 h-0 overflow-y-auto flex flex-col gap-2 p-2" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="flex-1 h-0 overflow-y-auto flex flex-col gap-2 p-2" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
|
||||
{/* 유출유 확산예측 섹션 */}
|
||||
{(() => {
|
||||
const sec = oilSection
|
||||
const checkedCount = sec.items.filter(it => it.checked).length
|
||||
return (
|
||||
<div className="bg-bg-2 border border-border rounded-md p-2.5">
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">{sec.icon}</span>
|
||||
@ -265,7 +265,7 @@ export function IncidentsRightPanel({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{sec.items.length === 0 ? (
|
||||
<div className="text-[9px] text-text-3 text-center py-1.5">예측 실행 이력이 없습니다</div>
|
||||
<div className="text-[9px] text-fg-disabled text-center py-1.5">예측 실행 이력이 없습니다</div>
|
||||
) : (
|
||||
sec.items.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-1.5"
|
||||
@ -286,12 +286,12 @@ export function IncidentsRightPanel({
|
||||
<div className="text-[10px] font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-text-3 font-mono text-[8px]">{item.sub}</div>
|
||||
<div className="text-fg-disabled font-mono text-[8px]">{item.sub}</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={() => removePredItem(item.id)}
|
||||
title="제거"
|
||||
className="text-[10px] cursor-pointer text-text-3 shrink-0"
|
||||
className="text-[10px] cursor-pointer text-fg-disabled shrink-0"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
@ -299,7 +299,7 @@ export function IncidentsRightPanel({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-text-3">
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-fg-disabled">
|
||||
선택: <b style={{ color: sec.color }}>{checkedCount}건</b> · {sec.totalLabel}
|
||||
</div>
|
||||
</div>
|
||||
@ -308,7 +308,7 @@ export function IncidentsRightPanel({
|
||||
|
||||
{/* HNS 대기확산 / 긴급구난 섹션 (미개발 - 구조 유지) */}
|
||||
{STATIC_SECTIONS.map(sec => (
|
||||
<div key={sec.key} className="bg-bg-2 border border-border rounded-md p-2.5">
|
||||
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">{sec.icon}</span>
|
||||
@ -324,22 +324,22 @@ export function IncidentsRightPanel({
|
||||
📋 조회
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 text-center py-1.5">준비 중입니다</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-text-3">
|
||||
<div className="text-[9px] text-fg-disabled text-center py-1.5">준비 중입니다</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-fg-disabled">
|
||||
선택: <b style={{ color: sec.color }}>0건</b> · 전체 0건
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 민감자원 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md p-2.5">
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-sm">🐟</span>
|
||||
<span className="text-xs font-bold text-[#22c55e]">민감자원</span>
|
||||
<span className="text-xs font-bold text-color-success">민감자원</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{sensCategories.length === 0 ? (
|
||||
<div className="text-[9px] text-text-3 text-center py-1.5">해당 사고 영역의 민감자원이 없습니다</div>
|
||||
<div className="text-[9px] text-fg-disabled text-center py-1.5">해당 사고 영역의 민감자원이 없습니다</div>
|
||||
) : (
|
||||
sensCategories.map((cat, i) => {
|
||||
const icon = CATEGORY_ICON[cat.category] ?? '🌊'
|
||||
@ -360,7 +360,7 @@ export function IncidentsRightPanel({
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: hex, flexShrink: 0, display: 'inline-block', border: `1px solid rgba(${r},${g},${b},0.45)` }} />
|
||||
<span>{icon}</span>
|
||||
<span className="flex-1">{cat.category}</span>
|
||||
<span className="text-text-3 font-mono shrink-0">({areaLabel})</span>
|
||||
<span className="text-fg-disabled font-mono shrink-0">({areaLabel})</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
@ -369,26 +369,26 @@ export function IncidentsRightPanel({
|
||||
</div>
|
||||
|
||||
{/* 근처 방제자원 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md p-2.5">
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-sm">🛡</span>
|
||||
<span className="text-xs font-bold text-[#f59e0b]">
|
||||
<span className="text-xs font-bold text-color-boom">
|
||||
근처 방제자원
|
||||
</span>
|
||||
{nearbyOrgs.length > 0 && (
|
||||
<span className="ml-auto text-[9px] font-mono text-[#f59e0b]">{nearbyOrgs.length}개</span>
|
||||
<span className="ml-auto text-[9px] font-mono text-color-boom">{nearbyOrgs.length}개</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedVessel ? (
|
||||
<div className="py-2.5 text-center text-text-3 text-[10px] leading-[1.7]">
|
||||
<div className="py-2.5 text-center text-fg-disabled text-[10px] leading-[1.7]">
|
||||
<div className="text-xl mb-1 opacity-40">🚢</div>
|
||||
지도에서 선박을 클릭하면<br />부근 방제자원이 표시됩니다
|
||||
</div>
|
||||
) : nearbyLoading ? (
|
||||
<div className="py-2.5 text-center text-text-3 text-[10px]">조회 중...</div>
|
||||
<div className="py-2.5 text-center text-fg-disabled text-[10px]">조회 중...</div>
|
||||
) : nearbyOrgs.length === 0 ? (
|
||||
<div className="py-2.5 text-center text-text-3 text-[10px]">반경 내 방제자원 없음</div>
|
||||
<div className="py-2.5 text-center text-fg-disabled text-[10px]">반경 내 방제자원 없음</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-[3px] max-h-[200px] overflow-y-auto">
|
||||
{nearbyOrgs.map(org => (
|
||||
@ -400,13 +400,13 @@ export function IncidentsRightPanel({
|
||||
style={{ background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }}>
|
||||
{org.orgTp}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-text-1 truncate">{org.orgNm}</span>
|
||||
<span className="text-[10px] font-bold text-fg truncate">{org.orgNm}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3">
|
||||
<div className="text-[9px] text-fg-disabled">
|
||||
{org.areaNm}{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-[#f59e0b] shrink-0">
|
||||
<span className="text-[9px] font-mono text-color-boom shrink-0">
|
||||
{org.distanceNm.toFixed(1)} nm
|
||||
</span>
|
||||
</div>
|
||||
@ -417,8 +417,8 @@ export function IncidentsRightPanel({
|
||||
{/* Radius slider */}
|
||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
|
||||
<div className="flex items-center justify-between mb-[5px]">
|
||||
<span className="text-[9px] text-text-3">탐색 반경</span>
|
||||
<span className="text-[10px] font-bold font-mono text-[#f59e0b]">
|
||||
<span className="text-[9px] text-fg-disabled">탐색 반경</span>
|
||||
<span className="text-[10px] font-bold font-mono text-color-boom">
|
||||
{nearbyRadius} nm
|
||||
</span>
|
||||
</div>
|
||||
@ -432,7 +432,7 @@ export function IncidentsRightPanel({
|
||||
className="w-full outline-none cursor-pointer"
|
||||
style={{
|
||||
height: '4px',
|
||||
background: 'var(--bd)', borderRadius: '2px',
|
||||
background: 'var(--stroke-default)', borderRadius: '2px',
|
||||
accentColor: '#f59e0b',
|
||||
}}
|
||||
/>
|
||||
@ -441,7 +441,7 @@ export function IncidentsRightPanel({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-1.5 p-2.5 border-t border-border shrink-0">
|
||||
<div className="flex flex-col gap-1.5 p-2.5 border-t border-stroke shrink-0">
|
||||
{/* View Mode */}
|
||||
<div className="flex gap-1">
|
||||
{([
|
||||
@ -453,11 +453,11 @@ export function IncidentsRightPanel({
|
||||
return (
|
||||
<button key={v.mode} onClick={() => onViewModeChange(v.mode)} className="flex-1 text-[10px] cursor-pointer"
|
||||
style={{
|
||||
padding: '6px', borderRadius: 'var(--rS)',
|
||||
padding: '6px', borderRadius: 'var(--radius-sm)',
|
||||
fontWeight: isActive ? 700 : 600,
|
||||
background: isActive ? 'rgba(6,182,212,0.1)' : 'var(--bg3)',
|
||||
border: isActive ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
|
||||
color: isActive ? 'var(--cyan)' : 'var(--t3)',
|
||||
background: isActive ? 'rgba(6,182,212,0.1)' : 'var(--bg-card)',
|
||||
border: isActive ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--stroke-default)',
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}>
|
||||
{v.icon} {v.label}
|
||||
</button>
|
||||
@ -481,8 +481,8 @@ export function IncidentsRightPanel({
|
||||
? 'linear-gradient(135deg,rgba(239,68,68,0.15),rgba(239,68,68,0.1))'
|
||||
: 'linear-gradient(135deg,rgba(6,182,212,0.15),rgba(59,130,246,0.1))',
|
||||
border: analysisActive ? '1px solid rgba(239,68,68,0.3)' : '1px solid rgba(6,182,212,0.3)',
|
||||
borderRadius: 'var(--rS)',
|
||||
color: analysisActive ? '#f87171' : 'var(--cyan)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: analysisActive ? '#f87171' : 'var(--color-accent)',
|
||||
}}>
|
||||
{analysisActive ? '✕ 분석 닫기' : '🔬 통합 분석 비교 실행'}
|
||||
</button>
|
||||
|
||||
@ -540,18 +540,18 @@ export function IncidentsView() {
|
||||
{/* Analysis Bar */}
|
||||
{analysisActive && (
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between border-b border-border"
|
||||
className="shrink-0 flex items-center justify-between border-b border-stroke"
|
||||
style={{
|
||||
height: 36,
|
||||
padding: '0 16px',
|
||||
background: 'linear-gradient(90deg,rgba(6,182,212,0.06),var(--bg1))',
|
||||
background: 'linear-gradient(90deg,rgba(6,182,212,0.06),var(--bg-surface))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold">
|
||||
🔬 통합 분석 비교
|
||||
</span>
|
||||
<span className="text-[9px] text-text-3">
|
||||
<span className="text-[9px] text-fg-disabled">
|
||||
{selectedIncident?.name}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
@ -585,9 +585,9 @@ export function IncidentsView() {
|
||||
className="text-[9px] font-semibold cursor-pointer rounded-sm"
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
background: viewMode === v.mode ? 'rgba(6,182,212,0.12)' : 'var(--bg3)',
|
||||
border: viewMode === v.mode ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
|
||||
color: viewMode === v.mode ? 'var(--cyan)' : 'var(--t3)',
|
||||
background: viewMode === v.mode ? 'rgba(6,182,212,0.12)' : 'var(--bg-card)',
|
||||
border: viewMode === v.mode ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--stroke-default)',
|
||||
color: viewMode === v.mode ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{v.icon} {v.label}
|
||||
@ -600,7 +600,7 @@ export function IncidentsView() {
|
||||
padding: '3px 8px',
|
||||
background: 'rgba(239,68,68,0.06)',
|
||||
border: '1px solid rgba(239,68,68,0.2)',
|
||||
color: '#f87171',
|
||||
color: 'var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
✕ 닫기
|
||||
@ -648,10 +648,10 @@ export function IncidentsView() {
|
||||
closeOnClick={false}
|
||||
>
|
||||
<div className="text-center min-w-[180px] text-xs">
|
||||
<div className="font-semibold text-[#1a1a2e]" style={{ marginBottom: 6 }}>
|
||||
<div className="font-semibold text-fg" style={{ marginBottom: 6 }}>
|
||||
{incidentPopup.incident.name}
|
||||
</div>
|
||||
<div className="text-[11px] text-[#555] leading-[1.6]">
|
||||
<div className="text-[11px] text-fg-disabled leading-[1.6]">
|
||||
<div>상태: {getStatusLabel(incidentPopup.incident.status)}</div>
|
||||
<div>
|
||||
일시: {incidentPopup.incident.date} {incidentPopup.incident.time}
|
||||
@ -661,7 +661,7 @@ export function IncidentsView() {
|
||||
<div>원인: {incidentPopup.incident.causeType}</div>
|
||||
)}
|
||||
{incidentPopup.incident.prediction && (
|
||||
<div className="text-[#0891b2]">{incidentPopup.incident.prediction}</div>
|
||||
<div className="text-color-accent">{incidentPopup.incident.prediction}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -676,8 +676,8 @@ export function IncidentsView() {
|
||||
style={{
|
||||
left: hoverInfo.x + 12,
|
||||
top: hoverInfo.y - 12,
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
minWidth: 150,
|
||||
@ -752,8 +752,8 @@ export function IncidentsView() {
|
||||
right: dischargeMode ? 340 : 180,
|
||||
padding: '6px 10px',
|
||||
background: dischargeMode ? 'rgba(6,182,212,0.15)' : 'rgba(13,17,23,0.88)',
|
||||
border: dischargeMode ? '1px solid rgba(6,182,212,0.4)' : '1px solid #30363d',
|
||||
color: dischargeMode ? '#22d3ee' : '#8b949e',
|
||||
border: dischargeMode ? '1px solid rgba(6,182,212,0.4)' : '1px solid var(--stroke-default)',
|
||||
color: dischargeMode ? '#22d3ee' : 'var(--fg-disabled)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
@ -796,7 +796,7 @@ export function IncidentsView() {
|
||||
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
|
||||
style={{
|
||||
background: 'rgba(13,17,23,0.88)',
|
||||
border: '1px solid #30363d',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
padding: '8px 12px',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
@ -807,23 +807,23 @@ export function IncidentsView() {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
background: 'var(--color-success)',
|
||||
animation: 'pd 1.5s infinite',
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] font-bold">
|
||||
AIS Live
|
||||
</span>
|
||||
<span className="text-[8px] text-text-3 font-mono">MarineTraffic</span>
|
||||
<span className="text-[8px] text-fg-disabled font-mono">MarineTraffic</span>
|
||||
</div>
|
||||
<div className="flex gap-2.5 text-[9px] font-mono">
|
||||
<div className="text-text-2">
|
||||
선박 <b className="text-primary-cyan">20</b>
|
||||
<div className="text-fg-sub">
|
||||
선박 <b className="text-color-accent">20</b>
|
||||
</div>
|
||||
<div className="text-text-2">
|
||||
<div className="text-fg-sub">
|
||||
사고 <b className="text-red-400">6</b>
|
||||
</div>
|
||||
<div className="text-text-2">
|
||||
<div className="text-fg-sub">
|
||||
방제선 <b className="text-cyan-500">2</b>
|
||||
</div>
|
||||
</div>
|
||||
@ -834,12 +834,12 @@ export function IncidentsView() {
|
||||
className="absolute bottom-[10px] left-[10px] z-[500] rounded-md flex flex-col gap-1.5"
|
||||
style={{
|
||||
background: 'rgba(13,17,23,0.88)',
|
||||
border: '1px solid #30363d',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
padding: '8px 12px',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<div className="text-[9px] font-bold text-text-2">
|
||||
<div className="text-[9px] font-bold text-fg-sub">
|
||||
사고 상태
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
@ -850,11 +850,11 @@ export function IncidentsView() {
|
||||
].map(s => (
|
||||
<div key={s.l} className="flex items-center gap-1">
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: s.c }} />
|
||||
<span className="text-[8px] text-text-3">{s.l}</span>
|
||||
<span className="text-[8px] text-fg-disabled">{s.l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[9px] font-bold text-text-2 mt-0.5">
|
||||
<div className="text-[9px] font-bold text-fg-sub mt-0.5">
|
||||
AIS 선박
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-[6px_12px]">
|
||||
@ -869,7 +869,7 @@ export function IncidentsView() {
|
||||
borderBottom: `7px solid ${vl.color}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[8px] text-text-3">{vl.type}</span>
|
||||
<span className="text-[8px] text-fg-disabled">{vl.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -901,28 +901,28 @@ export function IncidentsView() {
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
style={{ borderRight: '2px solid var(--cyan)' }}
|
||||
style={{ borderRight: '2px solid var(--color-accent)' }}
|
||||
>
|
||||
<div className="flex items-center shrink-0 bg-bg-1 border-b border-border" style={{ height: 28, padding: '0 10px' }}>
|
||||
<span className="text-[9px] font-bold text-primary-cyan">
|
||||
<div className="flex items-center shrink-0 bg-bg-surface border-b border-stroke" style={{ height: 28, padding: '0 10px' }}>
|
||||
<span className="text-[9px] font-bold text-color-accent">
|
||||
{analysisTags[0]
|
||||
? `${analysisTags[0].icon} ${analysisTags[0].label}`
|
||||
: '— 분석 결과를 선택하세요 —'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-0 flex flex-col gap-2" style={{ padding: 12 }}>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-2" style={{ padding: 12 }}>
|
||||
<SplitPanelContent tag={analysisTags[0]} incident={selectedIncident} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center shrink-0 bg-bg-1 border-b border-border" style={{ height: 28, padding: '0 10px' }}>
|
||||
<span className="text-[9px] font-bold text-primary-cyan">
|
||||
<div className="flex items-center shrink-0 bg-bg-surface border-b border-stroke" style={{ height: 28, padding: '0 10px' }}>
|
||||
<span className="text-[9px] font-bold text-color-accent">
|
||||
{analysisTags[1]
|
||||
? `${analysisTags[1].icon} ${analysisTags[1].label}`
|
||||
: '— 분석 결과를 선택하세요 —'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-0 flex flex-col gap-2" style={{ padding: 12 }}>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-2" style={{ padding: 12 }}>
|
||||
<SplitPanelContent tag={analysisTags[1]} incident={selectedIncident} />
|
||||
</div>
|
||||
</div>
|
||||
@ -933,13 +933,13 @@ export function IncidentsView() {
|
||||
{analysisActive && viewMode === 'split3' && (
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden border-r border-border"
|
||||
className="flex-1 flex flex-col overflow-hidden border-r border-stroke"
|
||||
>
|
||||
<div
|
||||
className="flex items-center shrink-0 border-b border-border"
|
||||
className="flex items-center shrink-0 border-b border-stroke"
|
||||
style={{
|
||||
height: 28,
|
||||
background: 'linear-gradient(90deg,rgba(249,115,22,0.08),var(--bg1))',
|
||||
background: 'linear-gradient(90deg,rgba(249,115,22,0.08),var(--bg-surface))',
|
||||
padding: '0 10px',
|
||||
}}
|
||||
>
|
||||
@ -947,7 +947,7 @@ export function IncidentsView() {
|
||||
🛢 유출유 확산예측
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-0 flex flex-col gap-1.5" style={{ padding: 10 }}>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5" style={{ padding: 10 }}>
|
||||
<SplitPanelContent
|
||||
tag={{ icon: '🛢', label: '유출유', color: '#f97316' }}
|
||||
incident={selectedIncident}
|
||||
@ -955,13 +955,13 @@ export function IncidentsView() {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden border-r border-border"
|
||||
className="flex-1 flex flex-col overflow-hidden border-r border-stroke"
|
||||
>
|
||||
<div
|
||||
className="flex items-center shrink-0 border-b border-border"
|
||||
className="flex items-center shrink-0 border-b border-stroke"
|
||||
style={{
|
||||
height: 28,
|
||||
background: 'linear-gradient(90deg,rgba(168,85,247,0.08),var(--bg1))',
|
||||
background: 'linear-gradient(90deg,rgba(168,85,247,0.08),var(--bg-surface))',
|
||||
padding: '0 10px',
|
||||
}}
|
||||
>
|
||||
@ -969,7 +969,7 @@ export function IncidentsView() {
|
||||
🧪 HNS 대기확산
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-0 flex flex-col gap-1.5" style={{ padding: 10 }}>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5" style={{ padding: 10 }}>
|
||||
<SplitPanelContent
|
||||
tag={{ icon: '🧪', label: 'HNS', color: '#a855f7' }}
|
||||
incident={selectedIncident}
|
||||
@ -978,18 +978,18 @@ export function IncidentsView() {
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex items-center shrink-0 border-b border-border"
|
||||
className="flex items-center shrink-0 border-b border-stroke"
|
||||
style={{
|
||||
height: 28,
|
||||
background: 'linear-gradient(90deg,rgba(6,182,212,0.08),var(--bg1))',
|
||||
background: 'linear-gradient(90deg,rgba(6,182,212,0.08),var(--bg-surface))',
|
||||
padding: '0 10px',
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] font-bold text-primary-cyan">
|
||||
<span className="text-[9px] font-bold text-color-accent">
|
||||
🚨 긴급구난
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-0 flex flex-col gap-1.5" style={{ padding: 10 }}>
|
||||
<div className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5" style={{ padding: 10 }}>
|
||||
<SplitPanelContent
|
||||
tag={{ icon: '🚨', label: '구난', color: '#06b6d4' }}
|
||||
incident={selectedIncident}
|
||||
@ -1002,8 +1002,8 @@ export function IncidentsView() {
|
||||
|
||||
{/* Decision Bar */}
|
||||
{analysisActive && (
|
||||
<div className="shrink-0 flex items-center justify-between bg-bg-1 border-t border-border" style={{ padding: '6px 16px' }}>
|
||||
<div className="text-[9px] text-text-3">
|
||||
<div className="shrink-0 flex items-center justify-between bg-bg-surface border-t border-stroke" style={{ padding: '6px 16px' }}>
|
||||
<div className="text-[9px] text-fg-disabled">
|
||||
📊 {selectedIncident?.name} · {analysisTags.map(t => t.label).join(' + ')} 분석 결과 비교
|
||||
</div>
|
||||
<div className="flex gap-[6px]">
|
||||
@ -1013,7 +1013,7 @@ export function IncidentsView() {
|
||||
padding: '4px 12px',
|
||||
background: 'rgba(59,130,246,0.1)',
|
||||
border: '1px solid rgba(59,130,246,0.2)',
|
||||
color: '#58a6ff',
|
||||
color: 'var(--color-info)',
|
||||
}}
|
||||
>
|
||||
📋 보고서 생성
|
||||
@ -1062,7 +1062,7 @@ function SplitPanelContent({
|
||||
}) {
|
||||
if (!tag) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-text-3 text-[11px]">
|
||||
<div className="flex-1 flex items-center justify-center text-fg-disabled text-[11px]">
|
||||
R&D 분석 결과를 선택하세요
|
||||
</div>
|
||||
)
|
||||
@ -1136,29 +1136,29 @@ function SplitPanelContent({
|
||||
>
|
||||
{tag.icon} {data.title}
|
||||
</div>
|
||||
<div className="text-[8px] text-text-3 font-mono">{data.model}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-mono">{data.model}</div>
|
||||
{incident && (
|
||||
<div className="text-[8px] text-text-3" style={{ marginTop: 2 }}>
|
||||
<div className="text-[8px] text-fg-disabled" style={{ marginTop: 2 }}>
|
||||
사고: {incident.name} · {incident.date} {incident.time}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-sm border border-border overflow-hidden">
|
||||
<div className="rounded-sm border border-stroke overflow-hidden">
|
||||
{data.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between items-center"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderBottom: i < data.items.length - 1 ? '1px solid var(--bd)' : 'none',
|
||||
background: i % 2 === 0 ? 'var(--bg1)' : 'var(--bg2)',
|
||||
borderBottom: i < data.items.length - 1 ? '1px solid var(--stroke-default)' : 'none',
|
||||
background: i % 2 === 0 ? 'var(--bg-surface)' : 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] text-text-3">{item.label}</span>
|
||||
<span className="text-[9px] text-fg-disabled">{item.label}</span>
|
||||
<span
|
||||
className="text-[10px] font-semibold font-mono"
|
||||
style={{ color: item.color || 'var(--t1)' }}
|
||||
style={{ color: item.color || 'var(--fg-default)' }}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
@ -1167,7 +1167,7 @@ function SplitPanelContent({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-sm text-[9px] text-text-2"
|
||||
className="rounded-sm text-[9px] text-fg-sub"
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: `${tag.color}06`,
|
||||
@ -1179,11 +1179,11 @@ function SplitPanelContent({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-sm bg-bg-0 border border-border flex items-center justify-center flex-col gap-1"
|
||||
className="rounded-sm bg-bg-base border border-stroke flex items-center justify-center flex-col gap-1"
|
||||
style={{ height: 120 }}
|
||||
>
|
||||
<div className="text-[32px] opacity-30">{tag.icon}</div>
|
||||
<div className="text-[9px] text-text-3">시각화 영역</div>
|
||||
<div className="text-[9px] text-fg-disabled">시각화 영역</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@ -1213,8 +1213,8 @@ function VesselPopupPanel({
|
||||
transform: 'translate(-50%,-50%)',
|
||||
zIndex: 9995,
|
||||
width: 300,
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.6)',
|
||||
overflow: 'hidden',
|
||||
@ -1227,8 +1227,8 @@ function VesselPopupPanel({
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '10px 14px',
|
||||
background: 'linear-gradient(135deg,#1c2333,#161b22)',
|
||||
borderBottom: '1px solid #30363d',
|
||||
background: 'var(--bg-elevated)',
|
||||
borderBottom: '1px solid var(--stroke-default)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center text-[16px]" style={{ width: 28, height: 20 }}>
|
||||
@ -1236,20 +1236,20 @@ function VesselPopupPanel({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[12px] font-[800] text-[#f0f6fc] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
className="text-[12px] font-[800] text-fg whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
{v.name}
|
||||
</div>
|
||||
<div className="text-[9px] text-[#8b949e] font-mono">MMSI: {v.mmsi}</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">MMSI: {v.mmsi}</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="text-[14px] cursor-pointer text-[#8b949e] p-[2px]">
|
||||
<span onClick={onClose} className="text-[14px] cursor-pointer text-fg-disabled p-[2px]">
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Ship Image */}
|
||||
<div
|
||||
className="w-full flex items-center justify-center text-[40px] text-[#30363d]"
|
||||
className="w-full flex items-center justify-center text-[40px] text-fg-disabled"
|
||||
style={{
|
||||
height: 120,
|
||||
background: '#0d1117',
|
||||
@ -1262,7 +1262,7 @@ function VesselPopupPanel({
|
||||
{/* Tags */}
|
||||
<div className="flex gap-2" style={{ padding: '6px 14px', borderBottom: '1px solid #21262d' }}>
|
||||
<span
|
||||
className="text-[8px] font-bold rounded text-[#58a6ff]"
|
||||
className="text-[8px] font-bold rounded text-color-info"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'rgba(59,130,246,0.12)',
|
||||
@ -1296,14 +1296,14 @@ function VesselPopupPanel({
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[10px] text-[#8b949e]">출항지</span>
|
||||
<span className="text-[10px] text-[#c9d1d9] font-semibold font-mono">
|
||||
<span className="text-[10px] text-fg-disabled">출항지</span>
|
||||
<span className="text-[10px] text-fg-sub font-semibold font-mono">
|
||||
{v.depart}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[10px] text-[#8b949e]">입항지</span>
|
||||
<span className="text-[10px] text-[#c9d1d9] font-semibold font-mono">
|
||||
<span className="text-[10px] text-fg-disabled">입항지</span>
|
||||
<span className="text-[10px] text-fg-sub font-semibold font-mono">
|
||||
{v.arrive}
|
||||
</span>
|
||||
</div>
|
||||
@ -1315,7 +1315,7 @@ function VesselPopupPanel({
|
||||
<div className="flex gap-1.5" style={{ padding: '10px 14px' }}>
|
||||
<button
|
||||
onClick={onDetail}
|
||||
className="flex-1 text-[10px] font-bold cursor-pointer text-center rounded-sm text-[#58a6ff]"
|
||||
className="flex-1 text-[10px] font-bold cursor-pointer text-center rounded-sm text-color-info"
|
||||
style={{
|
||||
padding: 6,
|
||||
background: 'rgba(59,130,246,0.12)',
|
||||
@ -1325,7 +1325,7 @@ function VesselPopupPanel({
|
||||
📋 상세정보
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 text-[10px] font-bold cursor-pointer text-center rounded-sm text-[#a78bfa]"
|
||||
className="flex-1 text-[10px] font-bold cursor-pointer text-center rounded-sm text-color-tertiary"
|
||||
style={{
|
||||
padding: 6,
|
||||
background: 'rgba(168,85,247,0.1)',
|
||||
@ -1335,7 +1335,7 @@ function VesselPopupPanel({
|
||||
🔍 항적조회
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 text-[10px] font-bold cursor-pointer text-center rounded-sm text-[#22d3ee]"
|
||||
className="flex-1 text-[10px] font-bold cursor-pointer text-center rounded-sm text-color-accent"
|
||||
style={{
|
||||
padding: 6,
|
||||
background: 'rgba(6,182,212,0.1)',
|
||||
@ -1368,7 +1368,7 @@ function PopupRow({
|
||||
borderBottom: '1px solid rgba(48,54,61,0.4)',
|
||||
}}
|
||||
>
|
||||
<span className="text-[#8b949e]">{label}</span>
|
||||
<span className="text-fg-disabled">{label}</span>
|
||||
<span
|
||||
className="font-semibold font-mono"
|
||||
style={{
|
||||
@ -1431,13 +1431,13 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: ()
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<span className="text-lg">{v.flag}</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-[800] text-[#f0f6fc]">{v.name}</div>
|
||||
<div className="text-[10px] text-[#8b949e] font-mono">
|
||||
<div className="text-[14px] font-[800] text-fg">{v.name}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono">
|
||||
MMSI: {v.mmsi} · IMO: {v.imo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="text-[16px] cursor-pointer text-[#8b949e]">
|
||||
<span onClick={onClose} className="text-[16px] cursor-pointer text-fg-disabled">
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
@ -1509,7 +1509,7 @@ function Sec({
|
||||
return (
|
||||
<div style={{ border: `1px solid ${borderColor || '#21262d'}`, borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div
|
||||
className="text-[11px] font-bold text-[#c9d1d9] flex items-center justify-between"
|
||||
className="text-[11px] font-bold text-fg-sub flex items-center justify-between"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: bgColor || '#0d1117',
|
||||
@ -1548,7 +1548,7 @@ function Cell({
|
||||
gridColumn: span ? '1 / -1' : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="text-[9px] text-[#8b949e]" style={{ marginBottom: 2 }}>{label}</div>
|
||||
<div className="text-[9px] text-fg-disabled" style={{ marginBottom: 2 }}>{label}</div>
|
||||
<div className="text-[11px] font-semibold font-mono" style={{ color: color || '#f0f6fc' }}>
|
||||
{value}
|
||||
</div>
|
||||
@ -1578,7 +1578,7 @@ function TabInfo({ v }: { v: Vessel }) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="w-full rounded-lg overflow-hidden flex items-center justify-center text-[60px] text-[#30363d]"
|
||||
className="w-full rounded-lg overflow-hidden flex items-center justify-center text-[60px] text-fg-disabled"
|
||||
style={{
|
||||
height: 160,
|
||||
background: '#0d1117',
|
||||
@ -1657,7 +1657,7 @@ function TabNav(_props: { v: Vessel }) {
|
||||
</Sec>
|
||||
|
||||
<Sec title="📊 속도 이력">
|
||||
<div className="p-3 bg-[#0d1117]">
|
||||
<div className="p-3 bg-bg-base">
|
||||
<div className="flex items-end gap-1.5" style={{ height: 80 }}>
|
||||
{hours.map((h, i) => (
|
||||
<div
|
||||
@ -1672,13 +1672,13 @@ function TabNav(_props: { v: Vessel }) {
|
||||
height: `${heights[i]}%`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[7px] text-[#8b949e]">{h}</span>
|
||||
<span className="text-[7px] text-fg-disabled">{h}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-[8px] text-[#8b949e]" style={{ marginTop: 6 }}>
|
||||
평균: <b className="text-[#58a6ff]">8.4 kn</b> · 최대:{' '}
|
||||
<b className="text-[#22d3ee]">11.2 kn</b>
|
||||
<div className="text-center text-[8px] text-fg-disabled" style={{ marginTop: 6 }}>
|
||||
평균: <b className="text-color-info">8.4 kn</b> · 최대:{' '}
|
||||
<b className="text-color-accent">11.2 kn</b>
|
||||
</div>
|
||||
</div>
|
||||
</Sec>
|
||||
@ -1718,7 +1718,7 @@ function TabSpec({ v }: { v: Vessel }) {
|
||||
</Sec>
|
||||
|
||||
<Sec title="⚠ 위험물 적재 정보">
|
||||
<div className="p-[10px_12px] bg-[#0d1117]">
|
||||
<div className="p-[10px_12px] bg-bg-base">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded"
|
||||
style={{
|
||||
@ -1729,14 +1729,14 @@ function TabSpec({ v }: { v: Vessel }) {
|
||||
>
|
||||
<span className="text-[12px]">🛢</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] font-semibold text-[#f0f6fc]">
|
||||
<div className="text-[10px] font-semibold text-fg">
|
||||
{v.cargo.split('·')[0].trim()}
|
||||
</div>
|
||||
<div className="text-[8px] text-[#8b949e]">{v.cargo}</div>
|
||||
<div className="text-[8px] text-fg-disabled">{v.cargo}</div>
|
||||
</div>
|
||||
{v.cargo.includes('IMO') && (
|
||||
<span
|
||||
className="text-[8px] font-bold text-[#f87171]"
|
||||
className="text-[8px] font-bold text-color-danger"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: 'rgba(239,68,68,0.15)',
|
||||
@ -1811,7 +1811,7 @@ function TabInsurance(_props: { v: Vessel }) {
|
||||
</Sec>
|
||||
|
||||
<div
|
||||
className="rounded-sm text-[9px] text-[#8b949e]"
|
||||
className="rounded-sm text-[9px] text-fg-disabled"
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: 'rgba(59,130,246,0.04)',
|
||||
@ -1834,7 +1834,7 @@ function TabDangerous({ v }: { v: Vessel }) {
|
||||
bgColor="rgba(249,115,22,0.06)"
|
||||
badge={
|
||||
<span
|
||||
className="text-[8px] font-bold text-[#ef4444]"
|
||||
className="text-[8px] font-bold text-color-danger"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: 'rgba(239,68,68,0.15)',
|
||||
@ -1861,16 +1861,16 @@ function TabDangerous({ v }: { v: Vessel }) {
|
||||
|
||||
<Sec title="📋 화물창 및 첨부">
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 bg-[#0d1117]"
|
||||
className="flex items-center justify-between gap-2 bg-bg-base"
|
||||
style={{ padding: '12px' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] whitespace-nowrap"
|
||||
>
|
||||
<span className="text-[#8b949e]">화물창 2개이상 여부</span>
|
||||
<span className="text-fg-disabled">화물창 2개이상 여부</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="flex items-center justify-center text-[8px] text-[#22d3ee]"
|
||||
className="flex items-center justify-center text-[8px] text-color-accent"
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
@ -1880,11 +1880,11 @@ function TabDangerous({ v }: { v: Vessel }) {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span className="font-semibold text-[10px] text-[#22d3ee]">예</span>
|
||||
<span className="font-semibold text-[10px] text-color-accent">예</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-[10px] font-semibold text-[#58a6ff] cursor-pointer whitespace-nowrap shrink-0 rounded"
|
||||
className="text-[10px] font-semibold text-color-info cursor-pointer whitespace-nowrap shrink-0 rounded"
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: 'rgba(59,130,246,0.1)',
|
||||
@ -1909,7 +1909,7 @@ function TabDangerous({ v }: { v: Vessel }) {
|
||||
|
||||
<Sec title="🚨 비상 대응 요약 (EmS)" bgColor="rgba(234,179,8,0.06)">
|
||||
<div
|
||||
className="flex flex-col gap-1.5 bg-[#0d1117]"
|
||||
className="flex flex-col gap-1.5 bg-bg-base"
|
||||
style={{ padding: '10px 12px' }}
|
||||
>
|
||||
<EmsRow
|
||||
@ -1937,7 +1937,7 @@ function TabDangerous({ v }: { v: Vessel }) {
|
||||
</Sec>
|
||||
|
||||
<div
|
||||
className="rounded-sm text-[9px] text-[#8b949e]"
|
||||
className="rounded-sm text-[9px] text-fg-disabled"
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: 'rgba(249,115,22,0.04)',
|
||||
@ -1976,8 +1976,8 @@ function EmsRow({
|
||||
>
|
||||
<span className="text-[13px]">{icon}</span>
|
||||
<div>
|
||||
<div className="text-[9px] text-[#8b949e]">{label}</div>
|
||||
<div className="text-[10px] font-semibold text-[#f0f6fc]">{value}</div>
|
||||
<div className="text-[9px] text-fg-disabled">{label}</div>
|
||||
<div className="text-[10px] font-semibold text-fg">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -2017,13 +2017,13 @@ function ActionBtn({
|
||||
function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) {
|
||||
return (
|
||||
<>
|
||||
<div className="text-[11px] font-bold text-[#f0f6fc]" style={{ marginBottom: 3 }}>{v.name}</div>
|
||||
<div className="text-[9px] text-[#8b949e]" style={{ marginBottom: 4 }}>
|
||||
<div className="text-[11px] font-bold text-fg" style={{ marginBottom: 3 }}>{v.name}</div>
|
||||
<div className="text-[9px] text-fg-disabled" style={{ marginBottom: 4 }}>
|
||||
{v.typS} · {v.flag}
|
||||
</div>
|
||||
<div className="flex justify-between text-[9px]">
|
||||
<span className="text-[#22d3ee] font-semibold">{v.speed} kn</span>
|
||||
<span className="text-[#8b949e]">HDG {v.heading}°</span>
|
||||
<span className="text-color-accent font-semibold">{v.speed} kn</span>
|
||||
<span className="text-fg-disabled">HDG {v.heading}°</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@ -2035,8 +2035,8 @@ function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-[11px] font-bold text-[#f0f6fc]" style={{ marginBottom: 3 }}>{i.name}</div>
|
||||
<div className="text-[9px] text-[#8b949e]" style={{ marginBottom: 4 }}>
|
||||
<div className="text-[11px] font-bold text-fg" style={{ marginBottom: 3 }}>{i.name}</div>
|
||||
<div className="text-[9px] text-fg-disabled" style={{ marginBottom: 4 }}>
|
||||
{i.date} {i.time}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@ -2046,7 +2046,7 @@ function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) {
|
||||
>
|
||||
{getStatusLabel(i.status)}
|
||||
</span>
|
||||
<span className="text-[9px] text-[#58a6ff] font-mono">
|
||||
<span className="text-[9px] text-color-info font-mono">
|
||||
{i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -58,12 +58,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)' }}
|
||||
>
|
||||
<div
|
||||
className="text-center text-[12px] text-[#8b949e]"
|
||||
className="text-center text-[12px] text-fg-disabled"
|
||||
style={{
|
||||
width: 300,
|
||||
padding: 40,
|
||||
background: '#0d1117',
|
||||
border: '1px solid #30363d',
|
||||
background: 'var(--bg-base)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 14,
|
||||
}}
|
||||
>
|
||||
@ -92,8 +92,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
width: '95vw',
|
||||
height: '92vh',
|
||||
maxWidth: 1600,
|
||||
background: '#0d1117',
|
||||
border: '1px solid #30363d',
|
||||
background: 'var(--bg-base)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 14,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
|
||||
}}
|
||||
@ -103,17 +103,17 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: 'linear-gradient(135deg,#161b22,#0d1117)',
|
||||
borderBottom: '1px solid #30363d',
|
||||
background: 'linear-gradient(135deg,var(--bg-elevated),var(--bg-base))',
|
||||
borderBottom: '1px solid var(--stroke-default)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<span className="text-lg">📋</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-[800] text-[#f0f6fc]">
|
||||
<div className="text-[14px] font-[800] text-fg">
|
||||
현장정보 — {incident.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#8b949e] font-mono">
|
||||
<div className="text-[10px] text-fg-disabled font-mono">
|
||||
{incident.name} · {incident.date} · 사진 {media.photoCnt} / 영상 {media.videoCnt} / 위성 {media.satCnt} / CCTV {media.cctvCnt}
|
||||
</div>
|
||||
</div>
|
||||
@ -126,7 +126,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
padding: '5px 12px', borderRadius: 6, fontSize: 11, fontWeight: activeTab === t.id ? 700 : 400,
|
||||
cursor: 'pointer', border: 'none',
|
||||
background: activeTab === t.id ? 'rgba(168,85,247,0.15)' : 'transparent',
|
||||
color: activeTab === t.id ? '#c084fc' : '#8b949e',
|
||||
color: activeTab === t.id ? '#c084fc' : 'var(--fg-disabled)',
|
||||
}}>
|
||||
{t.icon ? `${t.icon} ${t.label}` : t.label}
|
||||
</button>
|
||||
@ -142,7 +142,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
{/* Close */}
|
||||
<span
|
||||
onClick={onClose}
|
||||
className="text-[18px] cursor-pointer text-[#8b949e] rounded"
|
||||
className="text-[18px] cursor-pointer text-fg-disabled rounded"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>✕</span>
|
||||
</div>
|
||||
@ -151,11 +151,11 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
{/* ── Timeline ────────────────────────────────── */}
|
||||
<div
|
||||
className="shrink-0 flex items-center gap-[10px]"
|
||||
style={{ padding: '6px 20px', borderBottom: '1px solid #21262d' }}
|
||||
style={{ padding: '6px 20px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<span className="text-[9px] text-[#8b949e] whitespace-nowrap">TIMELINE</span>
|
||||
<span className="text-[9px] text-fg-disabled whitespace-nowrap">TIMELINE</span>
|
||||
<div className="flex-1 relative" style={{ height: 16 }}>
|
||||
<div style={{ position: 'absolute', top: 7, left: 0, right: 0, height: 2, background: '#21262d', borderRadius: 1 }} />
|
||||
<div style={{ position: 'absolute', top: 7, left: 0, right: 0, height: 2, background: 'var(--stroke-light)', borderRadius: 1 }} />
|
||||
{timelineDots.map((d, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'absolute', left: `${d.pct}%`, top: 3,
|
||||
@ -164,10 +164,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 text-[8px] font-mono text-[#8b949e] whitespace-nowrap">
|
||||
<div className="flex gap-2 text-[8px] font-mono text-fg-disabled whitespace-nowrap">
|
||||
<span style={{ color: '#ef4444' }}>● 초기</span>
|
||||
<span style={{ color: '#f59e0b' }}>● 대응</span>
|
||||
<span className="text-[#8b949e]">● 종료</span>
|
||||
<span className="text-fg-disabled">● 종료</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -178,20 +178,20 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
gridTemplateColumns: (showPhoto || showSat) && (showVideo || showCctv) ? '1fr 1fr' : '1fr',
|
||||
gridTemplateRows: (showPhoto || showVideo) && (showSat || showCctv) ? '1fr 1fr' : '1fr',
|
||||
gap: 1,
|
||||
background: '#21262d',
|
||||
background: 'var(--stroke-light)',
|
||||
}}
|
||||
>
|
||||
{/* ── Q1: 현장사진 ──────────────────────────── */}
|
||||
{showPhoto && (
|
||||
<div className="flex flex-col overflow-hidden bg-[#0d1117]">
|
||||
<div className="flex flex-col overflow-hidden bg-bg-base">
|
||||
{/* Section header */}
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-[12px]">📷</span>
|
||||
<span className="text-[12px] font-bold text-[#f0f6fc]">
|
||||
<span className="text-[12px] font-bold text-fg">
|
||||
현장사진 — {str(media.photoMeta, 'title', '현장 사진')}
|
||||
</span>
|
||||
</div>
|
||||
@ -201,30 +201,30 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
</div>
|
||||
{/* Photo content */}
|
||||
<div className="flex-1 flex items-center justify-center flex-col gap-2">
|
||||
<div className="text-[48px] text-[#30363d]">📷</div>
|
||||
<div className="text-[12px] text-[#c9d1d9] font-semibold">
|
||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>📷</div>
|
||||
<div className="text-[12px] text-fg-sub font-semibold">
|
||||
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진
|
||||
</div>
|
||||
<div className="text-[9px] text-[#8b949e] font-mono">
|
||||
<div className="text-[9px] text-fg-disabled font-mono">
|
||||
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Thumbnails */}
|
||||
<div className="shrink-0" style={{ padding: '8px 12px', borderTop: '1px solid #21262d' }}>
|
||||
<div className="shrink-0" style={{ padding: '8px 12px', borderTop: '1px solid var(--stroke-light)' }}>
|
||||
<div className="flex gap-1.5" style={{ marginBottom: 6 }}>
|
||||
{Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-center text-[14px] text-[#30363d] cursor-pointer" style={{
|
||||
width: 40, height: 36, borderRadius: 4,
|
||||
background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22',
|
||||
border: i === 0 ? '2px solid rgba(168,85,247,0.5)' : '1px solid #30363d',
|
||||
<div key={i} className="flex items-center justify-center text-[14px] cursor-pointer" style={{
|
||||
width: 40, height: 36, borderRadius: 4, color: 'var(--stroke-default)',
|
||||
background: i === 0 ? 'rgba(168,85,247,0.15)' : 'var(--bg-elevated)',
|
||||
border: i === 0 ? '2px solid rgba(168,85,247,0.5)' : '1px solid var(--stroke-default)',
|
||||
}}>📷</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[8px] text-[#8b949e]">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')}
|
||||
</span>
|
||||
<span className="text-[8px] text-[#a78bfa] cursor-pointer">🔗 R&D 연계</span>
|
||||
<span className="text-[8px] text-color-tertiary cursor-pointer">🔗 R&D 연계</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -232,19 +232,19 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
|
||||
{/* ── Q2: 드론 영상 ─────────────────────────── */}
|
||||
{showVideo && (
|
||||
<div className="flex flex-col overflow-hidden bg-[#0d1117]">
|
||||
<div className="flex flex-col overflow-hidden bg-bg-base">
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-[12px]">🎬</span>
|
||||
<span className="text-[12px] font-bold text-[#f0f6fc]">
|
||||
<span className="text-[12px] font-bold text-fg">
|
||||
드론 영상 — {str(media.droneMeta, 'title', '드론 영상')}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[9px] font-bold text-[#ef4444] rounded"
|
||||
className="text-[9px] font-bold text-color-danger rounded"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'rgba(239,68,68,0.15)',
|
||||
@ -252,39 +252,39 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
>● REC</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center flex-col gap-2">
|
||||
<div className="text-[48px] text-[#30363d]">🎬</div>
|
||||
<div className="text-[12px] text-[#c9d1d9] font-semibold">
|
||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>🎬</div>
|
||||
<div className="text-[12px] text-fg-sub font-semibold">
|
||||
드론 항공 촬영 영상
|
||||
</div>
|
||||
<div className="text-[9px] text-[#8b949e] font-mono">
|
||||
<div className="text-[9px] text-fg-disabled font-mono">
|
||||
{str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')} 고도
|
||||
</div>
|
||||
</div>
|
||||
{/* Video controls */}
|
||||
<div
|
||||
className="shrink-0 flex flex-col gap-2"
|
||||
style={{ padding: '10px 16px', borderTop: '1px solid #21262d' }}
|
||||
style={{ padding: '10px 16px', borderTop: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span className="text-[12px] text-[#8b949e] cursor-pointer">⏮</span>
|
||||
<span className="text-[12px] text-fg-disabled cursor-pointer">⏮</span>
|
||||
<div
|
||||
className="flex items-center justify-center text-[12px] text-[#c084fc] cursor-pointer"
|
||||
className="flex items-center justify-center text-[12px] text-color-tertiary cursor-pointer"
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'rgba(168,85,247,0.15)',
|
||||
border: '1px solid rgba(168,85,247,0.3)',
|
||||
}}
|
||||
>▶</div>
|
||||
<span className="text-[12px] text-[#8b949e] cursor-pointer">⏭</span>
|
||||
<span className="text-[10px] text-[#8b949e] font-mono">02:34 / {str(media.droneMeta, 'duration')}</span>
|
||||
<span className="text-[12px] text-fg-disabled cursor-pointer">⏭</span>
|
||||
<span className="text-[10px] text-fg-disabled font-mono">02:34 / {str(media.droneMeta, 'duration')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[8px] text-[#8b949e]">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
🎬 영상 {num(media.droneMeta, 'videoCount')}건 · {str(media.droneMeta, 'stage')}
|
||||
</span>
|
||||
<div className="flex gap-[8px]">
|
||||
<span className="text-[8px] text-[#58a6ff] cursor-pointer">📂 전체보기</span>
|
||||
<span className="text-[8px] text-[#a78bfa] cursor-pointer">🔗 R&D 연계</span>
|
||||
<span className="text-[8px] text-color-info cursor-pointer">📂 전체보기</span>
|
||||
<span className="text-[8px] text-color-tertiary cursor-pointer">🔗 R&D 연계</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -293,14 +293,14 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
|
||||
{/* ── Q3: 위성영상 ──────────────────────────── */}
|
||||
{showSat && (
|
||||
<div className="flex flex-col overflow-hidden bg-[#0d1117]">
|
||||
<div className="flex flex-col overflow-hidden bg-bg-base">
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-[12px]">🛰</span>
|
||||
<span className="text-[12px] font-bold text-[#f0f6fc]">
|
||||
<span className="text-[12px] font-bold text-fg">
|
||||
위성영상 — {str(media.satMeta, 'title', '위성영상')}
|
||||
</span>
|
||||
</div>
|
||||
@ -316,24 +316,24 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}>
|
||||
<div
|
||||
className="absolute text-[9px] font-bold text-[#ef4444] font-mono bg-[#0d1117]"
|
||||
className="absolute text-[9px] font-bold text-color-danger font-mono bg-bg-base"
|
||||
style={{ top: -10, left: 8, padding: '0 4px' }}
|
||||
>
|
||||
{str(media.satMeta, 'detection')}
|
||||
</div>
|
||||
<div className="text-[40px] text-[#30363d]">🛰</div>
|
||||
<div className="text-[11px] text-[#c9d1d9] font-semibold">
|
||||
<div className="text-[40px] text-fg-disabled">🛰</div>
|
||||
<div className="text-[11px] text-fg-sub font-semibold">
|
||||
{str(media.satMeta, 'title', '위성영상')} 위성영상
|
||||
</div>
|
||||
<div className="text-[8px] text-[#8b949e] font-mono">
|
||||
<div className="text-[8px] text-fg-disabled font-mono">
|
||||
{str(media.satMeta, 'date')} · 해상도 {str(media.satMeta, 'resolution')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{str(media.satMeta, 'detection') === '—' && (
|
||||
<div className="text-center">
|
||||
<div className="text-[40px] text-[#30363d]">🛰</div>
|
||||
<div className="text-[11px] text-[#8b949e]" style={{ marginTop: 8 }}>위성영상 없음</div>
|
||||
<div className="text-[40px] text-fg-disabled">🛰</div>
|
||||
<div className="text-[11px] text-fg-disabled" style={{ marginTop: 8 }}>위성영상 없음</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -341,7 +341,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
{num(media.satMeta, 'thumbCount') > 0 && (
|
||||
<div className="flex gap-1.5" style={{ marginBottom: 6 }}>
|
||||
{Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-center text-[14px] text-[#30363d] cursor-pointer" style={{
|
||||
<div key={i} className="flex items-center justify-center text-[14px] text-fg-disabled cursor-pointer" style={{
|
||||
width: 40, height: 36, borderRadius: 4,
|
||||
background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22',
|
||||
border: i === 0 ? '2px solid rgba(168,85,247,0.5)' : '1px solid #30363d',
|
||||
@ -350,10 +350,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[8px] text-[#8b949e]">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
🛰 위성 {num(media.satMeta, 'thumbCount')}장 · {str(media.satMeta, 'sensor')}
|
||||
</span>
|
||||
<span className="text-[8px] text-[#58a6ff] cursor-pointer">🔍 편집/측 비교</span>
|
||||
<span className="text-[8px] text-color-info cursor-pointer">🔍 편집/측 비교</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -361,21 +361,21 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
|
||||
{/* ── Q4: CCTV ──────────────────────────────── */}
|
||||
{showCctv && (
|
||||
<div className="flex flex-col overflow-hidden bg-[#0d1117]">
|
||||
<div className="flex flex-col overflow-hidden bg-bg-base">
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-[12px]">📹</span>
|
||||
<span className="text-[12px] font-bold text-[#f0f6fc]">
|
||||
<span className="text-[12px] font-bold text-fg">
|
||||
CCTV — {str(media.cctvMeta, 'title', 'CCTV')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
{bool(media.cctvMeta, 'live') && (
|
||||
<span
|
||||
className="text-[9px] font-bold text-[#22c55e] rounded"
|
||||
className="text-[9px] font-bold text-color-success rounded"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'rgba(34,197,94,0.15)',
|
||||
@ -387,15 +387,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center flex-col gap-2 relative">
|
||||
{bool(media.cctvMeta, 'live') && (
|
||||
<div className="absolute text-[9px] font-bold text-[#ef4444] font-mono" style={{ top: 10, left: 16 }}>
|
||||
<div className="absolute text-[9px] font-bold text-color-danger font-mono" style={{ top: 10, left: 16 }}>
|
||||
● LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[48px] text-[#30363d]">📹</div>
|
||||
<div className="text-[12px] text-[#c9d1d9] font-semibold">
|
||||
<div className="text-[48px] text-fg-disabled">📹</div>
|
||||
<div className="text-[12px] text-fg-sub font-semibold">
|
||||
{str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
|
||||
</div>
|
||||
<div className="text-[9px] text-[#8b949e] font-mono">
|
||||
<div className="text-[9px] text-fg-disabled font-mono">
|
||||
{str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} · {bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
|
||||
</div>
|
||||
</div>
|
||||
@ -408,7 +408,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
{Array.from({ length: num(media.cctvMeta, 'camCount') }).map((_, i) => (
|
||||
<button key={i} onClick={() => setSelectedCam(i)} style={{
|
||||
padding: '6px 16px', borderRadius: 4, fontSize: 10, fontWeight: 600,
|
||||
fontFamily: 'var(--fM)', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', cursor: 'pointer',
|
||||
background: selectedCam === i ? 'rgba(168,85,247,0.12)' : '#161b22',
|
||||
border: selectedCam === i ? '1px solid rgba(168,85,247,0.4)' : '1px solid #30363d',
|
||||
color: selectedCam === i ? '#c084fc' : '#8b949e',
|
||||
@ -416,12 +416,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[8px] text-[#8b949e]">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
📹 CCTV {num(media.cctvMeta, 'camCount')}채널 · {str(media.cctvMeta, 'location')}
|
||||
</span>
|
||||
<div className="flex gap-[8px]">
|
||||
<span className="text-[8px] text-[#ef4444] cursor-pointer">🔴 녹화영상</span>
|
||||
<span className="text-[8px] text-[#58a6ff] cursor-pointer">🎥 PTZ</span>
|
||||
<span className="text-[8px] text-color-danger cursor-pointer">🔴 녹화영상</span>
|
||||
<span className="text-[8px] text-color-info cursor-pointer">🎥 PTZ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -438,12 +438,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
borderTop: '1px solid #30363d',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 text-[10px] font-mono text-[#8b949e]">
|
||||
<span>📷 사진 <b className="text-[#f0f6fc]">{media.photoCnt}</b></span>
|
||||
<span>🎬 영상 <b className="text-[#f0f6fc]">{media.videoCnt}</b></span>
|
||||
<span>🛰 위성 <b className="text-[#f0f6fc]">{media.satCnt}</b></span>
|
||||
<span>📹 CCTV <b className="text-[#f0f6fc]">{media.cctvCnt}</b></span>
|
||||
<span>📎 총 <b className="text-[#c084fc]">{total}건</b></span>
|
||||
<div className="flex gap-4 text-[10px] font-mono text-fg-disabled">
|
||||
<span>📷 사진 <b className="text-fg">{media.photoCnt}</b></span>
|
||||
<span>🎬 영상 <b className="text-fg">{media.videoCnt}</b></span>
|
||||
<span>🛰 위성 <b className="text-fg">{media.satCnt}</b></span>
|
||||
<span>📹 CCTV <b className="text-fg">{media.cctvCnt}</b></span>
|
||||
<span>📎 총 <b className="text-color-tertiary">{total}건</b></span>
|
||||
</div>
|
||||
<div className="flex gap-[8px]">
|
||||
<BottomBtn icon="📥" label="다운로드" bg="rgba(100,116,139,0.1)" bd="rgba(100,116,139,0.2)" fg="#8b949e" />
|
||||
@ -459,7 +459,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
function NavBtn({ label }: { label: string }) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center justify-center text-[10px] text-[#8b949e] cursor-pointer rounded bg-[#161b22]"
|
||||
className="flex items-center justify-center text-[10px] text-fg-disabled cursor-pointer rounded bg-bg-elevated"
|
||||
style={{
|
||||
width: 22, height: 22,
|
||||
border: '1px solid #30363d',
|
||||
|
||||
@ -48,13 +48,13 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
)
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(138,150,168,0.15)] text-text-3">
|
||||
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(138,150,168,0.15)] text-fg-disabled">
|
||||
대기
|
||||
</span>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(239,68,68,0.15)] text-status-red">
|
||||
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(239,68,68,0.15)] text-color-danger">
|
||||
오류
|
||||
</span>
|
||||
)
|
||||
@ -97,7 +97,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
return pages.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<span key={`ellipsis-${index}`} className="px-3 py-1 text-text-3">
|
||||
<span key={`ellipsis-${index}`} className="px-3 py-1 text-fg-disabled">
|
||||
...
|
||||
</span>
|
||||
)
|
||||
@ -108,8 +108,8 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
onClick={() => setCurrentPage(page as number)}
|
||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'text-text-2 hover:bg-bg-2'
|
||||
? 'bg-color-accent text-bg-0'
|
||||
: 'text-fg-sub hover:bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
@ -119,12 +119,12 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-text-1">유출유 확산 예측 목록</h1>
|
||||
<p className="text-sm text-text-3 mt-1">총 {analyses.length}건</p>
|
||||
<h1 className="text-xl font-bold text-fg">유출유 확산 예측 목록</h1>
|
||||
<p className="text-sm text-fg-disabled mt-1">총 {analyses.length}건</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
@ -133,10 +133,10 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-64 px-4 py-2 text-sm bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none"
|
||||
className="w-64 px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => onTabChange('analysis')} className="px-4 py-2 text-sm font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
|
||||
<button onClick={() => onTabChange('analysis')} className="px-4 py-2 text-sm font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
|
||||
+ 새 분석
|
||||
</button>
|
||||
</div>
|
||||
@ -145,38 +145,38 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-text-3 text-sm">로딩 중...</div>
|
||||
<div className="text-center py-20 text-fg-disabled text-sm">로딩 중...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-bg-1 border-b border-border z-10">
|
||||
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">번호</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고일시</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">예측 실행</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">예측시간</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">유종</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider">유출량</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">KOSPS</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">POSEIDON</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">OpenDrift</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">역추적</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">담당자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">소속</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">번호</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">사고명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">사고일시</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">예측 실행</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">예측시간</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">유종</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-fg-disabled uppercase tracking-wider">유출량</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-fg-disabled uppercase tracking-wider">KOSPS</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-fg-disabled uppercase tracking-wider">POSEIDON</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-fg-disabled uppercase tracking-wider">OpenDrift</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-fg-disabled uppercase tracking-wider">역추적</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">담당자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">소속</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{currentAnalyses.map((analysis) => (
|
||||
<tr
|
||||
key={analysis.predRunSn ?? analysis.acdntSn}
|
||||
className="hover:bg-bg-2 transition-colors cursor-pointer group"
|
||||
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.acdntSn}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{analysis.acdntSn}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-status-red animate-pulse" />
|
||||
<span className="w-2 h-2 rounded-full bg-color-danger animate-pulse" />
|
||||
<span
|
||||
className="text-sm font-semibold text-primary-cyan hover:underline transition-all cursor-pointer"
|
||||
className="text-sm font-semibold text-color-accent hover:underline transition-all cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (onSelectAnalysis) {
|
||||
@ -188,19 +188,19 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.occurredAt ? new Date(analysis.occurredAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.runDtm ? new Date(analysis.runDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.duration}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{analysis.oilType}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold">
|
||||
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{analysis.occurredAt ? new Date(analysis.occurredAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{analysis.runDtm ? new Date(analysis.runDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{analysis.duration}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub">{analysis.oilType}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg font-mono text-right font-semibold">
|
||||
{analysis.volume != null ? analysis.volume.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.kospsStatus)}</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.poseidonStatus)}</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.opendriftStatus)}</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.backtrackStatus)}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{analysis.analyst}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{analysis.officeName}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub">{analysis.analyst}</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-sub">{analysis.officeName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -208,23 +208,23 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
)}
|
||||
|
||||
{!loading && analyses.length === 0 && (
|
||||
<div className="text-center py-20 text-text-3 text-sm">분석 데이터가 없습니다.</div>
|
||||
<div className="text-center py-20 text-fg-disabled text-sm">분석 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-center gap-2 px-5 py-4 border-t border-border">
|
||||
<div className="flex items-center justify-center gap-2 px-5 py-4 border-t border-stroke">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm font-medium text-text-2 hover:bg-bg-2 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-sm font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
≪
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm font-medium text-text-2 hover:bg-bg-2 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-sm font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
@ -234,14 +234,14 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm font-medium text-text-2 hover:bg-bg-2 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-sm font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm font-medium text-text-2 hover:bg-bg-2 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-sm font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
≫
|
||||
</button>
|
||||
|
||||
@ -56,7 +56,7 @@ export function BacktrackModal({
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
background: 'var(--bg3)',
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--bd)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--t1)',
|
||||
@ -77,14 +77,14 @@ export function BacktrackModal({
|
||||
>
|
||||
<div style={{
|
||||
width: '580px', maxHeight: 'calc(100vh - 120px)',
|
||||
background: 'var(--bg1)',
|
||||
background: 'var(--bg-surface)',
|
||||
borderRadius: '14px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
}} className="border border-border overflow-hidden flex flex-col">
|
||||
}} className="border border-stroke overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
}} className="border-b border-border flex items-center gap-[14px]">
|
||||
}} className="border-b border-stroke flex items-center gap-[14px]">
|
||||
<div style={{
|
||||
width: '40px', height: '40px', borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(6,182,212,0.2))',
|
||||
@ -97,7 +97,7 @@ export function BacktrackModal({
|
||||
<h2 className="text-base font-bold m-0">
|
||||
유출유 역추적 분석
|
||||
</h2>
|
||||
<div className="text-[11px] text-text-3 mt-[2px]">
|
||||
<div className="text-[11px] text-fg-disabled mt-[2px]">
|
||||
AIS 항적 기반 유출 선박 추정
|
||||
</div>
|
||||
</div>
|
||||
@ -105,10 +105,10 @@ export function BacktrackModal({
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '32px', height: '32px', borderRadius: '8px',
|
||||
background: 'var(--bg3)',
|
||||
background: 'var(--bg-card)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
className="border border-border text-text-3 cursor-pointer flex items-center justify-center"
|
||||
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@ -120,7 +120,7 @@ export function BacktrackModal({
|
||||
}} className="flex-1 overflow-y-auto flex flex-col gap-4">
|
||||
{/* Analysis Conditions */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-bold text-text-2 mb-[10px]">
|
||||
<h3 className="text-[12px] font-bold text-fg-sub mb-[10px]">
|
||||
분석 조건
|
||||
</h3>
|
||||
<div style={{
|
||||
@ -128,10 +128,10 @@ export function BacktrackModal({
|
||||
}}>
|
||||
{/* 유출 추정 시각 */}
|
||||
<div style={{
|
||||
padding: '10px 12px', background: 'var(--bg3)',
|
||||
padding: '10px 12px', background: 'var(--bg-card)',
|
||||
borderRadius: '8px',
|
||||
}} className="border border-border">
|
||||
<div className="text-[9px] text-text-3 mb-1">
|
||||
}} className="border border-stroke">
|
||||
<div className="text-[9px] text-fg-disabled mb-1">
|
||||
유출 추정 시각
|
||||
</div>
|
||||
<input
|
||||
@ -145,10 +145,10 @@ export function BacktrackModal({
|
||||
|
||||
{/* 분석 범위 */}
|
||||
<div style={{
|
||||
padding: '10px 12px', background: 'var(--bg3)',
|
||||
padding: '10px 12px', background: 'var(--bg-card)',
|
||||
borderRadius: '8px',
|
||||
}} className="border border-border">
|
||||
<div className="text-[9px] text-text-3 mb-1">
|
||||
}} className="border border-stroke">
|
||||
<div className="text-[9px] text-fg-disabled mb-1">
|
||||
분석 범위
|
||||
</div>
|
||||
<select
|
||||
@ -165,10 +165,10 @@ export function BacktrackModal({
|
||||
|
||||
{/* 탐색 반경 */}
|
||||
<div style={{
|
||||
padding: '10px 12px', background: 'var(--bg3)',
|
||||
padding: '10px 12px', background: 'var(--bg-card)',
|
||||
borderRadius: '8px',
|
||||
}} className="border border-border">
|
||||
<div className="text-[9px] text-text-3 mb-1">
|
||||
}} className="border border-stroke">
|
||||
<div className="text-[9px] text-fg-disabled mb-1">
|
||||
탐색 반경
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@ -182,16 +182,16 @@ export function BacktrackModal({
|
||||
step={0.5}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<span className="text-[10px] text-text-3 shrink-0">NM</span>
|
||||
<span className="text-[10px] text-fg-disabled shrink-0">NM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유출 위치 */}
|
||||
<div style={{
|
||||
padding: '10px 12px', background: 'var(--bg3)',
|
||||
padding: '10px 12px', background: 'var(--bg-card)',
|
||||
borderRadius: '8px',
|
||||
}} className="border border-border">
|
||||
<div className="text-[9px] text-text-3 mb-1">
|
||||
}} className="border border-stroke">
|
||||
<div className="text-[9px] text-fg-disabled mb-1">
|
||||
유출 위치
|
||||
</div>
|
||||
<div className="text-[12px] font-semibold font-mono">
|
||||
@ -201,15 +201,15 @@ export function BacktrackModal({
|
||||
|
||||
{/* 분석 대상 선박 */}
|
||||
<div style={{
|
||||
padding: '10px 12px', background: 'var(--bg3)',
|
||||
padding: '10px 12px', background: 'var(--bg-card)',
|
||||
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '8px',
|
||||
gridColumn: '1 / -1',
|
||||
}}>
|
||||
<div className="text-[9px] text-text-3 mb-1">
|
||||
<div className="text-[9px] text-fg-disabled mb-1">
|
||||
분석 대상 선박
|
||||
</div>
|
||||
<div className="text-sm font-bold text-primary-purple font-mono">
|
||||
{conditions.totalVessels}척 <span className="text-[10px] font-medium text-text-3">(AIS 수신)</span>
|
||||
<div className="text-sm font-bold text-color-tertiary font-mono">
|
||||
{conditions.totalVessels}척 <span className="text-[10px] font-medium text-fg-disabled">(AIS 수신)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -219,13 +219,13 @@ export function BacktrackModal({
|
||||
{phase === 'results' && vessels.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-[12px] font-bold text-text-2 m-0">
|
||||
<h3 className="text-[12px] font-bold text-fg-sub m-0">
|
||||
분석 결과
|
||||
</h3>
|
||||
<div style={{
|
||||
padding: '4px 10px', borderRadius: '12px',
|
||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
}} className="text-[10px] font-bold text-status-red">
|
||||
}} className="text-[10px] font-bold text-color-danger">
|
||||
{conditions.totalVessels}척 중 {vessels.length}척 의심
|
||||
</div>
|
||||
</div>
|
||||
@ -242,14 +242,14 @@ export function BacktrackModal({
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
}} className="border-t border-border flex gap-2">
|
||||
}} className="border-t border-stroke flex gap-2">
|
||||
{phase === 'conditions' && (
|
||||
<button
|
||||
onClick={() => onRunAnalysis({ estimatedSpillTime: inputTime, analysisRange: inputRange, searchRadius: inputRadius })}
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: 'linear-gradient(135deg, var(--purple), var(--cyan))',
|
||||
background: 'linear-gradient(135deg, var(--color-tertiary), var(--color-accent))',
|
||||
border: 'none', color: '#fff',
|
||||
}}
|
||||
className="flex-1 text-[13px] font-bold cursor-pointer"
|
||||
@ -263,10 +263,10 @@ export function BacktrackModal({
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--bg3)',
|
||||
color: 'var(--purple)', cursor: 'wait',
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--color-tertiary)', cursor: 'wait',
|
||||
}}
|
||||
className="flex-1 text-[13px] font-bold border border-border"
|
||||
className="flex-1 text-[13px] font-bold border border-stroke"
|
||||
>
|
||||
⏳ AIS 항적 분석중...
|
||||
</button>
|
||||
@ -277,7 +277,7 @@ export function BacktrackModal({
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: 'linear-gradient(135deg, var(--purple), var(--cyan))',
|
||||
background: 'linear-gradient(135deg, var(--color-tertiary), var(--color-accent))',
|
||||
border: 'none', color: '#fff',
|
||||
}}
|
||||
className="flex-1 text-[13px] font-bold cursor-pointer"
|
||||
@ -292,15 +292,15 @@ export function BacktrackModal({
|
||||
}
|
||||
|
||||
function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
const probColor = vessel.probability >= 80 ? 'var(--red)' :
|
||||
vessel.probability >= 20 ? 'var(--orange)' : 'var(--t3)'
|
||||
const probColor = vessel.probability >= 80 ? 'var(--color-danger)' :
|
||||
vessel.probability >= 20 ? 'var(--color-warning)' : 'var(--fg-disabled)'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '14px', background: 'var(--bg0)',
|
||||
padding: '14px', background: 'var(--bg-base)',
|
||||
borderLeft: `4px solid ${vessel.color}`,
|
||||
borderRadius: '10px',
|
||||
}} className="border border-border">
|
||||
}} className="border border-stroke">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-[10px] mb-[10px]">
|
||||
<div style={{
|
||||
@ -314,7 +314,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
<div className="text-[13px] font-bold font-mono">
|
||||
{vessel.name}
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-mono mt-[2px]">
|
||||
<div className="text-[9px] text-fg-disabled font-mono mt-[2px]">
|
||||
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
|
||||
</div>
|
||||
</div>
|
||||
@ -322,7 +322,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
<div style={{ fontSize: '22px', color: probColor, lineHeight: 1 }} className="font-bold font-mono">
|
||||
{vessel.probability}%
|
||||
</div>
|
||||
<div className="text-[8px] text-text-3">유출 확률</div>
|
||||
<div className="text-[8px] text-fg-disabled">유출 확률</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -335,14 +335,14 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
{ label: 'AIS 상태', value: vessel.aisStatus, highlight: vessel.aisStatus === '충돌신호' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{
|
||||
padding: '6px', background: 'var(--bg3)', borderRadius: '6px',
|
||||
border: s.highlight ? '1px solid rgba(239,68,68,0.3)' : '1px solid var(--bd)',
|
||||
padding: '6px', background: 'var(--bg-card)', borderRadius: '6px',
|
||||
border: s.highlight ? '1px solid rgba(239,68,68,0.3)' : '1px solid var(--stroke-default)',
|
||||
}}>
|
||||
<div className="text-[8px] text-text-3 mb-[2px]">
|
||||
<div className="text-[8px] text-fg-disabled mb-[2px]">
|
||||
{s.label}
|
||||
</div>
|
||||
<div style={{
|
||||
color: s.highlight ? 'var(--red)' : 'var(--t1)',
|
||||
color: s.highlight ? 'var(--color-danger)' : 'var(--fg-default)',
|
||||
}} className="text-[10px] font-semibold font-mono">
|
||||
{s.value}
|
||||
</div>
|
||||
@ -356,7 +356,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
padding: '8px 10px', background: 'rgba(239,68,68,0.05)',
|
||||
border: '1px solid rgba(239,68,68,0.15)', borderRadius: '6px',
|
||||
lineHeight: '1.5',
|
||||
}} className="text-[9px] text-text-2">
|
||||
}} className="text-[9px] text-fg-sub">
|
||||
{vessel.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -17,7 +17,7 @@ export function BoomDeploymentTheoryView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-bg-0">
|
||||
<div className="flex flex-col h-full overflow-hidden bg-bg-base">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-5">
|
||||
|
||||
{/* 헤더 */}
|
||||
@ -29,11 +29,11 @@ export function BoomDeploymentTheoryView() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[15px] font-bold">오일펜스 배치 최적화 알고리즘 이론</div>
|
||||
<div className="text-[10px] mt-0.5 text-text-3">Oil Boom Deployment Optimization · 유출유 확산예측 연동 · 방제효율 최대화</div>
|
||||
<div className="text-[10px] mt-0.5 text-fg-disabled">Oil Boom Deployment Optimization · 유출유 확산예측 연동 · 방제효율 최대화</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleExportPDF}
|
||||
className="px-3.5 py-1.5 rounded-md text-[10px] font-semibold cursor-pointer text-primary-blue"
|
||||
className="px-3.5 py-1.5 rounded-md text-[10px] font-semibold cursor-pointer text-color-info"
|
||||
style={{ border: '1px solid rgba(59,130,246,.3)', background: 'rgba(59,130,246,.08)' }}>
|
||||
📤 PDF 내보내기
|
||||
</button>
|
||||
@ -41,7 +41,7 @@ export function BoomDeploymentTheoryView() {
|
||||
|
||||
{/* 내부 네비게이션 */}
|
||||
<div className="mb-5">
|
||||
<div className="flex gap-[3px] rounded-lg p-1 bg-bg-3 border border-border">
|
||||
<div className="flex gap-[3px] rounded-lg p-1 bg-bg-card border border-stroke">
|
||||
{boomTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@ -49,8 +49,8 @@ export function BoomDeploymentTheoryView() {
|
||||
className="flex-1 py-2 px-2 text-[12px] font-semibold rounded-md transition-all"
|
||||
style={{
|
||||
background: activePanel === tab.id ? 'rgba(249,115,22,.15)' : undefined,
|
||||
color: activePanel === tab.id ? 'var(--orange)' : 'var(--t3)',
|
||||
border: activePanel === tab.id ? '1px solid rgba(249,115,22,.3)' : '1px solid var(--bd)',
|
||||
color: activePanel === tab.id ? 'var(--color-warning)' : 'var(--fg-disabled)',
|
||||
border: activePanel === tab.id ? '1px solid rgba(249,115,22,.3)' : '1px solid var(--stroke-default)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
@ -79,22 +79,22 @@ function OverviewPanel() {
|
||||
{/* 인트로 카드 */}
|
||||
<div className="rounded-xl p-5 mb-4 relative overflow-hidden"
|
||||
style={{ background: 'linear-gradient(135deg,rgba(249,115,22,.06),rgba(234,179,8,.04))', border: '1px solid rgba(249,115,22,.2)' }}>
|
||||
<div className="absolute top-0 left-0 right-0 h-[3px]" style={{ background: 'linear-gradient(90deg,var(--orange),var(--yellow),var(--cyan))' }} />
|
||||
<div className="absolute top-0 left-0 right-0 h-[3px]" style={{ background: 'linear-gradient(90deg,var(--color-warning),var(--color-caution),var(--color-accent))' }} />
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<div className="text-[13px] font-bold mb-2">🛡️ 오일펜스 배치 최적화란?</div>
|
||||
<div className="text-[11px] leading-[1.8] text-text-2">
|
||||
해양 유류오염 발생 시 <b className="text-status-orange">유출유 확산 예측 결과</b>와 실시간 해양환경(조류·풍향·파고)을 연동하여, 제한된 방제자원(오일펜스 길이·방제정 수)으로 <b className="text-primary-cyan">오염 확산 차단 효율을 최대화</b>하는 최적 배치 지점·형태·순서를 자동 산출하는 수치 알고리즘 체계입니다.
|
||||
<div className="text-[11px] leading-[1.8] text-fg-sub">
|
||||
해양 유류오염 발생 시 <b className="text-color-warning">유출유 확산 예측 결과</b>와 실시간 해양환경(조류·풍향·파고)을 연동하여, 제한된 방제자원(오일펜스 길이·방제정 수)으로 <b className="text-color-accent">오염 확산 차단 효율을 최대화</b>하는 최적 배치 지점·형태·순서를 자동 산출하는 수치 알고리즘 체계입니다.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[13px] font-bold mb-2">🎯 WING 최적화 목표</div>
|
||||
<div className="flex flex-col gap-[5px] text-[11px] text-text-2">
|
||||
<div className="flex flex-col gap-[5px] text-[11px] text-fg-sub">
|
||||
{[
|
||||
{ num: '①', color: 'var(--orange)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.12)', text: '차단 면적 최대화 — 예측 유출유 확산 경계와 오일펜스 교차 면적 극대화' },
|
||||
{ num: '②', color: 'var(--cyan)', bg: 'rgba(6,182,212,.05)', bd: 'rgba(6,182,212,.12)', text: '도달시간 최소화 — 유출유 해안·ESI 민감구역 도달 전 선제적 차단선 구축' },
|
||||
{ num: '③', color: 'var(--green)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.12)', text: '자원 효율 최적화 — 가용 오일펜스 길이·방제정 수·이동시간 제약 충족' },
|
||||
{ num: '④', color: 'var(--purple)', bg: 'rgba(168,85,247,.05)', bd: 'rgba(168,85,247,.12)', text: '실패 안전성 확보 — 조류 초과 시 오일펜스 이탈 방지 방향각 자동 보정' },
|
||||
{ num: '①', color: 'var(--color-warning)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.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-success)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.12)', text: '자원 효율 최적화 — 가용 오일펜스 길이·방제정 수·이동시간 제약 충족' },
|
||||
{ num: '④', color: 'var(--color-tertiary)', bg: 'rgba(168,85,247,.05)', bd: 'rgba(168,85,247,.12)', text: '실패 안전성 확보 — 조류 초과 시 오일펜스 이탈 방지 방향각 자동 보정' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="px-2.5 py-1.5 rounded-md" style={{ background: item.bg, border: `1px solid ${item.bd}` }}>
|
||||
<span className="font-bold" style={{ color: item.color }}>{item.num}</span> <b>{item.text}</b>
|
||||
@ -106,16 +106,16 @@ function OverviewPanel() {
|
||||
</div>
|
||||
|
||||
{/* 전체 흐름도 */}
|
||||
<div className="rounded-md p-4 mb-4 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-4 mb-4 bg-bg-card border border-stroke">
|
||||
<div className="text-xs font-bold mb-3.5">⚙️ WING 오일펜스 배치 최적화 전체 흐름</div>
|
||||
<div className="flex items-center justify-center gap-0 flex-nowrap overflow-x-auto py-2">
|
||||
{[
|
||||
{ icon: '🌊', label: '확산예측', sub: 'KOSPS/POSEIDON\nOpenDrift', color: 'var(--orange)', bg: 'rgba(249,115,22,.08)', bd: 'rgba(249,115,22,.2)' },
|
||||
{ icon: '📡', label: '환경입력', sub: '조류·풍향\n파고·수심', color: 'var(--cyan)', bg: 'rgba(6,182,212,.08)', bd: 'rgba(6,182,212,.2)' },
|
||||
{ icon: '🗺️', label: '차단선 후보', sub: '격자탐색\n후보지점 생성', color: 'var(--yellow)', bg: 'rgba(234,179,8,.08)', bd: 'rgba(234,179,8,.2)' },
|
||||
{ icon: '⚙️', label: '최적화 엔진', sub: '다목적\n유전알고리즘', color: 'var(--purple)', bg: 'rgba(168,85,247,.08)', bd: 'rgba(168,85,247,.3)', bold: true },
|
||||
{ icon: '✅', label: '배치안 출력', sub: '좌표·형태\n방향·순서', color: 'var(--green)', bg: 'rgba(34,197,94,.08)', bd: 'rgba(34,197,94,.2)' },
|
||||
{ icon: '🗺️', label: '지도 표시', sub: 'ESI 중첩\n방제자원 연계', color: 'var(--blue)', bg: 'rgba(59,130,246,.08)', bd: 'rgba(59,130,246,.2)' },
|
||||
{ icon: '🌊', label: '확산예측', sub: 'KOSPS/POSEIDON\nOpenDrift', color: 'var(--color-warning)', bg: 'rgba(249,115,22,.08)', bd: 'rgba(249,115,22,.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(234,179,8,.08)', bd: 'rgba(234,179,8,.2)' },
|
||||
{ icon: '⚙️', label: '최적화 엔진', sub: '다목적\n유전알고리즘', color: 'var(--color-tertiary)', bg: 'rgba(168,85,247,.08)', bd: 'rgba(168,85,247,.3)', bold: true },
|
||||
{ icon: '✅', label: '배치안 출력', sub: '좌표·형태\n방향·순서', color: 'var(--color-success)', bg: 'rgba(34,197,94,.08)', bd: 'rgba(34,197,94,.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) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="text-center min-w-[80px] rounded-lg px-3 py-2.5 text-[9px]" style={{
|
||||
@ -124,9 +124,9 @@ function OverviewPanel() {
|
||||
}}>
|
||||
<div className="text-[15px] mb-1">{step.icon}</div>
|
||||
<div className="font-bold" style={{ color: step.color }}>{step.label}</div>
|
||||
<div className="text-text-3" style={{ whiteSpace: 'pre-line' }}>{step.sub}</div>
|
||||
<div className="text-fg-disabled" style={{ whiteSpace: 'pre-line' }}>{step.sub}</div>
|
||||
</div>
|
||||
{i < 5 && <div className="px-1.5 text-text-3 text-[14px]">▶</div>}
|
||||
{i < 5 && <div className="px-1.5 text-fg-disabled text-[14px]">▶</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -135,16 +135,16 @@ function OverviewPanel() {
|
||||
{/* 오일펜스 종류 */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{[
|
||||
{ icon: '⛽', title: '고형 오일펜스', color: 'var(--orange)', desc: '단단한 부체와 수중커튼으로 구성. 정적 배치. 항구·좁은 수로에 적합.', specs: ['내조류 한계: 0.5~1.0 knot', '높이: 30~60cm · 수중 30~60cm', '전개속도: 30~60m/hr'] },
|
||||
{ icon: '🌊', title: '공기충전식 오일펜스', color: 'var(--blue)', desc: '공기로 부력 확보. 이동·보관 편리. 해상 광역 차단에 주로 사용.', specs: ['내조류 한계: 0.7~1.5 knot', '높이: 45~90cm · 수중 45~90cm', '전개속도: 100~300m/hr'] },
|
||||
{ icon: '🔄', title: '자항식 오일펜스', color: 'var(--green)', desc: '방제정 예인 또는 자체 추진. U형·V형 동적 배치. 강조류 해역 적합.', specs: ['운용수심: 5m 이상', 'U형·V형·J형 동적 형태', '내조류: 조류각도 보정으로 극복'] },
|
||||
{ icon: '⛽', title: '고형 오일펜스', color: 'var(--color-warning)', 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-success)', desc: '방제정 예인 또는 자체 추진. U형·V형 동적 배치. 강조류 해역 적합.', specs: ['운용수심: 5m 이상', 'U형·V형·J형 동적 형태', '내조류: 조류각도 보정으로 극복'] },
|
||||
].map((boom, i) => (
|
||||
<div key={i} className="rounded-md p-3.5 bg-bg-3 border border-border" style={{ borderTop: `3px solid ${boom.color}` }}>
|
||||
<div key={i} className="rounded-md p-3.5 bg-bg-card border border-stroke" style={{ borderTop: `3px solid ${boom.color}` }}>
|
||||
<div className="text-[11px] font-bold mb-2" style={{ color: boom.color }}>{boom.icon} {boom.title}</div>
|
||||
<div className="text-[10px] mb-2 leading-[1.7] text-text-2">{boom.desc}</div>
|
||||
<div className="flex flex-col gap-[3px] text-[9px] text-text-2">
|
||||
<div className="text-[10px] mb-2 leading-[1.7] text-fg-sub">{boom.desc}</div>
|
||||
<div className="flex flex-col gap-[3px] text-[9px] text-fg-sub">
|
||||
{boom.specs.map((spec, j) => (
|
||||
<div key={j} className="px-[7px] py-[3px] rounded-[3px] text-text-2" style={{ background: `${boom.color}11` }}>{spec}</div>
|
||||
<div key={j} className="px-[7px] py-[3px] rounded-[3px] text-fg-sub" style={{ background: `${boom.color}11` }}>{spec}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -152,17 +152,17 @@ function OverviewPanel() {
|
||||
</div>
|
||||
|
||||
{/* 핵심 제약조건 */}
|
||||
<div className="rounded-md p-3.5 bg-bg-3 border border-border">
|
||||
<div className="text-[11px] font-bold mb-2.5 text-status-red">⚠️ 최적화 핵심 제약조건</div>
|
||||
<div className="grid grid-cols-3 gap-2.5 text-[10px] text-text-2">
|
||||
<div className="rounded-md p-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-[11px] font-bold mb-2.5 text-color-danger">⚠️ 최적화 핵심 제약조건</div>
|
||||
<div className="grid grid-cols-3 gap-2.5 text-[10px] text-fg-sub">
|
||||
{[
|
||||
{ icon: '🌊', title: '조류 제약', color: 'var(--red)', bg: 'rgba(239,68,68,.05)', bd: 'rgba(239,68,68,.15)', lines: ['조류속도 > 임계유속 시', '오일펜스 하단 통과 발생', 'U<0.7 knot 유지 필수', '임계각도 자동 계산 적용'] },
|
||||
{ icon: '📏', title: '자원 제약', color: 'var(--yellow)', bg: 'rgba(234,179,8,.05)', bd: 'rgba(234,179,8,.15)', lines: ['가용 오일펜스 총 길이', '방제정 척수·이동시간', '앵커링 가능 수심 조건', '연결부 허용 장력'] },
|
||||
{ icon: '⏱️', title: '시간 제약', color: 'var(--blue)', bg: 'rgba(59,130,246,.05)', bd: 'rgba(59,130,246,.15)', lines: ['유출유 도달 예측시간', '오일펜스 전개 소요시간', '방제정 현장 도착시간', '조석 변환 주기 고려'] },
|
||||
{ icon: '🌊', title: '조류 제약', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.05)', bd: 'rgba(239,68,68,.15)', lines: ['조류속도 > 임계유속 시', '오일펜스 하단 통과 발생', 'U<0.7 knot 유지 필수', '임계각도 자동 계산 적용'] },
|
||||
{ icon: '📏', title: '자원 제약', color: 'var(--color-caution)', bg: 'rgba(234,179,8,.05)', bd: 'rgba(234,179,8,.15)', lines: ['가용 오일펜스 총 길이', '방제정 척수·이동시간', '앵커링 가능 수심 조건', '연결부 허용 장력'] },
|
||||
{ icon: '⏱️', title: '시간 제약', color: 'var(--color-info)', bg: 'rgba(59,130,246,.05)', bd: 'rgba(59,130,246,.15)', lines: ['유출유 도달 예측시간', '오일펜스 전개 소요시간', '방제정 현장 도착시간', '조석 변환 주기 고려'] },
|
||||
].map((c, i) => (
|
||||
<div key={i} className="p-2.5 rounded-[7px]" style={{ background: c.bg, border: `1px solid ${c.bd}` }}>
|
||||
<div className="font-bold mb-[5px]" style={{ color: c.color }}>{c.icon} {c.title}</div>
|
||||
<div className="leading-[1.7] text-text-2">
|
||||
<div className="leading-[1.7] text-fg-sub">
|
||||
{c.lines.map((l, j) => <span key={j}>{j > 0 && <br />}{l}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
@ -178,44 +178,44 @@ function DeploymentTheoryPanel() {
|
||||
return (
|
||||
<>
|
||||
{/* 차단 효율 이론 */}
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-xs font-bold mb-3">📐 오일펜스 차단 효율 이론 (Boom Containment Efficiency)</div>
|
||||
<div className="grid grid-cols-2 gap-3.5">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold mb-2 text-status-orange">① 차단 효율 함수 E(θ, U)</div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-text-2">
|
||||
<div className="text-[11px] font-bold mb-2 text-color-warning">① 차단 효율 함수 E(θ, U)</div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-fg-sub">
|
||||
오일펜스의 차단 효율은 <b>조류 유속(U)</b>과 <b>오일펜스 방향각(θ)</b>의 함수입니다. 조류가 오일펜스에 수직으로 입사할수록 차단 효율이 낮아지며, 임계유속 초과 시 기름이 오일펜스 하부로 통과합니다.
|
||||
</div>
|
||||
<div className="rounded-md p-2.5 text-[10px] leading-[2.1] bg-bg-0 font-mono" style={{ border: '1px solid rgba(249,115,22,.2)' }}>
|
||||
E(θ,U) = 1 − <span className="text-status-red">F<sub>loss</sub>(U<sub>n</sub>)</span><br />
|
||||
U<sub>n</sub> = U · sin(θ) <span className="text-[9px] text-text-3">(법선방향 유속)</span><br />
|
||||
<div className="rounded-md p-2.5 text-[10px] leading-[2.1] bg-bg-base font-mono" style={{ border: '1px solid rgba(249,115,22,.2)' }}>
|
||||
E(θ,U) = 1 − <span className="text-color-danger">F<sub>loss</sub>(U<sub>n</sub>)</span><br />
|
||||
U<sub>n</sub> = U · sin(θ) <span className="text-[9px] text-fg-disabled">(법선방향 유속)</span><br />
|
||||
E = 1 (U<sub>n</sub> ≤ U<sub>c</sub>)<br />
|
||||
E = max(0, 1 − (U<sub>n</sub>/U<sub>c</sub>)²) (U<sub>n</sub> > U<sub>c</sub>)<br />
|
||||
<span className="text-[9px] text-text-3">U<sub>c</sub>: 임계유속(약 0.35m/s = 0.7 knot)</span>
|
||||
<span className="text-[9px] text-fg-disabled">U<sub>c</sub>: 임계유속(약 0.35m/s = 0.7 knot)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold mb-2 text-primary-cyan">② 최적 방향각 θ* 산정</div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-text-2">
|
||||
오일펜스 방향각은 조류 방향에 따라 최적화됩니다. 차단 면적과 오일 수집 효율의 <b>트레이드오프</b>를 고려하여, 일반적으로 조류에 대해 <b className="text-primary-cyan">30°~45° 예각 배치</b>가 최적입니다.
|
||||
<div className="text-[11px] font-bold mb-2 text-color-accent">② 최적 방향각 θ* 산정</div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-fg-sub">
|
||||
오일펜스 방향각은 조류 방향에 따라 최적화됩니다. 차단 면적과 오일 수집 효율의 <b>트레이드오프</b>를 고려하여, 일반적으로 조류에 대해 <b className="text-color-accent">30°~45° 예각 배치</b>가 최적입니다.
|
||||
</div>
|
||||
<div className="rounded-md p-2.5 text-[10px] leading-[2.1] bg-bg-0 font-mono" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
|
||||
θ* = arcsin(U<sub>c</sub> / U) <span className="text-[9px] text-text-3">(임계조건)</span><br />
|
||||
<div className="rounded-md p-2.5 text-[10px] leading-[2.1] bg-bg-base font-mono" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
|
||||
θ* = arcsin(U<sub>c</sub> / U) <span className="text-[9px] text-fg-disabled">(임계조건)</span><br />
|
||||
θ<sub>opt</sub> = argmax [A<sub>block</sub>(θ) · E(θ,U)]<br />
|
||||
실용범위: 15° ≤ θ ≤ 60°<br />
|
||||
<span className="text-[9px] text-text-3">단, θ < arcsin(U<sub>c</sub>/U) 이면 기름 통과 발생</span>
|
||||
<span className="text-[9px] text-fg-disabled">단, θ < arcsin(U<sub>c</sub>/U) 이면 기름 통과 발생</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* V형·U형·J형 배치 패턴 */}
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-xs font-bold mb-3">🔷 오일펜스 배치 형태별 이론</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* V형 */}
|
||||
<div className="rounded-lg p-3.5 bg-bg-0" style={{ border: '1px solid rgba(6,182,212,.2)', borderTop: '3px solid var(--cyan)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-primary-cyan">V형 (Chevron)</div>
|
||||
<div className="rounded-lg p-3.5 bg-bg-base" style={{ border: '1px solid rgba(6,182,212,.2)', borderTop: '3px solid var(--color-accent)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-color-accent">V형 (Chevron)</div>
|
||||
<div className="flex items-center justify-center p-4 rounded-md mb-2" style={{ background: 'rgba(6,182,212,.04)' }}>
|
||||
<svg width="180" height="120" viewBox="0 0 100 65" className="overflow-visible">
|
||||
<defs><marker id="arr1" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto"><path d="M0,0 L0,6 L6,3 z" fill="rgba(6,182,212,.7)" /></marker></defs>
|
||||
@ -227,17 +227,17 @@ function DeploymentTheoryPanel() {
|
||||
<text x="58" y="64" fill="rgba(6,182,212,.7)" fontSize="6">조류</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[10px] leading-[1.7] mb-[7px] text-text-2">조류 방향 정면에서 양측으로 펼친 V형. 기름을 중앙 집유점으로 유도. 회수선 배치 용이.</div>
|
||||
<div className="text-[10px] leading-[1.7] mb-[7px] text-fg-sub">조류 방향 정면에서 양측으로 펼친 V형. 기름을 중앙 집유점으로 유도. 회수선 배치 용이.</div>
|
||||
<div className="rounded-[5px] p-[7px] text-[9px] leading-[1.9] font-mono" style={{ background: 'rgba(6,182,212,.05)' }}>
|
||||
A<sub>V</sub> = L²·sin(2α)/2<br />
|
||||
<span className="text-text-3">α: 반개각, L: 편측 길이</span><br />
|
||||
<span className="text-fg-disabled">α: 반개각, L: 편측 길이</span><br />
|
||||
최적 α = 30°~45°
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* U형 */}
|
||||
<div className="rounded-lg p-3.5 bg-bg-0" style={{ border: '1px solid rgba(34,197,94,.2)', borderTop: '3px solid var(--green)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-status-green">U형 (Horseshoe)</div>
|
||||
<div className="rounded-lg p-3.5 bg-bg-base" style={{ border: '1px solid rgba(34,197,94,.2)', borderTop: '3px solid var(--color-success)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-color-success">U형 (Horseshoe)</div>
|
||||
<div className="flex items-center justify-center p-4 rounded-md mb-2" style={{ background: 'rgba(34,197,94,.04)' }}>
|
||||
<svg width="180" height="120" viewBox="0 0 100 65" className="overflow-visible">
|
||||
<defs><marker id="arr2" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto"><path d="M0,0 L0,6 L6,3 z" fill="rgba(6,182,212,.7)" /></marker></defs>
|
||||
@ -248,17 +248,17 @@ function DeploymentTheoryPanel() {
|
||||
<text x="58" y="5" fill="rgba(6,182,212,.7)" fontSize="6">조류</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[10px] leading-[1.7] mb-[7px] text-text-2">말굽형으로 기름을 완전 포위. 폐쇄형 구조로 회수 효율 최고. 저조류 해역 적합.</div>
|
||||
<div className="text-[10px] leading-[1.7] mb-[7px] text-fg-sub">말굽형으로 기름을 완전 포위. 폐쇄형 구조로 회수 효율 최고. 저조류 해역 적합.</div>
|
||||
<div className="rounded-[5px] p-[7px] text-[9px] leading-[1.9] font-mono" style={{ background: 'rgba(34,197,94,.05)' }}>
|
||||
A<sub>U</sub> = π·r²/2 + 2r·h<br />
|
||||
<span className="text-text-3">r: 반경, h: 직선부 길이</span><br />
|
||||
<span className="text-fg-disabled">r: 반경, h: 직선부 길이</span><br />
|
||||
전제: U < 0.5 knot
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* J형 */}
|
||||
<div className="rounded-lg p-3.5 bg-bg-0" style={{ border: '1px solid rgba(168,85,247,.2)', borderTop: '3px solid var(--purple)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-primary-purple">J형 (Skimming)</div>
|
||||
<div className="rounded-lg p-3.5 bg-bg-base" style={{ border: '1px solid rgba(168,85,247,.2)', borderTop: '3px solid var(--color-tertiary)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-color-tertiary">J형 (Skimming)</div>
|
||||
<div className="flex items-center justify-center p-4 rounded-md mb-2" style={{ background: 'rgba(168,85,247,.04)' }}>
|
||||
<svg width="180" height="120" viewBox="0 0 100 65" className="overflow-visible">
|
||||
<defs><marker id="arr3" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto"><path d="M0,0 L0,6 L6,3 z" fill="rgba(6,182,212,.7)" /></marker></defs>
|
||||
@ -270,10 +270,10 @@ function DeploymentTheoryPanel() {
|
||||
<text x="58" y="5" fill="rgba(6,182,212,.7)" fontSize="6">조류</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[10px] leading-[1.7] mb-[7px] text-text-2">직선+곡선 조합. 기름을 한쪽으로 편향 유도하여 집유. 강조류·연안 배치에 최적.</div>
|
||||
<div className="text-[10px] leading-[1.7] mb-[7px] text-fg-sub">직선+곡선 조합. 기름을 한쪽으로 편향 유도하여 집유. 강조류·연안 배치에 최적.</div>
|
||||
<div className="rounded-[5px] p-[7px] text-[9px] leading-[1.9] font-mono" style={{ background: 'rgba(168,85,247,.05)' }}>
|
||||
θ<sub>J</sub> = arcsin(U<sub>c</sub>/U) + δ<br />
|
||||
<span className="text-text-3">δ: 안전여유각(5°~10°)</span><br />
|
||||
<span className="text-fg-disabled">δ: 안전여유각(5°~10°)</span><br />
|
||||
활용: U > 0.7 knot
|
||||
</div>
|
||||
</div>
|
||||
@ -281,24 +281,24 @@ function DeploymentTheoryPanel() {
|
||||
</div>
|
||||
|
||||
{/* 다단 배치 이론 */}
|
||||
<div className="rounded-md p-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-[11px] font-bold mb-2.5">🔢 다단계 차단선(Multi-Boom) 배치 이론</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="text-[10px] leading-[1.8] text-text-2">
|
||||
<div className="text-[10px] leading-[1.8] text-fg-sub">
|
||||
단일 오일펜스로 차단 불가한 경우 <b>직렬 다단 배치</b>로 누적 차단 효율을 향상합니다. n개 직렬 배치 시 누적 차단 효율:
|
||||
<div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-0 font-mono">
|
||||
<div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-base font-mono">
|
||||
E<sub>total</sub> = 1 − ∏(1−E<sub>i</sub>)<br />
|
||||
<span className="text-[9px] text-text-3">E<sub>i</sub>: i번째 오일펜스 단독 차단효율</span>
|
||||
<span className="text-[9px] text-fg-disabled">E<sub>i</sub>: i번째 오일펜스 단독 차단효율</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[5px] text-[9px] text-text-2">
|
||||
<div className="flex flex-col gap-[5px] text-[9px] text-fg-sub">
|
||||
{[
|
||||
{ color: 'var(--green)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.12)', label: '2단 직렬', text: ': E_total = E₁+E₂−E₁·E₂ (예: 70%+70% → 91%)' },
|
||||
{ color: 'var(--cyan)', bg: 'rgba(6,182,212,.05)', bd: 'rgba(6,182,212,.12)', label: '단간 거리', text: ': 부표 집적 방지를 위해 ≥ 200m 이격 권장' },
|
||||
{ color: 'var(--purple)', bg: 'rgba(168,85,247,.05)', bd: 'rgba(168,85,247,.12)', label: '배치 우선순위', text: ': ESI 고등급 구역 보호 → 취수원 → 어항 순' },
|
||||
{ color: 'var(--orange)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.12)', label: '조석 변화', text: ': 창조·낙조 전환 시 오일펜스 방향 재조정 필요' },
|
||||
{ color: 'var(--color-success)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.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-tertiary)', bg: 'rgba(168,85,247,.05)', bd: 'rgba(168,85,247,.12)', label: '배치 우선순위', text: ': ESI 고등급 구역 보호 → 취수원 → 어항 순' },
|
||||
{ color: 'var(--color-warning)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.12)', label: '조석 변화', text: ': 창조·낙조 전환 시 오일펜스 방향 재조정 필요' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="px-[9px] py-1.5 rounded-[5px] text-text-2" style={{ background: item.bg, border: `1px solid ${item.bd}` }}>
|
||||
<div key={i} className="px-[9px] py-1.5 rounded-[5px] text-fg-sub" style={{ background: item.bg, border: `1px solid ${item.bd}` }}>
|
||||
<b style={{ color: item.color }}>{item.label}</b>{item.text}
|
||||
</div>
|
||||
))}
|
||||
@ -316,54 +316,54 @@ function OptimizationPanel() {
|
||||
{/* 다목적 최적화 개요 */}
|
||||
<div className="rounded-xl p-4 mb-3.5 relative overflow-hidden"
|
||||
style={{ background: 'linear-gradient(135deg,rgba(168,85,247,.06),rgba(59,130,246,.04))', border: '1px solid rgba(168,85,247,.2)' }}>
|
||||
<div className="absolute top-0 left-0 right-0 h-[3px]" style={{ background: 'linear-gradient(90deg,var(--purple),var(--blue))' }} />
|
||||
<div className="absolute top-0 left-0 right-0 h-[3px]" style={{ background: 'linear-gradient(90deg,var(--color-tertiary),var(--color-info))' }} />
|
||||
<div className="text-[13px] font-bold mb-2">⚙️ 다목적 최적화 문제 (Multi-Objective Optimization)</div>
|
||||
<div className="text-[11px] leading-[1.8] text-text-2">
|
||||
오일펜스 배치 최적화는 <b className="text-primary-purple">상충하는 복수 목적함수</b>를 동시에 만족해야 하는 전형적인 다목적 최적화 문제입니다. 차단 효율 최대화와 자원 사용 최소화는 서로 트레이드오프 관계를 가지며, <b className="text-primary-cyan">파레토 최적(Pareto Optimal) 해집합</b>에서 의사결정자가 선택합니다.
|
||||
<div className="text-[11px] leading-[1.8] text-fg-sub">
|
||||
오일펜스 배치 최적화는 <b className="text-color-tertiary">상충하는 복수 목적함수</b>를 동시에 만족해야 하는 전형적인 다목적 최적화 문제입니다. 차단 효율 최대화와 자원 사용 최소화는 서로 트레이드오프 관계를 가지며, <b className="text-color-accent">파레토 최적(Pareto Optimal) 해집합</b>에서 의사결정자가 선택합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목적함수 정의 */}
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-xs font-bold mb-3">📊 목적함수 및 제약조건 정의</div>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="rounded-lg p-3 bg-bg-0" style={{ border: '1px solid rgba(34,197,94,.2)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-status-green">🎯 목적함수 F(x)</div>
|
||||
<div className="rounded-lg p-3 bg-bg-base" style={{ border: '1px solid rgba(34,197,94,.2)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-color-success">🎯 목적함수 F(x)</div>
|
||||
<div className="rounded-[5px] p-[9px] text-[10px] leading-[2.2] font-mono" style={{ background: 'rgba(34,197,94,.04)' }}>
|
||||
<b className="text-status-green">최대화:</b><br />
|
||||
f₁(x) = Σ A<sub>blocked,i</sub> · w<sub>ESI,i</sub> <span className="text-[9px] text-text-3">(가중 차단면적)</span><br />
|
||||
f₂(x) = T<sub>deadline</sub> − T<sub>deploy</sub> <span className="text-[9px] text-text-3">(여유시간)</span><br />
|
||||
<b className="text-status-red">최소화:</b><br />
|
||||
f₃(x) = Σ L<sub>boom,j</sub> <span className="text-[9px] text-text-3">(총 오일펜스 사용량)</span><br />
|
||||
f₄(x) = Σ D<sub>vessel,k</sub> <span className="text-[9px] text-text-3">(방제정 총 이동거리)</span>
|
||||
<b className="text-color-success">최대화:</b><br />
|
||||
f₁(x) = Σ A<sub>blocked,i</sub> · w<sub>ESI,i</sub> <span className="text-[9px] text-fg-disabled">(가중 차단면적)</span><br />
|
||||
f₂(x) = T<sub>deadline</sub> − T<sub>deploy</sub> <span className="text-[9px] text-fg-disabled">(여유시간)</span><br />
|
||||
<b className="text-color-danger">최소화:</b><br />
|
||||
f₃(x) = Σ L<sub>boom,j</sub> <span className="text-[9px] text-fg-disabled">(총 오일펜스 사용량)</span><br />
|
||||
f₄(x) = Σ D<sub>vessel,k</sub> <span className="text-[9px] text-fg-disabled">(방제정 총 이동거리)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg p-3 bg-bg-0" style={{ border: '1px solid rgba(239,68,68,.2)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-status-red">🚫 제약조건 G(x)</div>
|
||||
<div className="rounded-lg p-3 bg-bg-base" style={{ border: '1px solid rgba(239,68,68,.2)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-color-danger">🚫 제약조건 G(x)</div>
|
||||
<div className="rounded-[5px] p-[9px] text-[10px] leading-[2.2] font-mono" style={{ background: 'rgba(239,68,68,.04)' }}>
|
||||
g₁: U·sin(θ<sub>i</sub>) ≤ U<sub>c</sub> ∀i <span className="text-[9px] text-text-3">(임계유속)</span><br />
|
||||
g₂: Σ L<sub>j</sub> ≤ L<sub>max</sub> <span className="text-[9px] text-text-3">(자원 한계)</span><br />
|
||||
g₃: T<sub>deploy,i</sub> ≤ T<sub>arrive,i</sub> <span className="text-[9px] text-text-3">(시간 제약)</span><br />
|
||||
g₄: d(p<sub>i</sub>, shore) ≥ d<sub>min</sub> <span className="text-[9px] text-text-3">(연안 이격)</span><br />
|
||||
g₅: h(p<sub>i</sub>) ≥ h<sub>min</sub> <span className="text-[9px] text-text-3">(수심 조건)</span>
|
||||
g₁: U·sin(θ<sub>i</sub>) ≤ U<sub>c</sub> ∀i <span className="text-[9px] text-fg-disabled">(임계유속)</span><br />
|
||||
g₂: Σ L<sub>j</sub> ≤ L<sub>max</sub> <span className="text-[9px] text-fg-disabled">(자원 한계)</span><br />
|
||||
g₃: T<sub>deploy,i</sub> ≤ T<sub>arrive,i</sub> <span className="text-[9px] text-fg-disabled">(시간 제약)</span><br />
|
||||
g₄: d(p<sub>i</sub>, shore) ≥ d<sub>min</sub> <span className="text-[9px] text-fg-disabled">(연안 이격)</span><br />
|
||||
g₅: h(p<sub>i</sub>) ≥ h<sub>min</sub> <span className="text-[9px] text-fg-disabled">(수심 조건)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ESI 가중치 */}
|
||||
<div className="rounded-lg p-3 bg-bg-0" style={{ border: '1px solid rgba(234,179,8,.2)' }}>
|
||||
<div className="text-[10px] font-bold mb-2 text-status-yellow">🏖️ ESI 가중치 w<sub>ESI</sub> 설계</div>
|
||||
<div className="grid grid-cols-5 gap-[5px] text-[9px] text-text-2">
|
||||
<div className="rounded-lg p-3 bg-bg-base" style={{ border: '1px solid rgba(234,179,8,.2)' }}>
|
||||
<div className="text-[10px] font-bold mb-2 text-color-caution">🏖️ ESI 가중치 w<sub>ESI</sub> 설계</div>
|
||||
<div className="grid grid-cols-5 gap-[5px] text-[9px] text-fg-sub">
|
||||
{[
|
||||
{ grade: 'ESI 1~2', desc: '노출암반', w: 'w = 0.2', color: 'var(--green)', bg: 'rgba(34,197,94,.06)' },
|
||||
{ grade: 'ESI 3~4', desc: '모래해변', w: 'w = 0.4', color: 'var(--cyan)', bg: 'rgba(6,182,212,.06)' },
|
||||
{ grade: 'ESI 5~6', desc: '자갈·조약', w: 'w = 0.6', color: 'var(--yellow)', bg: 'rgba(234,179,8,.06)' },
|
||||
{ grade: 'ESI 7~8', desc: '갯벌·조간대', w: 'w = 0.85', color: 'var(--orange)', bg: 'rgba(249,115,22,.06)' },
|
||||
{ grade: 'ESI 9~10', desc: '맹그로브·습지', w: 'w = 1.0', color: 'var(--red)', bg: 'rgba(239,68,68,.08)', bd: 'rgba(239,68,68,.2)' },
|
||||
{ grade: 'ESI 1~2', desc: '노출암반', w: 'w = 0.2', color: 'var(--color-success)', bg: 'rgba(34,197,94,.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(234,179,8,.06)' },
|
||||
{ grade: 'ESI 7~8', desc: '갯벌·조간대', w: 'w = 0.85', color: 'var(--color-warning)', bg: 'rgba(249,115,22,.06)' },
|
||||
{ grade: 'ESI 9~10', desc: '맹그로브·습지', w: 'w = 1.0', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.08)', bd: 'rgba(239,68,68,.2)' },
|
||||
].map((esi, i) => (
|
||||
<div key={i} className="p-1.5 rounded text-center" style={{ background: esi.bg, border: esi.bd ? `1px solid ${esi.bd}` : undefined }}>
|
||||
<div className="font-bold" style={{ color: esi.color }}>{esi.grade}</div>
|
||||
<div className="text-text-3">{esi.desc}</div>
|
||||
<div className="text-fg-disabled">{esi.desc}</div>
|
||||
<div className="font-bold">{esi.w}</div>
|
||||
</div>
|
||||
))}
|
||||
@ -372,14 +372,14 @@ function OptimizationPanel() {
|
||||
</div>
|
||||
|
||||
{/* NSGA-II 알고리즘 */}
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-3 border border-border">
|
||||
<div className="text-xs font-bold mb-3 text-primary-purple">🧬 NSGA-II (Non-dominated Sorting Genetic Algorithm II)</div>
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-xs font-bold mb-3 text-color-tertiary">🧬 NSGA-II (Non-dominated Sorting Genetic Algorithm II)</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-text-2">
|
||||
WING의 오일펜스 배치 최적화는 다목적 유전알고리즘 <b className="text-primary-purple">NSGA-II</b>(Deb et al., 2002)를 핵심 엔진으로 사용합니다. 파레토 전면(Pareto Front)을 탐색하여 차단 효율과 자원 효율의 최적 해집합을 제공합니다.
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-fg-sub">
|
||||
WING의 오일펜스 배치 최적화는 다목적 유전알고리즘 <b className="text-color-tertiary">NSGA-II</b>(Deb et al., 2002)를 핵심 엔진으로 사용합니다. 파레토 전면(Pareto Front)을 탐색하여 차단 효율과 자원 효율의 최적 해집합을 제공합니다.
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-[9px] text-text-2">
|
||||
<div className="flex flex-col gap-1 text-[9px] text-fg-sub">
|
||||
{[
|
||||
'염색체 구조 : [배치지점 좌표, 방향각θ, 길이L, 형태, 배치순서]',
|
||||
'집단 크기 : 100~200개체 · 세대수 50~200',
|
||||
@ -387,15 +387,15 @@ function OptimizationPanel() {
|
||||
'변이 연산 : 다항식 변이(Polynomial Mutation) · ηm=20',
|
||||
'선택 방식 : 비지배 정렬 + 혼잡도 거리(Crowding Distance)',
|
||||
].map((item, i) => (
|
||||
<div key={i} className="px-2 py-[5px] rounded text-text-2" style={{ background: 'rgba(168,85,247,.05)', border: '1px solid rgba(168,85,247,.12)' }}>
|
||||
<b className="text-primary-purple">{item.split(' : ')[0]}</b> : {item.split(' : ')[1]}
|
||||
<div key={i} className="px-2 py-[5px] rounded text-fg-sub" style={{ background: 'rgba(168,85,247,.05)', border: '1px solid rgba(168,85,247,.12)' }}>
|
||||
<b className="text-color-tertiary">{item.split(' : ')[0]}</b> : {item.split(' : ')[1]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold mb-[7px] text-text-2">NSGA-II 5단계 진화 루프</div>
|
||||
<div className="flex flex-col gap-[5px] text-[9px] text-text-2">
|
||||
<div className="text-[10px] font-bold mb-[7px] text-fg-sub">NSGA-II 5단계 진화 루프</div>
|
||||
<div className="flex flex-col gap-[5px] text-[9px] text-fg-sub">
|
||||
{[
|
||||
{ step: '①', title: '초기 집단 생성', desc: '확산예측 결과 기반 랜덤 + 휴리스틱 배치안 혼합 초기화' },
|
||||
{ step: '②', title: '적합도 평가', desc: '유출유 확산 시뮬레이터로 각 배치안의 차단면적·도달시간 계산' },
|
||||
@ -404,8 +404,8 @@ function OptimizationPanel() {
|
||||
{ step: '⑤', title: '엘리트 선택', desc: '부모+자식 2N 집단에서 비지배 정렬+혼잡도 기준으로 N개 선택 → 수렴까지 반복' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex gap-2 px-[9px] py-1.5 rounded-[5px]" style={{ background: i === 4 ? 'rgba(168,85,247,.05)' : 'rgba(168,85,247,.04)', border: i === 4 ? '1px solid rgba(168,85,247,.12)' : undefined }}>
|
||||
<span className="min-w-[20px] font-extrabold text-primary-purple">{item.step}</span>
|
||||
<div className="leading-[1.6] text-text-2"><b>{item.title}</b> : {item.desc}</div>
|
||||
<span className="min-w-[20px] font-extrabold text-color-tertiary">{item.step}</span>
|
||||
<div className="leading-[1.6] text-fg-sub"><b>{item.title}</b> : {item.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -414,29 +414,29 @@ function OptimizationPanel() {
|
||||
</div>
|
||||
|
||||
{/* 보조 알고리즘 비교 */}
|
||||
<div className="rounded-md p-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-[11px] font-bold mb-2.5">🔬 보조 최적화 알고리즘 비교 적용</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[10px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(255,255,255,.03)', borderBottom: '1px solid var(--bdL)' }}>
|
||||
<tr style={{ background: 'rgba(255,255,255,.03)', borderBottom: '1px solid var(--stroke-light)' }}>
|
||||
{['알고리즘', '유형', '장점', '단점', 'WING 활용'].map(h => (
|
||||
<th key={h} className="py-[7px] px-2.5 font-semibold text-text-3" style={{ textAlign: h === '알고리즘' ? 'left' : 'center' }}>{h}</th>
|
||||
<th key={h} className="py-[7px] px-2.5 font-semibold text-fg-disabled" style={{ textAlign: h === '알고리즘' ? 'left' : 'center' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ name: 'NSGA-II', color: 'var(--purple)', type: '다목적 GA', pros: '파레토 전면 탐색\n다양성 유지 우수', cons: '계산비용 높음\n수렴 느림', wing: '메인 엔진', wingColor: 'var(--cyan)' },
|
||||
{ name: 'PSO', color: 'var(--orange)', type: '입자군집', pros: '빠른 수렴\n구현 단순', cons: '조기수렴\n다목적 취약', wing: '단일목적 빠른 배치', wingColor: 'var(--t2)' },
|
||||
{ name: 'SA', color: 'var(--blue)', type: '모의담금질', pros: '전역 탈출 우수\n국소최적 회피', cons: '매개변수 민감\n느린 수렴', wing: '긴급 단순 배치', wingColor: 'var(--t2)' },
|
||||
{ name: 'Greedy+휴리스틱', color: 'var(--green)', type: '결정론적', pros: '즉시 결과\n해석 용이', cons: '전역최적 미보장', wing: '실시간 초기 제안', wingColor: 'var(--green)' },
|
||||
{ name: 'NSGA-II', color: 'var(--color-tertiary)', type: '다목적 GA', pros: '파레토 전면 탐색\n다양성 유지 우수', cons: '계산비용 높음\n수렴 느림', wing: '메인 엔진', wingColor: 'var(--color-accent)' },
|
||||
{ name: 'PSO', color: 'var(--color-warning)', 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-success)', type: '결정론적', pros: '즉시 결과\n해석 용이', cons: '전역최적 미보장', wing: '실시간 초기 제안', wingColor: 'var(--color-success)' },
|
||||
].map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid rgba(255,255,255,.04)', background: i % 2 === 1 ? 'rgba(255,255,255,.01)' : undefined }}>
|
||||
<td className="py-[7px] px-2.5 font-bold" style={{ color: row.color }}>{row.name}</td>
|
||||
<td className="py-[7px] px-2.5 text-center text-text-2">{row.type}</td>
|
||||
<td className="py-[7px] px-2.5 text-center text-text-2 whitespace-pre-line">{row.pros}</td>
|
||||
<td className="py-[7px] px-2.5 text-center text-text-2 whitespace-pre-line">{row.cons}</td>
|
||||
<td className="py-[7px] px-2.5 text-center text-fg-sub">{row.type}</td>
|
||||
<td className="py-[7px] px-2.5 text-center text-fg-sub whitespace-pre-line">{row.pros}</td>
|
||||
<td className="py-[7px] px-2.5 text-center text-fg-sub whitespace-pre-line">{row.cons}</td>
|
||||
<td className="py-[7px] px-2.5 text-center" style={{ color: row.wingColor }}>{row.wing}</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -453,54 +453,54 @@ function FluidDynamicsPanel() {
|
||||
return (
|
||||
<>
|
||||
{/* 유동 수치 모델 */}
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-xs font-bold mb-3">🌊 오일펜스 주변 유동 수치 모델</div>
|
||||
<div className="grid grid-cols-2 gap-3.5">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold mb-2 text-primary-blue">① 오일펜스 항력 모델</div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-text-2">오일펜스에 작용하는 항력은 조류속도의 제곱에 비례합니다. 오일펜스 구조 변형(catenary형태)을 고려한 동적 항력 계산.</div>
|
||||
<div className="rounded-md p-2.5 text-[10px] leading-[2.1] bg-bg-0 font-mono" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-color-info">① 오일펜스 항력 모델</div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-fg-sub">오일펜스에 작용하는 항력은 조류속도의 제곱에 비례합니다. 오일펜스 구조 변형(catenary형태)을 고려한 동적 항력 계산.</div>
|
||||
<div className="rounded-md p-2.5 text-[10px] leading-[2.1] bg-bg-base font-mono" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||
F<sub>D</sub> = ½ · ρ · C<sub>D</sub> · A · U<sub>n</sub>²<br />
|
||||
T = F<sub>D</sub> · L / (2·sin(α))<br />
|
||||
<span className="text-[9px] text-text-3">C<sub>D</sub>: 항력계수(≈1.2), A: 수중 투영면적</span><br />
|
||||
<span className="text-[9px] text-text-3">T: 연결부 장력, α: 체인각도</span>
|
||||
<span className="text-[9px] text-fg-disabled">C<sub>D</sub>: 항력계수(≈1.2), A: 수중 투영면적</span><br />
|
||||
<span className="text-[9px] text-fg-disabled">T: 연결부 장력, α: 체인각도</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold mb-2 text-status-orange">② 기름 통과(Splash-over) 조건</div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-text-2">조류 유속이 임계값을 초과하면 기름이 파도를 타고 오일펜스를 넘어가는 Splash-over가 발생합니다.</div>
|
||||
<div className="rounded-md p-2.5 text-[10px] leading-[2.1] bg-bg-0 font-mono" style={{ border: '1px solid rgba(249,115,22,.2)' }}>
|
||||
<div className="text-[11px] font-bold mb-2 text-color-warning">② 기름 통과(Splash-over) 조건</div>
|
||||
<div className="text-[10px] leading-[1.8] mb-2 text-fg-sub">조류 유속이 임계값을 초과하면 기름이 파도를 타고 오일펜스를 넘어가는 Splash-over가 발생합니다.</div>
|
||||
<div className="rounded-md p-2.5 text-[10px] leading-[2.1] bg-bg-base font-mono" style={{ border: '1px solid rgba(249,115,22,.2)' }}>
|
||||
Fr = U<sub>n</sub> / √(g·Δρ/ρ·h)<br />
|
||||
Splash-over: Fr > 0.5~0.6<br />
|
||||
<span className="text-[9px] text-text-3">Fr: 수정 Froude수, h: 오일펜스 수중깊이</span><br />
|
||||
<span className="text-[9px] text-text-3">Δρ/ρ: 기름-해수 밀도비 (~0.15)</span>
|
||||
<span className="text-[9px] text-fg-disabled">Fr: 수정 Froude수, h: 오일펜스 수중깊이</span><br />
|
||||
<span className="text-[9px] text-fg-disabled">Δρ/ρ: 기름-해수 밀도비 (~0.15)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Catenary 변형 모델 */}
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-xs font-bold mb-3">🔗 오일펜스 현수선(Catenary) 변형 모델</div>
|
||||
<div className="grid grid-cols-2 gap-3.5">
|
||||
<div className="text-[10px] leading-[1.8] text-text-2">
|
||||
<div className="text-[10px] leading-[1.8] text-fg-sub">
|
||||
조류와 바람에 의해 오일펜스는 현수선(Catenary) 형태로 변형됩니다. 실제 차단 길이가 설계 길이보다 짧아지며, 최적화 알고리즘에서 <b>변형 후 유효 차단 길이</b> L<sub>eff</sub>를 계산합니다.
|
||||
<div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-0 font-mono">
|
||||
<div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-base font-mono">
|
||||
y(x) = a·cosh(x/a) − a<br />
|
||||
L<sub>arc</sub> = 2a·sinh(L<sub>span</sub>/(2a))<br />
|
||||
L<sub>eff</sub> = L<sub>span</sub> · cos(φ<sub>max</sub>)<br />
|
||||
<span className="text-[9px] text-text-3">a: catenary 파라미터, φ: 최대 편향각</span>
|
||||
<span className="text-[9px] text-fg-disabled">a: catenary 파라미터, φ: 최대 편향각</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 text-[9px] text-text-2">
|
||||
<div className="text-[10px] font-bold mb-1 text-text-2">변형 단계별 유효 차단 길이 보정</div>
|
||||
<div className="flex flex-col gap-1.5 text-[9px] text-fg-sub">
|
||||
<div className="text-[10px] font-bold mb-1 text-fg-sub">변형 단계별 유효 차단 길이 보정</div>
|
||||
{[
|
||||
{ cond: 'U < 0.3 knot', result: 'L_eff ≈ L (직선 유지)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.12)' },
|
||||
{ cond: '0.3~0.7 knot', result: 'L_eff = 0.8~0.95 L (경미 변형)', bg: 'rgba(234,179,8,.05)', bd: 'rgba(234,179,8,.12)' },
|
||||
{ cond: '0.7~1.0 knot', result: 'L_eff = 0.5~0.8 L (Catenary 현저)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.12)' },
|
||||
{ cond: 'U > 1.0 knot', result: '기름 통과 위험 · 배치 재계산', bg: 'rgba(239,68,68,.05)', bd: 'rgba(239,68,68,.12)', danger: true },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="px-[9px] py-1.5 rounded-[5px]" style={{ background: item.bg, border: `1px solid ${item.bd}`, color: item.danger ? 'var(--red)' : 'var(--t2)' }}>
|
||||
<div key={i} className="px-[9px] py-1.5 rounded-[5px]" style={{ background: item.bg, border: `1px solid ${item.bd}`, color: item.danger ? 'var(--color-danger)' : 'var(--fg-sub)' }}>
|
||||
{item.cond} → {item.result}
|
||||
</div>
|
||||
))}
|
||||
@ -509,12 +509,12 @@ function FluidDynamicsPanel() {
|
||||
</div>
|
||||
|
||||
{/* 유막 포집 모델 */}
|
||||
<div className="rounded-md p-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-[11px] font-bold mb-2.5">🛢️ 오일펜스 내 유막 포집 동역학</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-[10px] text-text-2">
|
||||
<div className="grid grid-cols-2 gap-3 text-[10px] text-fg-sub">
|
||||
<div>
|
||||
<div className="font-bold mb-1.5 text-primary-cyan">포집 기름 체적 변화율</div>
|
||||
<div className="rounded-[5px] p-[9px] leading-[2] bg-bg-0 font-mono">
|
||||
<div className="font-bold mb-1.5 text-color-accent">포집 기름 체적 변화율</div>
|
||||
<div className="rounded-[5px] p-[9px] leading-[2] bg-bg-base font-mono">
|
||||
dV<sub>oil</sub>/dt = Q<sub>in</sub> − Q<sub>out</sub> − Q<sub>loss</sub><br />
|
||||
Q<sub>in</sub> = U<sub>oil</sub>·h<sub>oil</sub>·L<sub>eff</sub><br />
|
||||
Q<sub>out</sub> = Q<sub>skim</sub> (회수기 흡입량)<br />
|
||||
@ -522,8 +522,8 @@ function FluidDynamicsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold mb-1.5 text-status-orange">최적 회수 타이밍</div>
|
||||
<div className="text-[10px] leading-[1.7] text-text-2">포집 기름 체적이 오일펜스 저장 용량의 70~80%에 도달하면 Skimmer 회수 작업을 개시합니다. 이를 초과하면 오일 overflow 발생. WING이 실시간 체적 모니터링 후 회수 알람 발령.</div>
|
||||
<div className="font-bold mb-1.5 text-color-warning">최적 회수 타이밍</div>
|
||||
<div className="text-[10px] leading-[1.7] text-fg-sub">포집 기름 체적이 오일펜스 저장 용량의 70~80%에 도달하면 Skimmer 회수 작업을 개시합니다. 이를 초과하면 오일 overflow 발생. WING이 실시간 체적 모니터링 후 회수 알람 발령.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -534,25 +534,25 @@ function FluidDynamicsPanel() {
|
||||
/* ──────────── PANEL 4: 현장 적용 ──────────── */
|
||||
function FieldApplicationPanel() {
|
||||
const steps = [
|
||||
{ num: 1, color: 'var(--orange)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.15)', numBg: 'rgba(249,115,22,.15)', numBd: 'rgba(249,115,22,.3)', title: '확산예측 결과 분석 — 위협 구역 및 도달시간 산출', desc: 'KOSPS·POSEIDON·OpenDrift 앙상블 예측 결과에서 유출유 확산 경계선(Pollution Boundary)과 각 ESI 구역별 도달 예상시간(T_arrive)을 산출합니다. 신뢰도 70% 이상 예측 경계를 기준으로 차단 전략 영역을 설정합니다.' },
|
||||
{ num: 2, color: 'var(--cyan)', 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(--yellow)', bg: 'rgba(234,179,8,.05)', bd: 'rgba(234,179,8,.15)', numBg: 'rgba(234,179,8,.15)', numBd: 'rgba(234,179,8,.3)', title: '후보 차단선 격자 탐색 — 배치 가능지점 생성', desc: '확산 예측 경계를 따라 500m 간격 격자로 후보 배치지점을 생성합니다. 각 지점에서 조류 조건·수심·해안선 이격·방제정 접근 가능성을 동시 검토합니다. ESI 고등급 구역 전방 2km 이내 지점에 우선 가중치를 부여합니다.' },
|
||||
{ num: 4, color: 'var(--purple)', bg: 'rgba(168,85,247,.05)', bd: 'rgba(168,85,247,.15)', numBg: 'rgba(168,85,247,.15)', numBd: 'rgba(168,85,247,.3)', title: 'NSGA-II 최적화 실행 — 파레토 최적 배치안 산출', desc: '후보 배치지점·방향각·형태 조합을 염색체로 인코딩하여 NSGA-II 다목적 유전알고리즘을 실행합니다. 수렴 후 파레토 전면에서 3~5개 추천 배치안을 제시하며, 의사결정자가 차단 효율과 자원 사용량의 트레이드오프를 보고 선택합니다.' },
|
||||
{ num: 5, color: 'var(--green)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.15)', numBg: 'rgba(34,197,94,.15)', numBd: 'rgba(34,197,94,.3)', title: '실시간 재최적화 — 조석 변환·조류 변화 대응', desc: '창조→낙조 전환 시(약 6시간 주기) 조류 방향 역전에 따른 오일펜스 재배치 알람을 발령합니다. 확산 예측이 갱신될 때마다 배치 최적화를 자동 재실행하여 방제대응 체계를 동적으로 업데이트합니다.' },
|
||||
{ num: 1, color: 'var(--color-warning)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.15)', numBg: 'rgba(249,115,22,.15)', numBd: 'rgba(249,115,22,.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(234,179,8,.05)', bd: 'rgba(234,179,8,.15)', numBg: 'rgba(234,179,8,.15)', numBd: 'rgba(234,179,8,.3)', title: '후보 차단선 격자 탐색 — 배치 가능지점 생성', desc: '확산 예측 경계를 따라 500m 간격 격자로 후보 배치지점을 생성합니다. 각 지점에서 조류 조건·수심·해안선 이격·방제정 접근 가능성을 동시 검토합니다. ESI 고등급 구역 전방 2km 이내 지점에 우선 가중치를 부여합니다.' },
|
||||
{ num: 4, color: 'var(--color-tertiary)', bg: 'rgba(168,85,247,.05)', bd: 'rgba(168,85,247,.15)', numBg: 'rgba(168,85,247,.15)', numBd: 'rgba(168,85,247,.3)', title: 'NSGA-II 최적화 실행 — 파레토 최적 배치안 산출', desc: '후보 배치지점·방향각·형태 조합을 염색체로 인코딩하여 NSGA-II 다목적 유전알고리즘을 실행합니다. 수렴 후 파레토 전면에서 3~5개 추천 배치안을 제시하며, 의사결정자가 차단 효율과 자원 사용량의 트레이드오프를 보고 선택합니다.' },
|
||||
{ num: 5, color: 'var(--color-success)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.15)', numBg: 'rgba(34,197,94,.15)', numBd: 'rgba(34,197,94,.3)', title: '실시간 재최적화 — 조석 변환·조류 변화 대응', desc: '창조→낙조 전환 시(약 6시간 주기) 조류 방향 역전에 따른 오일펜스 재배치 알람을 발령합니다. 확산 예측이 갱신될 때마다 배치 최적화를 자동 재실행하여 방제대응 체계를 동적으로 업데이트합니다.' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 배치 5단계 절차 */}
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-4 mb-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-xs font-bold mb-3">🗺️ WING 오일펜스 배치 의사결정 5단계</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{steps.map(step => (
|
||||
<div key={step.num} className="flex gap-3 items-start p-3 rounded-lg" style={{ background: step.bg, border: `1px solid ${step.bd}` }}>
|
||||
<div className="min-w-[36px] h-[36px] rounded-[9px] flex items-center justify-center font-extrabold text-sm flex-shrink-0" style={{ background: step.numBg, border: `1px solid ${step.numBd}`, color: step.color }}>{step.num}</div>
|
||||
<div className="text-[10px] text-text-2">
|
||||
<div className="text-[10px] text-fg-sub">
|
||||
<div className="font-bold mb-1">{step.title}</div>
|
||||
<div className="leading-[1.7] text-text-2">{step.desc}</div>
|
||||
<div className="leading-[1.7] text-fg-sub">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -560,17 +560,17 @@ function FieldApplicationPanel() {
|
||||
</div>
|
||||
|
||||
{/* 해역별 적용 특성 */}
|
||||
<div className="rounded-md p-3.5 bg-bg-3 border border-border">
|
||||
<div className="rounded-md p-3.5 bg-bg-card border border-stroke">
|
||||
<div className="text-[11px] font-bold mb-2.5">📍 해역별 적용 특성 및 전략</div>
|
||||
<div className="grid grid-cols-3 gap-2.5 text-[9px] text-text-2">
|
||||
<div className="grid grid-cols-3 gap-2.5 text-[9px] text-fg-sub">
|
||||
{[
|
||||
{ icon: '🌊', title: '서해 (조차 대형)', color: 'var(--blue)', bg: 'rgba(59,130,246,.05)', bd: 'rgba(59,130,246,.12)', desc: '최대 조차 9m (인천), 조류 최대 3~5 knot. J형 배치 주력. 조석 전환 재배치 필수. 앵커링 수심 급변화 주의.' },
|
||||
{ icon: '🌿', title: '남해 (다도해)', color: 'var(--green)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.12)', desc: '복잡한 해안선·섬. 조류 1~2 knot. V형·U형 복합 배치. 좁은 수로 통제 우선. ESI 고등급 갯벌 보호.' },
|
||||
{ icon: '🏔️', title: '동해 (심해형)', color: 'var(--orange)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.12)', desc: '조차 소(0.3m), 너울·파고 높음. 조류 0.5~1 knot. V형 집중. 고파랑 시 배치 제한. 수온약층 고려.' },
|
||||
{ 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-success)', bg: 'rgba(34,197,94,.05)', bd: 'rgba(34,197,94,.12)', desc: '복잡한 해안선·섬. 조류 1~2 knot. V형·U형 복합 배치. 좁은 수로 통제 우선. ESI 고등급 갯벌 보호.' },
|
||||
{ icon: '🏔️', title: '동해 (심해형)', color: 'var(--color-warning)', bg: 'rgba(249,115,22,.05)', bd: 'rgba(249,115,22,.12)', desc: '조차 소(0.3m), 너울·파고 높음. 조류 0.5~1 knot. V형 집중. 고파랑 시 배치 제한. 수온약층 고려.' },
|
||||
].map((area, i) => (
|
||||
<div key={i} className="p-2.5 rounded-[7px]" style={{ background: area.bg, border: `1px solid ${area.bd}` }}>
|
||||
<div className="font-bold mb-[5px]" style={{ color: area.color }}>{area.icon} {area.title}</div>
|
||||
<div className="leading-[1.7] text-text-2">{area.desc}</div>
|
||||
<div className="leading-[1.7] text-fg-sub">{area.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -584,7 +584,7 @@ function ReferencesPanel() {
|
||||
const categories = [
|
||||
{
|
||||
title: '⚙️ 최적화 알고리즘',
|
||||
color: 'var(--purple)',
|
||||
color: 'var(--color-tertiary)',
|
||||
bg: 'rgba(168,85,247,.1)',
|
||||
bd: 'rgba(168,85,247,.25)',
|
||||
refs: [
|
||||
@ -595,7 +595,7 @@ function ReferencesPanel() {
|
||||
},
|
||||
{
|
||||
title: '🌊 유체역학 이론',
|
||||
color: 'var(--blue)',
|
||||
color: 'var(--color-info)',
|
||||
bg: 'rgba(59,130,246,.1)',
|
||||
bd: 'rgba(59,130,246,.25)',
|
||||
refs: [
|
||||
@ -607,7 +607,7 @@ function ReferencesPanel() {
|
||||
},
|
||||
{
|
||||
title: '📐 오일펜스 배치 설계',
|
||||
color: 'var(--cyan)',
|
||||
color: 'var(--color-accent)',
|
||||
bg: 'rgba(6,182,212,.1)',
|
||||
bd: 'rgba(6,182,212,.25)',
|
||||
refs: [
|
||||
@ -616,7 +616,7 @@ function ReferencesPanel() {
|
||||
},
|
||||
{
|
||||
title: '🗺️ 방제 운용 기준',
|
||||
color: 'var(--green)',
|
||||
color: 'var(--color-success)',
|
||||
bg: 'rgba(34,197,94,.1)',
|
||||
bd: 'rgba(34,197,94,.25)',
|
||||
refs: [
|
||||
@ -630,19 +630,19 @@ function ReferencesPanel() {
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs font-bold mb-1">📚 오일펜스 배치 최적화 이론 근거 문헌</div>
|
||||
<div className="text-[10px] mb-3.5 text-text-3">총 12편 · 4개 카테고리</div>
|
||||
<div className="text-[10px] mb-3.5 text-fg-disabled">총 12편 · 4개 카테고리</div>
|
||||
|
||||
{categories.map((cat, ci) => (
|
||||
<div key={ci} className="mb-4">
|
||||
<div className="text-[10px] font-bold mb-[7px] flex items-center gap-1.5" style={{ color: cat.color }}>
|
||||
<span className="px-[7px] py-0.5 rounded" style={{ background: cat.bg, border: `1px solid ${cat.bd}` }}>{cat.title}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[5px] text-[9px] text-text-2">
|
||||
<div className="flex flex-col gap-[5px] text-[9px] text-fg-sub">
|
||||
{cat.refs.map((ref, ri) => (
|
||||
<div key={ri} className="p-[9px] px-3 rounded-[7px] grid gap-2" style={{
|
||||
gridTemplateColumns: '24px 1fr',
|
||||
background: 'var(--bg3)',
|
||||
border: ref.highlight ? `1px solid ${cat.bd}` : '1px solid var(--bd)',
|
||||
background: 'var(--bg-card)',
|
||||
border: ref.highlight ? `1px solid ${cat.bd}` : '1px solid var(--stroke-default)',
|
||||
borderLeft: ref.highlight ? `3px solid ${cat.color}` : undefined,
|
||||
}}>
|
||||
<div className="w-[22px] h-[22px] rounded flex items-center justify-center text-[10px] flex-shrink-0" style={{
|
||||
@ -654,8 +654,8 @@ function ReferencesPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold mb-0.5">{ref.title}</div>
|
||||
<div className="leading-[1.6] text-text-3">{ref.author}</div>
|
||||
<div className="mt-0.5 text-text-2">{ref.desc}</div>
|
||||
<div className="leading-[1.6] text-fg-disabled">{ref.author}</div>
|
||||
<div className="mt-0.5 text-fg-sub">{ref.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -33,15 +33,15 @@ const InfoLayerSection = ({
|
||||
const effectiveLayers: Layer[] = layerTree ?? []
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="border-b border-stroke">
|
||||
<div
|
||||
className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]"
|
||||
>
|
||||
<h3
|
||||
onClick={onToggle}
|
||||
className="text-[13px] font-bold text-text-1 font-korean cursor-pointer"
|
||||
className="text-[13px] font-bold text-fg font-korean cursor-pointer"
|
||||
>
|
||||
📂 정보 레이어
|
||||
정보 레이어
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
@ -65,15 +65,15 @@ const InfoLayerSection = ({
|
||||
padding: '4px 8px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
border: '1px solid var(--cyan)',
|
||||
borderRadius: 'var(--rS)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'transparent',
|
||||
color: 'var(--cyan)',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
transition: '0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(6,182,212,0.1)'
|
||||
e.currentTarget.style.background = 'var(--bg-surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
@ -102,15 +102,15 @@ const InfoLayerSection = ({
|
||||
padding: '4px 8px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
border: '1px solid var(--red)',
|
||||
borderRadius: 'var(--rS)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'transparent',
|
||||
color: 'var(--red)',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
transition: '0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239,68,68,0.1)'
|
||||
e.currentTarget.style.background = 'var(--bg-surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
@ -120,7 +120,7 @@ const InfoLayerSection = ({
|
||||
</button>
|
||||
<span
|
||||
onClick={onToggle}
|
||||
className="text-[10px] text-text-3 cursor-pointer"
|
||||
className="text-[10px] text-fg-disabled cursor-pointer"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
@ -130,9 +130,9 @@ const InfoLayerSection = ({
|
||||
{expanded && (
|
||||
<div className="px-4 pb-2">
|
||||
{isLoading && effectiveLayers.length === 0 ? (
|
||||
<p className="text-[11px] text-text-3 py-2">레이어 로딩 중...</p>
|
||||
<p className="text-[11px] text-fg-disabled py-2">레이어 로딩 중...</p>
|
||||
) : effectiveLayers.length === 0 ? (
|
||||
<p className="text-[11px] text-text-3 py-2">레이어 데이터가 없습니다.</p>
|
||||
<p className="text-[11px] text-fg-disabled py-2">레이어 데이터가 없습니다.</p>
|
||||
) : (
|
||||
<LayerTree
|
||||
layers={effectiveLayers}
|
||||
|
||||
@ -110,6 +110,7 @@ export function LeftPanel({
|
||||
onLayerBrightnessChange,
|
||||
sensitiveResources = [],
|
||||
onImageAnalysisResult,
|
||||
validationErrors,
|
||||
}: LeftPanelProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
||||
predictionInput: true,
|
||||
@ -127,9 +128,9 @@ export function LeftPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-80 min-w-[320px] bg-bg-1 border-r border-border flex flex-col">
|
||||
<div className="w-80 min-w-[320px] bg-bg-surface border-r border-stroke flex flex-col">
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent">
|
||||
<div className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent" style={{ scrollbarGutter: 'stable' }}>
|
||||
{/* Prediction Input Section */}
|
||||
<PredictionInputSection
|
||||
expanded={expandedSections.predictionInput}
|
||||
@ -160,18 +161,19 @@ export function LeftPanel({
|
||||
spillUnit={spillUnit}
|
||||
onSpillUnitChange={onSpillUnitChange}
|
||||
onImageAnalysisResult={onImageAnalysisResult}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
|
||||
{/* Incident Section */}
|
||||
<div className="border-b border-border">
|
||||
<div className="border-b border-stroke">
|
||||
<div
|
||||
onClick={() => toggleSection('incident')}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||
>
|
||||
<h3 className="text-[13px] font-bold text-text-2 font-korean">
|
||||
<h3 className="text-[13px] font-bold text-fg-sub font-korean">
|
||||
사고정보
|
||||
</h3>
|
||||
<span className="text-[10px] text-text-3">
|
||||
<span className="text-[10px] text-fg-disabled">
|
||||
{expandedSections.incident ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
@ -184,18 +186,18 @@ export function LeftPanel({
|
||||
const statusMap: Record<string, { label: string; style: string; dot: string }> = {
|
||||
ACTIVE: {
|
||||
label: '진행중',
|
||||
style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]',
|
||||
dot: 'bg-status-red animate-pulse',
|
||||
style: 'bg-[rgba(239,68,68,0.15)] text-color-danger border border-[rgba(239,68,68,0.3)]',
|
||||
dot: 'bg-color-danger animate-pulse',
|
||||
},
|
||||
INVESTIGATING: {
|
||||
label: '조사중',
|
||||
style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]',
|
||||
dot: 'bg-status-orange animate-pulse',
|
||||
style: 'bg-[rgba(249,115,22,0.15)] text-color-warning border border-[rgba(249,115,22,0.3)]',
|
||||
dot: 'bg-color-warning animate-pulse',
|
||||
},
|
||||
CLOSED: {
|
||||
label: '종료',
|
||||
style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]',
|
||||
dot: 'bg-text-3',
|
||||
style: 'bg-[rgba(100,116,139,0.15)] text-fg-disabled border border-[rgba(100,116,139,0.3)]',
|
||||
dot: 'bg-fg-disabled',
|
||||
},
|
||||
}
|
||||
const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE']
|
||||
@ -210,53 +212,53 @@ export function LeftPanel({
|
||||
{/* Info Grid */}
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고코드</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.acdntSn}</span>
|
||||
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean">사고코드</span>
|
||||
<span className="text-[11px] text-fg font-medium font-mono">{selectedAnalysis.acdntSn}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고명</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
|
||||
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean">사고명</span>
|
||||
<span className="text-[11px] text-fg font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16).replace(' ', 'T') : '—'}</span>
|
||||
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean">사고일시</span>
|
||||
<span className="text-[11px] text-fg font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16).replace(' ', 'T') : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
|
||||
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean">유종</span>
|
||||
<span className="text-[11px] text-fg font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유출량</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'}</span>
|
||||
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean">유출량</span>
|
||||
<span className="text-[11px] text-fg font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">담당자</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
|
||||
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean">담당자</span>
|
||||
<span className="text-[11px] text-fg font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">위치</span>
|
||||
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
|
||||
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean">위치</span>
|
||||
<span className="text-[11px] text-color-warning font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-[11px] text-text-3 font-korean text-center py-2">선택된 사고정보가 없습니다.</p>
|
||||
<p className="text-[12px] text-fg-disabled font-korean text-center py-2">선택된 사고정보가 없습니다.</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Impact Resources Section */}
|
||||
<div className="border-b border-border">
|
||||
<div className="border-b border-stroke">
|
||||
<div
|
||||
onClick={() => toggleSection('impactResources')}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||
>
|
||||
<h3 className="text-[13px] font-bold text-text-2 font-korean">
|
||||
<h3 className="text-[13px] font-bold text-fg-sub font-korean">
|
||||
영향 민감자원
|
||||
</h3>
|
||||
<span className="text-[10px] text-text-3">
|
||||
<span className="text-[10px] text-fg-disabled">
|
||||
{expandedSections.impactResources ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
@ -264,7 +266,7 @@ export function LeftPanel({
|
||||
{expandedSections.impactResources && (
|
||||
<div className="px-4 pb-4">
|
||||
{sensitiveResources.length === 0 ? (
|
||||
<p className="text-[11px] text-text-3 font-korean">영향받는 민감자원 목록</p>
|
||||
<p className="text-[12px] text-fg-disabled text-center font-korean">영향받는 민감자원 목록</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sensitiveResources.map(({ category, count, totalArea }) => {
|
||||
@ -278,7 +280,7 @@ export function LeftPanel({
|
||||
>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<span className="text-[11px] text-text-2 font-korean">{category}</span>
|
||||
<span className="text-[11px] text-fg-sub font-korean">{category}</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-primary font-bold font-mono">
|
||||
{totalArea != null
|
||||
|
||||
@ -71,15 +71,15 @@ const OilBoomSection = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="border-b border-stroke">
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||
>
|
||||
<h3 className="text-[13px] font-bold text-text-2 font-korean">
|
||||
🛡 오일펜스 배치 가이드
|
||||
<h3 className="text-[13px] font-bold text-fg-sub font-korean">
|
||||
오일펜스 배치 가이드
|
||||
</h3>
|
||||
<span className="text-[10px] text-text-3">
|
||||
<span className="text-[10px] text-fg-disabled">
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
@ -104,10 +104,10 @@ const OilBoomSection = ({
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
borderRadius: 'var(--rS)',
|
||||
border: boomPlacementTab === tab.id ? '1px solid var(--orange)' : '1px solid var(--bd)',
|
||||
background: boomPlacementTab === tab.id ? 'rgba(245,158,11,0.1)' : 'var(--bg0)',
|
||||
color: boomPlacementTab === tab.id ? 'var(--orange)' : 'var(--t3)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: boomPlacementTab === tab.id ? '1px solid rgba(6,182,212,0.4)' : '1px solid var(--stroke-default)',
|
||||
background: boomPlacementTab === tab.id ? 'rgba(6,182,212,0.08)' : 'var(--bg-base)',
|
||||
color: boomPlacementTab === tab.id ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
className="flex-1 text-[10px] font-semibold cursor-pointer"
|
||||
@ -120,10 +120,10 @@ const OilBoomSection = ({
|
||||
disabled={!hasData}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--rS)',
|
||||
border: '1px solid var(--bd)',
|
||||
background: 'var(--bg0)',
|
||||
color: hasData ? 'var(--red)' : 'var(--t3)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
background: 'var(--bg-base)',
|
||||
color: hasData ? 'var(--color-danger)' : 'var(--fg-disabled)',
|
||||
cursor: hasData ? 'pointer' : 'not-allowed',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
@ -139,12 +139,12 @@ const OilBoomSection = ({
|
||||
padding: '14px',
|
||||
background: 'rgba(239,68,68,0.06)',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: 'var(--rM)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}>
|
||||
<div className="text-[11px] font-bold text-text-1 font-korean mb-2">
|
||||
<div className="text-[11px] font-bold text-fg font-korean mb-2">
|
||||
⚠ 오일펜스 배치 가이드를 초기화 합니다
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-korean mb-3">
|
||||
<div className="text-[9px] text-fg-disabled font-korean mb-3">
|
||||
배치된 오일펜스 라인과 시뮬레이션 결과가 삭제됩니다. 확산 예측 결과는 유지됩니다.
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@ -153,9 +153,9 @@ const OilBoomSection = ({
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: 'rgba(239,68,68,0.15)',
|
||||
border: '1px solid var(--red)',
|
||||
borderRadius: 'var(--rS)',
|
||||
color: 'var(--red)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--color-danger)',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
className="flex-1 text-[10px] font-bold cursor-pointer"
|
||||
@ -166,10 +166,10 @@ const OilBoomSection = ({
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: 'var(--bg0)',
|
||||
border: '1px solid var(--bd)',
|
||||
borderRadius: 'var(--rS)',
|
||||
color: 'var(--t2)',
|
||||
background: 'var(--bg-base)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--fg-sub)',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
className="flex-1 text-[10px] font-bold cursor-pointer"
|
||||
@ -183,20 +183,20 @@ const OilBoomSection = ({
|
||||
{/* Key Metrics */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
|
||||
{[
|
||||
{ value: String(boomLines.length), label: '배치 라인', color: 'var(--orange)' },
|
||||
{ value: boomLines.length > 0 ? `${(boomLines.reduce((s, l) => s + l.length, 0) / 1000).toFixed(1)}km` : '0km', label: '총 길이', color: 'var(--cyan)' },
|
||||
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--orange)' },
|
||||
{ value: String(boomLines.length), label: '배치 라인', color: 'var(--color-accent)' },
|
||||
{ value: boomLines.length > 0 ? `${(boomLines.reduce((s, l) => s + l.length, 0) / 1000).toFixed(1)}km` : '0km', label: '총 길이', color: 'var(--color-accent)' },
|
||||
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--color-accent)' },
|
||||
].map((metric, idx) => (
|
||||
<div key={idx} style={{
|
||||
padding: '10px 8px',
|
||||
background: 'var(--bg0)',
|
||||
borderRadius: 'var(--rS)',
|
||||
background: 'var(--bg-base)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textAlign: 'center',
|
||||
}} className="border border-border">
|
||||
}} className="border border-stroke">
|
||||
<div style={{ color: metric.color }} className="text-lg font-bold font-mono mb-[2px]">
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="text-[8px] text-text-3">
|
||||
<div className="text-[8px] text-fg-disabled">
|
||||
{metric.label}
|
||||
</div>
|
||||
</div>
|
||||
@ -208,10 +208,10 @@ const OilBoomSection = ({
|
||||
<>
|
||||
{/* 전제조건 체크 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div style={{ background: 'var(--bg0)', borderRadius: 'var(--rS)' }}
|
||||
className="flex items-center gap-1.5 p-[6px_10px] border border-border text-[10px]">
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--red)' }} />
|
||||
<span style={{ color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
|
||||
<div style={{ background: 'var(--bg-base)', borderRadius: 'var(--radius-sm)' }}
|
||||
className="flex items-center gap-1.5 p-[6px_10px] border border-stroke text-[10px]">
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--color-danger)' }} />
|
||||
<span style={{ color: oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--fg-disabled)' }}>
|
||||
확산 궤적 데이터 {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
|
||||
</span>
|
||||
</div>
|
||||
@ -219,7 +219,7 @@ const OilBoomSection = ({
|
||||
|
||||
{/* 알고리즘 설정 */}
|
||||
<div>
|
||||
<h4 className="text-[11px] font-bold text-primary-cyan mb-2" style={{ letterSpacing: '0.5px' }}>
|
||||
<h4 className="text-[11px] font-bold text-fg-sub mb-2" style={{ letterSpacing: '0.5px' }}>
|
||||
📊 V자형 배치 알고리즘 설정
|
||||
</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
@ -230,11 +230,11 @@ const OilBoomSection = ({
|
||||
{ label: '파고 보정 계수', key: 'waveHeightCorrectionFactor' as const, unit: 'x', value: algorithmSettings.waveHeightCorrectionFactor },
|
||||
].map((setting) => (
|
||||
<div key={setting.key} style={{
|
||||
background: 'var(--bg0)',
|
||||
borderRadius: 'var(--rS)',
|
||||
}} className="flex items-center justify-between p-[6px_8px] border border-border">
|
||||
<span className="text-[9px] text-text-3">● {setting.label}</span>
|
||||
<div className="flex items-center gap-[2px]">
|
||||
background: 'var(--bg-base)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}} className="flex items-center justify-between px-2.5 py-1.5 border border-stroke">
|
||||
<span className="flex-1 text-[9px] text-fg-disabled truncate">● {setting.label}</span>
|
||||
<div className="flex items-center gap-1 shrink-0 w-[80px] justify-end">
|
||||
<input
|
||||
type="number"
|
||||
value={setting.value}
|
||||
@ -245,7 +245,7 @@ const OilBoomSection = ({
|
||||
className="boom-setting-input"
|
||||
step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1}
|
||||
/>
|
||||
<span className="text-[9px] text-status-orange">{setting.unit}</span>
|
||||
<span className="text-[9px] text-fg-disabled w-[14px]">{setting.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -257,19 +257,20 @@ const OilBoomSection = ({
|
||||
onClick={handleRunSimulation}
|
||||
disabled={oilTrajectory.length === 0}
|
||||
style={{
|
||||
background: oilTrajectory.length > 0 ? 'rgba(6,182,212,0.15)' : 'var(--bg0)',
|
||||
border: oilTrajectory.length > 0 ? '2px solid var(--cyan)' : '1px solid var(--bd)',
|
||||
borderRadius: 'var(--rS)',
|
||||
color: oilTrajectory.length > 0 ? 'var(--cyan)' : 'var(--t3)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-accent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: oilTrajectory.length > 0 ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
|
||||
opacity: oilTrajectory.length > 0 ? 1 : 0.4,
|
||||
transition: '0.15s',
|
||||
}}
|
||||
className="w-full p-[10px] text-[11px] font-bold"
|
||||
>
|
||||
🛡 V자형 오일펜스 배치 + 시뮬레이션 실행
|
||||
V자형 오일펜스 배치 + 시뮬레이션 실행
|
||||
</button>
|
||||
|
||||
<p className="text-[9px] text-text-3 leading-relaxed font-korean">
|
||||
<p className="text-[9px] text-fg-disabled leading-relaxed font-korean">
|
||||
확산 궤적을 분석하여 해류 직교 방향 1차 방어선(V형), U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 배치하고 차단 시뮬레이션을 실행합니다.
|
||||
</p>
|
||||
|
||||
@ -279,23 +280,23 @@ const OilBoomSection = ({
|
||||
{/* 전체 효율 */}
|
||||
<div style={{
|
||||
padding: '16px', background: 'rgba(6,182,212,0.05)',
|
||||
border: '1px solid rgba(6,182,212,0.3)', borderRadius: 'var(--rM)', textAlign: 'center',
|
||||
border: '1px solid rgba(6,182,212,0.3)', borderRadius: 'var(--radius-md)', textAlign: 'center',
|
||||
}}>
|
||||
<div className="text-[28px] font-bold text-primary-cyan font-mono">
|
||||
<div className="text-[28px] font-bold text-color-accent font-mono">
|
||||
{containmentResult.overallEfficiency}%
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3 mt-[2px]">전체 차단 효율</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-[2px]">전체 차단 효율</div>
|
||||
</div>
|
||||
|
||||
{/* 차단/통과 카운트 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
|
||||
<div style={{ padding: '10px', background: 'var(--bg0)', borderRadius: 'var(--rS)', textAlign: 'center' }} className="border border-border">
|
||||
<div className="text-base font-bold text-status-green font-mono">{containmentResult.blockedParticles}</div>
|
||||
<div className="text-[8px] text-text-3">차단 입자</div>
|
||||
<div style={{ padding: '10px', background: 'var(--bg-base)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }} className="border border-stroke">
|
||||
<div className="text-base font-bold text-color-success font-mono">{containmentResult.blockedParticles}</div>
|
||||
<div className="text-[8px] text-fg-disabled">차단 입자</div>
|
||||
</div>
|
||||
<div style={{ padding: '10px', background: 'var(--bg0)', borderRadius: 'var(--rS)', textAlign: 'center' }} className="border border-border">
|
||||
<div className="text-base font-bold text-status-red font-mono">{containmentResult.passedParticles}</div>
|
||||
<div className="text-[8px] text-text-3">통과 입자</div>
|
||||
<div style={{ padding: '10px', background: 'var(--bg-base)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }} className="border border-stroke">
|
||||
<div className="text-base font-bold text-color-danger font-mono">{containmentResult.passedParticles}</div>
|
||||
<div className="text-[8px] text-fg-disabled">통과 입자</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -303,18 +304,18 @@ const OilBoomSection = ({
|
||||
<div className="boom-eff-bar">
|
||||
<div className="boom-eff-fill" style={{
|
||||
width: `${containmentResult.overallEfficiency}%`,
|
||||
background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)',
|
||||
background: containmentResult.overallEfficiency >= 80 ? 'var(--color-success)' : containmentResult.overallEfficiency >= 50 ? 'var(--color-warning)' : 'var(--color-danger)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 라인별 분석 */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-text-3 mb-1.5">라인별 차단 분석</h4>
|
||||
<h4 className="text-[10px] font-bold text-fg-sub mb-1.5">라인별 차단 분석</h4>
|
||||
{containmentResult.perLineResults.map((r) => (
|
||||
<div key={r.boomLineId} style={{ background: 'var(--bg0)', borderRadius: 'var(--rS)' }}
|
||||
className="flex items-center justify-between p-[6px_8px] mb-1 border border-border text-[9px]">
|
||||
<span className="text-text-2 flex-1">{r.boomLineName}</span>
|
||||
<span style={{ color: r.efficiency >= 50 ? 'var(--green)' : 'var(--orange)', marginLeft: '8px' }} className="font-bold font-mono">
|
||||
<div key={r.boomLineId} style={{ background: 'var(--bg-base)', borderRadius: 'var(--radius-sm)' }}
|
||||
className="flex items-center justify-between p-[6px_8px] mb-1 border border-stroke text-[9px]">
|
||||
<span className="text-fg-sub flex-1">{r.boomLineName}</span>
|
||||
<span style={{ color: r.efficiency >= 50 ? 'var(--color-success)' : 'var(--color-warning)', marginLeft: '8px' }} className="font-bold font-mono">
|
||||
{r.blocked}차단 / {r.efficiency}%
|
||||
</span>
|
||||
</div>
|
||||
@ -323,15 +324,15 @@ const OilBoomSection = ({
|
||||
|
||||
{/* 배치된 방어선 카드 */}
|
||||
{boomLines.map((line, idx) => {
|
||||
const priorityColor = line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'
|
||||
const priorityColor = line.priority === 'CRITICAL' ? 'var(--color-danger)' : line.priority === 'HIGH' ? 'var(--color-warning)' : 'var(--color-caution)'
|
||||
const priorityLabel = line.priority === 'CRITICAL' ? '긴급' : line.priority === 'HIGH' ? '중요' : '보통'
|
||||
return (
|
||||
<div key={line.id} style={{
|
||||
padding: '10px', background: 'var(--bg0)',
|
||||
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--rS)',
|
||||
}} className="border border-border">
|
||||
padding: '10px', background: 'var(--bg-base)',
|
||||
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--radius-sm)',
|
||||
}} className="border border-stroke">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold">
|
||||
<span className="text-[11px] font-bold text-fg">
|
||||
🛡 {idx + 1}차 방어선 ({line.type})
|
||||
</span>
|
||||
<span style={{
|
||||
@ -344,17 +345,17 @@ const OilBoomSection = ({
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} className="mb-1.5">
|
||||
<div>
|
||||
<span className="text-[8px] text-text-3">길이</span>
|
||||
<div className="text-sm font-bold font-mono">{line.length.toFixed(0)}m</div>
|
||||
<span className="text-[8px] text-fg-disabled">길이</span>
|
||||
<div className="text-sm font-bold font-mono text-fg">{line.length.toFixed(0)}m</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[8px] text-text-3">각도</span>
|
||||
<div className="text-sm font-bold font-mono">{line.angle.toFixed(0)}°</div>
|
||||
<span className="text-[8px] text-fg-disabled">각도</span>
|
||||
<div className="text-sm font-bold font-mono text-fg">{line.angle.toFixed(0)}°</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} />
|
||||
<span style={{ color: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} className="text-[9px] font-semibold">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: line.efficiency >= 80 ? 'var(--color-success)' : 'var(--color-warning)' }} />
|
||||
<span style={{ color: line.efficiency >= 80 ? 'var(--color-success)' : 'var(--color-warning)' }} className="text-[9px] font-semibold">
|
||||
차단 효율 {line.efficiency}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -127,6 +127,7 @@ export function OilSpillView() {
|
||||
const [simulationProgress, setSimulationProgress] = useState(0)
|
||||
const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [simulationError, setSimulationError] = useState<string | null>(null)
|
||||
const [validationErrors, setValidationErrors] = useState<Set<string>>(new Set())
|
||||
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
|
||||
const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
|
||||
const [predictionTime, setPredictionTime] = useState(48)
|
||||
@ -684,14 +685,30 @@ export function OilSpillView() {
|
||||
: (selectedAnalysis?.acdntSn ?? analysisDetail?.acdnt?.acdntSn);
|
||||
|
||||
const effectiveCoord = overrides?.incidentCoord ?? incidentCoord;
|
||||
if (!isDirectInput && !existingAcdntSn) return;
|
||||
if (!effectiveCoord) return;
|
||||
const effectiveModels = overrides?.models ?? selectedModels;
|
||||
|
||||
// ── 입력 유효성 검증 (border 하이라이트) ──
|
||||
const errors = new Set<string>();
|
||||
if (isDirectInput) {
|
||||
if (!incidentName.trim()) errors.add('incidentName');
|
||||
if (!accidentTime) errors.add('accidentTime');
|
||||
if (!effectiveCoord || (effectiveCoord.lat === 0 && effectiveCoord.lon === 0)) errors.add('coord');
|
||||
} else if (!existingAcdntSn) {
|
||||
errors.add('incidentName');
|
||||
}
|
||||
if (!effectiveCoord) errors.add('coord');
|
||||
if (effectiveModels.size === 0) errors.add('models');
|
||||
if (errors.size > 0) {
|
||||
setValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
setValidationErrors(new Set());
|
||||
const coord = effectiveCoord!; // 검증 통과 후 non-null 보장
|
||||
|
||||
const effectiveOilType = overrides?.oilType ?? oilType;
|
||||
const effectiveSpillAmount = overrides?.spillAmount ?? spillAmount;
|
||||
const effectiveSpillType = overrides?.spillType ?? spillType;
|
||||
const effectivePredictionTime = overrides?.predictionTime ?? predictionTime;
|
||||
const effectiveModels = overrides?.models ?? selectedModels;
|
||||
|
||||
setIsRunningSimulation(true);
|
||||
setSimulationSummary(null);
|
||||
@ -700,8 +717,8 @@ export function OilSpillView() {
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
acdntSn: existingAcdntSn,
|
||||
lat: effectiveCoord.lat,
|
||||
lon: effectiveCoord.lon,
|
||||
lat: coord.lat,
|
||||
lon: coord.lon,
|
||||
runTime: effectivePredictionTime,
|
||||
matTy: effectiveOilType,
|
||||
matVol: effectiveSpillAmount,
|
||||
@ -735,8 +752,8 @@ export function OilSpillView() {
|
||||
oilType,
|
||||
volume: spillAmount,
|
||||
location: '',
|
||||
lat: effectiveCoord.lat,
|
||||
lon: effectiveCoord.lon,
|
||||
lat: coord.lat,
|
||||
lon: coord.lon,
|
||||
kospsStatus: 'pending',
|
||||
poseidonStatus: 'pending',
|
||||
opendriftStatus: 'pending',
|
||||
@ -799,12 +816,12 @@ export function OilSpillView() {
|
||||
setHydrDataByModel(newHydrDataByModel);
|
||||
setSummaryByModel(newSummaryByModel);
|
||||
setStepSummariesByModel(newStepSummariesByModel);
|
||||
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
||||
const booms = generateAIBoomLines(merged, coord, algorithmSettings);
|
||||
setBoomLines(booms);
|
||||
setSensitiveResources([]);
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(true);
|
||||
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
|
||||
setFlyToCoord({ lon: coord.lon, lat: coord.lat });
|
||||
}
|
||||
|
||||
if (errors.length > 0 && merged.length === 0) {
|
||||
@ -812,8 +829,8 @@ export function OilSpillView() {
|
||||
} else {
|
||||
simulationSucceeded = true;
|
||||
const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn;
|
||||
if (effectiveCoord) {
|
||||
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
|
||||
if (coord) {
|
||||
fetchWeatherSnapshotForCoord(coord.lat, coord.lon)
|
||||
.then(snapshot => {
|
||||
useWeatherSnapshotStore.getState().setSnapshot(snapshot);
|
||||
if (effectiveAcdntSn) {
|
||||
@ -966,15 +983,15 @@ export function OilSpillView() {
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={handleToggleLayer}
|
||||
accidentTime={accidentTime}
|
||||
onAccidentTimeChange={setAccidentTime}
|
||||
onAccidentTimeChange={(v) => { setAccidentTime(v); setValidationErrors(prev => { const n = new Set(prev); n.delete('accidentTime'); return n; }); }}
|
||||
incidentCoord={incidentCoord}
|
||||
onCoordChange={setIncidentCoord}
|
||||
onCoordChange={(v) => { setIncidentCoord(v); setValidationErrors(prev => { const n = new Set(prev); n.delete('coord'); return n; }); }}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapSelectClick={() => setIsSelectingLocation(prev => !prev)}
|
||||
onRunSimulation={handleRunSimulation}
|
||||
isRunningSimulation={isRunningSimulation}
|
||||
selectedModels={selectedModels}
|
||||
onModelsChange={setSelectedModels}
|
||||
onModelsChange={(v) => { setSelectedModels(v); setValidationErrors(prev => { const n = new Set(prev); n.delete('models'); return n; }); }}
|
||||
visibleModels={visibleModels}
|
||||
onVisibleModelsChange={setVisibleModels}
|
||||
hasResults={oilTrajectory.length > 0}
|
||||
@ -987,7 +1004,7 @@ export function OilSpillView() {
|
||||
spillAmount={spillAmount}
|
||||
onSpillAmountChange={setSpillAmount}
|
||||
incidentName={incidentName}
|
||||
onIncidentNameChange={setIncidentName}
|
||||
onIncidentNameChange={(v) => { setIncidentName(v); setValidationErrors(prev => { const n = new Set(prev); n.delete('incidentName'); return n; }); }}
|
||||
spillUnit={spillUnit}
|
||||
onSpillUnitChange={setSpillUnit}
|
||||
boomLines={boomLines}
|
||||
@ -1007,6 +1024,7 @@ export function OilSpillView() {
|
||||
onLayerBrightnessChange={setLayerBrightness}
|
||||
sensitiveResources={sensitiveResourceCategories}
|
||||
onImageAnalysisResult={handleImageAnalysisResult}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1076,7 +1094,7 @@ export function OilSpillView() {
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[72px] flex items-center px-5 gap-4" style={{
|
||||
background: 'rgba(15,21,36,0.95)', backdropFilter: 'blur(16px)',
|
||||
borderTop: '1px solid var(--bd)',
|
||||
borderTop: '1px solid var(--stroke-default)',
|
||||
zIndex: 1100
|
||||
}}>
|
||||
{/* 컨트롤 버튼 */}
|
||||
@ -1087,7 +1105,7 @@ export function OilSpillView() {
|
||||
].map((btn, i) => (
|
||||
<button key={i} onClick={btn.action} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
border: '1px solid var(--stroke-default)', background: 'var(--bg-card)', color: 'var(--fg-sub)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
|
||||
}}>{btn.icon}</button>
|
||||
@ -1097,9 +1115,9 @@ export function OilSpillView() {
|
||||
setIsPlaying(p => !p);
|
||||
}} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: isPlaying ? '1px solid var(--cyan)' : '1px solid var(--bd)',
|
||||
background: isPlaying ? 'var(--cyan)' : 'var(--bg3)',
|
||||
color: isPlaying ? 'var(--bg0)' : 'var(--t2)',
|
||||
border: isPlaying ? '1px solid var(--color-accent)' : '1px solid var(--stroke-default)',
|
||||
background: isPlaying ? 'var(--color-accent)' : 'var(--bg-card)',
|
||||
color: isPlaying ? 'var(--bg-base)' : 'var(--fg-sub)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
|
||||
}}>{isPlaying ? '⏸' : '▶'}</button>
|
||||
@ -1109,7 +1127,7 @@ export function OilSpillView() {
|
||||
].map((btn, i) => (
|
||||
<button key={i} onClick={btn.action} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
border: '1px solid var(--stroke-default)', background: 'var(--bg-card)', color: 'var(--fg-sub)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '12px', transition: '0.2s'
|
||||
}}>{btn.icon}</button>
|
||||
@ -1117,9 +1135,9 @@ export function OilSpillView() {
|
||||
<div className="w-2" />
|
||||
<button onClick={() => setPlaySpeed(playSpeed >= 4 ? 1 : playSpeed * 2)} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
border: '1px solid var(--stroke-default)', background: 'var(--bg-card)', color: 'var(--fg-sub)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fM)', transition: '0.2s'
|
||||
cursor: 'pointer', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--font-mono)', transition: '0.2s'
|
||||
}}>{playSpeed}×</button>
|
||||
</div>
|
||||
|
||||
@ -1133,8 +1151,8 @@ export function OilSpillView() {
|
||||
return (
|
||||
<span key={t} style={{
|
||||
position: 'absolute', left: `${pos}%`, transform: 'translateX(-50%)',
|
||||
fontSize: '10px', fontFamily: 'var(--fM)',
|
||||
color: isActive ? 'var(--cyan)' : 'var(--t3)',
|
||||
fontSize: '10px', fontFamily: 'var(--font-mono)',
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
fontWeight: isActive ? 600 : 400, cursor: 'pointer', whiteSpace: 'nowrap'
|
||||
}} onClick={() => setCurrentStep(t)}>{t}h</span>
|
||||
)
|
||||
@ -1144,7 +1162,7 @@ export function OilSpillView() {
|
||||
{/* 슬라이더 트랙 */}
|
||||
<div className="relative h-6 flex items-center">
|
||||
<div
|
||||
style={{ width: '100%', height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative', cursor: 'pointer' }}
|
||||
style={{ width: '100%', height: '4px', background: 'var(--stroke-default)', borderRadius: '2px', position: 'relative', cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
if (timeSteps.length === 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@ -1160,7 +1178,7 @@ export function OilSpillView() {
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
width: `${progressPct}%`, height: '100%',
|
||||
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
|
||||
background: 'linear-gradient(90deg, var(--color-accent), var(--color-info))',
|
||||
borderRadius: '2px', transition: 'width 0.15s'
|
||||
}} />
|
||||
{/* 스텝 마커 (각 타임스텝 위치에 틱 표시) */}
|
||||
@ -1169,7 +1187,7 @@ export function OilSpillView() {
|
||||
return (
|
||||
<div key={`tick-${t}`} style={{
|
||||
position: 'absolute', width: '2px', height: '10px',
|
||||
background: t <= currentStep ? 'var(--cyan)' : 'var(--t3)',
|
||||
background: t <= currentStep ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
top: '-3px', left: `${pos}%`, opacity: 0.6
|
||||
}} />
|
||||
);
|
||||
@ -1192,7 +1210,7 @@ export function OilSpillView() {
|
||||
position: 'absolute', left: `${progressPct}%`, top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '16px', height: '16px',
|
||||
background: 'var(--cyan)', border: '3px solid var(--bg0)',
|
||||
background: 'var(--color-accent)', border: '3px solid var(--bg-base)',
|
||||
borderRadius: '50%', cursor: 'grab',
|
||||
boxShadow: '0 0 10px rgba(6,182,212,0.4)', zIndex: 2,
|
||||
transition: 'left 0.15s'
|
||||
@ -1202,7 +1220,7 @@ export function OilSpillView() {
|
||||
|
||||
{/* 시간 정보 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px', flexShrink: 0, minWidth: '200px' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-accent)', fontFamily: 'var(--font-mono)' }}>
|
||||
+{currentStep}h — {(() => {
|
||||
const base = accidentTime ? new Date(accidentTime) : new Date();
|
||||
const d = new Date(base.getTime() + currentStep * 3600 * 1000);
|
||||
@ -1217,11 +1235,11 @@ export function OilSpillView() {
|
||||
return [
|
||||
{ label: '풍화량', value: weatheredVal },
|
||||
{ label: '면적', value: areaVal },
|
||||
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' },
|
||||
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--color-boom)' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
|
||||
<span className="text-text-3">{s.label}</span>
|
||||
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
|
||||
<span className="text-fg-disabled">{s.label}</span>
|
||||
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--font-mono)' }}>{s.value}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user