feat(design): 디자인 시스템 토큰 적용 및 Float 카탈로그 추가

This commit is contained in:
leedano 2026-04-07 17:30:42 +09:00
부모 afa1d16b6b
커밋 109c0d2480
102개의 변경된 파일13635개의 추가작업 그리고 6373개의 파일을 삭제

파일 보기

@ -125,6 +125,25 @@ wing/
- API 인터페이스 변경 시 `memory/api-types.md` 갱신
- 개별 탭 개발자는 공통 가이드를 참조하여 연동 구현
## 진행 중 작업 (완료 후 삭제)
### 디자인 시스템 폰트+색상 통일 작업
compact 후 반드시 `memory/design-system-work.md`를 읽고 작업 상태(완료/미완료 컴포넌트)를 확인할 것.
**색상 규칙:**
- 하드코딩 색상(`#ef4444`, `#a855f7` 등) → CSS 변수 전환
- `rgba(59,130,246,...)` 등 비-accent 계열 → `rgba(6,182,212,...)` (accent cyan)
- 시맨틱 컬러(`color-accent`, `color-info`, `color-caution` 등)는 다양하게 사용 가능하되, 강조 색상은 **최대 2가지**로 제한
- `linear-gradient` → 단색으로 단순화
- 장식용 `border-top`, `border-left` → 제거 여부를 유저에게 확인 후 진행
**폰트 규칙:**
- 하드코딩 `fontSize`/`fontWeight` → Tailwind 토큰 (`text-title-2`, `text-caption` 등)
- `fontFamily: monospace``var(--font-mono)`
- `fontFamily: sans-serif` / `'Noto Sans KR'``var(--font-korean)`
- 인라인 `style={{ fontSize, padding }}` → Tailwind 클래스 전환 (가능한 범위)
## 환경 설정
- Node.js 20 (`.node-version`, fnm 사용)

파일 보기

@ -2,7 +2,7 @@
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

파일 보기

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<path d="M4 12 Q16 0 28 12 Q22 15 16 13 Q10 15 4 12 Z" fill="#06b6d4"/>
<path d="M4 19 Q10 15 16 19 T28 19 L28 22 Q22 26 16 22 T4 22 Z" fill="#06b6d4"/>
<path d="M4 25 Q10 21 16 25 T28 25 L28 28 Q22 32 16 28 T4 28 Z" fill="#06b6d4"/>
</svg>

After

Width:  |  Height:  |  크기: 320 B

파일 보기

@ -113,7 +113,7 @@ export function LoginPage() {
{/* User ID */}
<div className="mb-4">
<label
className="block text-[10px] font-semibold text-fg-disabled mb-1.5"
className="block text-caption font-semibold text-fg-disabled mb-1.5"
style={{ letterSpacing: '0.3px' }}
>
@ -147,7 +147,7 @@ export function LoginPage() {
placeholder="사용자 아이디 입력"
autoComplete="username"
autoFocus
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
className="w-full bg-bg-elevated border border-stroke rounded-md text-title-4 outline-none"
style={{
padding: '11px 14px 11px 38px',
transition: 'border-color 0.2s, box-shadow 0.2s',
@ -167,7 +167,7 @@ export function LoginPage() {
{/* Password */}
<div className="mb-5">
<label
className="block text-[10px] font-semibold text-fg-disabled mb-1.5"
className="block text-caption font-semibold text-fg-disabled mb-1.5"
style={{ letterSpacing: '0.3px' }}
>
@ -200,7 +200,7 @@ export function LoginPage() {
}}
placeholder="비밀번호 입력"
autoComplete="current-password"
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
className="w-full bg-bg-elevated border border-stroke rounded-md text-title-4 outline-none"
style={{
padding: '11px 14px 11px 38px',
transition: 'border-color 0.2s, box-shadow 0.2s',
@ -219,7 +219,7 @@ 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-fg-disabled cursor-pointer">
<label className="flex items-center gap-1.5 text-label-2 text-fg-disabled cursor-pointer">
<input
type="checkbox"
checked={remember}
@ -230,7 +230,7 @@ export function LoginPage() {
</label>
<button
type="button"
className="text-[11px] text-color-accent cursor-pointer bg-transparent border-none"
className="text-label-2 text-color-accent cursor-pointer bg-transparent border-none"
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>
@ -241,7 +241,7 @@ export function LoginPage() {
{/* Pending approval */}
{pendingMessage && (
<div
className="flex items-start gap-2 text-[11px] rounded-sm mb-4"
className="flex items-start gap-2 text-label-2 rounded-sm mb-4"
style={{
padding: '10px 12px',
background: 'rgba(6,182,212,0.08)',
@ -271,7 +271,7 @@ export function LoginPage() {
{/* Error */}
{error && (
<div
className="flex items-center gap-1.5 text-[11px] rounded-sm mb-4"
className="flex items-center gap-1.5 text-label-2 rounded-sm mb-4"
style={{
padding: '8px 12px',
background: 'rgba(239,68,68,0.08)',
@ -279,7 +279,7 @@ export function LoginPage() {
color: '#f87171',
}}
>
<span className="text-[13px]">
<span className="text-title-4">
<svg
width="13"
height="13"
@ -353,7 +353,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-fg-disabled"></span>
<span className="text-caption text-fg-disabled"></span>
<div className="flex-1 bg-border h-px" />
</div>
@ -375,7 +375,7 @@ export function LoginPage() {
)}
<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]"
className="w-full rounded-md bg-bg-card border border-stroke text-fg-sub text-label-2 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(--bg-surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-card)')}
@ -407,7 +407,7 @@ export function LoginPage() {
border: '1px solid rgba(6,182,212,0.08)',
}}
>
<div className="text-[9px] font-bold text-color-accent mb-1.5"> </div>
<div className="text-caption font-bold text-color-accent mb-1.5"> </div>
<div className="flex flex-col gap-[3px]">
{DEMO_ACCOUNTS.map((acc) => (
<div
@ -427,10 +427,10 @@ export function LoginPage() {
}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span className="text-[9px] text-fg-sub font-mono">
<span className="text-caption text-fg-sub font-mono">
{acc.id} / {acc.password}
</span>
<span className="text-[8px] text-fg-disabled">{acc.label}</span>
<span className="text-caption text-fg-disabled">{acc.label}</span>
</div>
))}
</div>
@ -440,7 +440,7 @@ export function LoginPage() {
{/* end form card */}
{/* Footer */}
<div className="text-center text-[9px] text-fg-disabled mt-6 leading-[1.6]">
<div className="text-center text-caption 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)' }}>
&copy; 2026 Korea Coast Guard. All rights reserved.

파일 보기

@ -42,7 +42,7 @@ export function LayerTree({
return (
<div className="px-1">
<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>
<span className="text-caption font-semibold text-fg-disabled"> </span>
<div
className={`lyr-sw ${allEnabled ? 'on' : ''} cursor-pointer`}
onClick={handleToggleAll}
@ -260,7 +260,7 @@ function LayerNode({
<div>
<div className="lyr-t gap-1.5">
<span
className={`lyr-arr ${expanded ? 'open' : ''} cursor-pointer text-[7px] w-[10px] text-center`}
className={`lyr-arr ${expanded ? 'open' : ''} cursor-pointer text-caption w-[10px] text-center`}
onClick={handleHeaderClick}
>

파일 보기

@ -216,7 +216,7 @@ export function BacktrackReplayBar({
{/* Collision marker */}
{collisionEvent && (
<div
className="absolute text-[10px] cursor-pointer"
className="absolute text-caption cursor-pointer"
style={{
top: '-14px',
left: `${collisionEvent.progressPercent}%`,
@ -244,7 +244,7 @@ export function BacktrackReplayBar({
</div>
{/* Time labels */}
<div className="flex justify-between text-[9px] font-mono">
<div className="flex justify-between text-caption font-mono">
<span className="text-fg-disabled">{startLabel}</span>
<span className="font-semibold text-color-tertiary">{currentTimeLabel}</span>
<span className="text-fg-disabled">{endLabel}</span>
@ -257,13 +257,13 @@ export function BacktrackReplayBar({
{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-fg-sub font-mono">{ship.vesselName}</span>
<span className="text-caption text-fg-sub font-mono">{ship.vesselName}</span>
</div>
))}
{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-fg-sub font-mono"> </span>
<span className="text-caption text-fg-sub font-mono"> </span>
</div>
)}
</div>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -26,7 +26,7 @@ export function MeasureOverlay() {
e.stopPropagation();
removeMeasurement(mk.id);
}}
className="px-2 py-0.5 text-[11px] font-semibold text-white bg-[rgba(239,68,68,0.85)] hover:bg-[rgba(239,68,68,1)] rounded shadow-lg border border-[rgba(255,255,255,0.2)] cursor-pointer font-korean"
className="px-2 py-0.5 text-label-2 font-semibold text-white bg-[rgba(239,68,68,0.85)] hover:bg-[rgba(239,68,68,1)] rounded shadow-lg border border-[rgba(255,255,255,0.2)] cursor-pointer font-korean"
>
</button>

파일 보기

@ -39,7 +39,7 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
>
<span>{displayText}</span>
<span
className="text-[8px] text-fg-disabled"
className="text-caption text-fg-disabled"
style={{
transition: 'transform 0.2s',
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
@ -67,7 +67,7 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
onChange(option.value);
setIsOpen(false);
}}
className="text-[11px] cursor-pointer"
className="text-label-2 cursor-pointer"
style={{
padding: '8px 10px',
color: option.value === String(value) ? 'var(--color-accent)' : 'var(--fg-sub)',

파일 보기

@ -1125,11 +1125,11 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
}}
>
<div className="flex items-center gap-3">
<span className="font-bold text-[15px]" style={{ color: '#e2e8f0' }}>
<span className="font-bold text-subtitle" style={{ color: '#e2e8f0' }}>
Wing
</span>
<span
className="text-[11px] px-2 py-0.5 rounded font-mono"
className="text-label-2 px-2 py-0.5 rounded font-mono"
style={{
background: 'rgba(6,182,212,0.12)',
color: '#06b6d4',
@ -1141,7 +1141,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</div>
<button
onClick={onClose}
className="flex items-center justify-center w-7 h-7 rounded text-[13px] font-semibold transition-colors"
className="flex items-center justify-center w-7 h-7 rounded text-title-4 font-semibold transition-colors"
style={{ color: '#94a3b8', background: 'transparent' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a2540';
@ -1194,7 +1194,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
>
<div className="flex items-center gap-2.5">
<span
className="flex-shrink-0 w-7 h-7 rounded flex items-center justify-center text-[10px] font-bold font-mono"
className="flex-shrink-0 w-7 h-7 rounded flex items-center justify-center text-caption font-bold font-mono"
style={{
background: isActive ? 'rgba(6,182,212,0.18)' : 'rgba(255,255,255,0.05)',
color: isActive ? '#06b6d4' : '#64748b',
@ -1205,13 +1205,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</span>
<div className="min-w-0">
<div
className="text-[12px] font-medium leading-tight truncate"
className="text-label-1 font-medium leading-tight truncate"
style={{ color: isActive ? '#06b6d4' : '#cbd5e1' }}
>
{chapter.title}
</div>
<div
className="text-[10px] leading-tight mt-0.5 truncate"
className="text-caption leading-tight mt-0.5 truncate"
style={{ color: '#475569' }}
>
{chapter.subtitle}
@ -1230,7 +1230,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className="text-[11px] font-mono px-2 py-0.5 rounded font-bold"
className="text-label-2 font-mono px-2 py-0.5 rounded font-bold"
style={{
background: 'rgba(6,182,212,0.12)',
color: '#06b6d4',
@ -1239,20 +1239,20 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
>
CH {selectedChapter.number}
</span>
<h2 className="text-[16px] font-semibold" style={{ color: '#e2e8f0' }}>
<h2 className="text-title-2 font-semibold" style={{ color: '#e2e8f0' }}>
{selectedChapter.title}
</h2>
<span className="text-[12px]" style={{ color: '#475569' }}>
<span className="text-label-1" style={{ color: '#475569' }}>
{selectedChapter.subtitle}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] mr-1" style={{ color: '#64748b' }}>
<span className="text-label-2 mr-1" style={{ color: '#64748b' }}>
{selectedChapter.screens.length}
</span>
<button
onClick={allExpanded ? collapseAll : expandAll}
className="text-[11px] px-3 py-1 rounded transition-colors"
className="text-label-2 px-3 py-1 rounded transition-colors"
style={{
background: 'rgba(6,182,212,0.08)',
color: '#06b6d4',
@ -1298,7 +1298,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
}}
>
<span
className="flex-shrink-0 text-[10px] font-mono font-bold px-1.5 py-0.5 rounded"
className="flex-shrink-0 text-caption font-mono font-bold px-1.5 py-0.5 rounded"
style={{
background: 'rgba(6,182,212,0.1)',
color: '#06b6d4',
@ -1310,13 +1310,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{screen.id}
</span>
<span
className="flex-1 text-[13px] font-medium"
className="flex-1 text-title-4 font-medium"
style={{ color: '#cbd5e1' }}
>
{screen.name}
</span>
<span
className="flex-shrink-0 text-[10px] font-mono"
className="flex-shrink-0 text-caption font-mono"
style={{
color: '#475569',
transition: 'transform 0.2s',
@ -1346,14 +1346,17 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
display: 'block',
}}
/>
<p className="mt-1 text-[10px] text-right" style={{ color: '#475569' }}>
<p
className="mt-1 text-caption text-right"
style={{ color: '#475569' }}
>
</p>
</div>
{/* Menu path breadcrumb */}
<div
className="mb-3 text-[11px] font-mono px-2 py-1 rounded inline-block"
className="mb-3 text-label-2 font-mono px-2 py-1 rounded inline-block"
style={{
background: 'rgba(71,85,105,0.15)',
color: '#64748b',
@ -1365,7 +1368,10 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{/* Overview */}
<div className="mt-2">
<p className="text-[12px] leading-relaxed" style={{ color: '#94a3b8' }}>
<p
className="text-label-1 leading-relaxed"
style={{ color: '#94a3b8' }}
>
{screen.overview}
</p>
</div>
@ -1380,13 +1386,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
}}
>
<div
className="text-[11px] font-semibold mb-1.5 uppercase tracking-wide"
className="text-label-2 font-semibold mb-1.5 uppercase tracking-wide"
style={{ color: '#475569' }}
>
</div>
<p
className="text-[12px] leading-relaxed"
className="text-label-1 leading-relaxed"
style={{ color: '#7f8ea3' }}
>
{screen.description}
@ -1398,7 +1404,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{screen.procedure && screen.procedure.length > 0 && (
<div className="mt-4">
<div
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
style={{ color: '#475569' }}
>
@ -1407,7 +1413,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{screen.procedure.map((step, idx) => (
<li key={idx} className="flex items-start gap-2.5">
<span
className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold mt-0.5"
className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-caption font-bold mt-0.5"
style={{
background: 'rgba(6,182,212,0.12)',
color: '#06b6d4',
@ -1417,7 +1423,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{idx + 1}
</span>
<span
className="text-[12px] leading-relaxed"
className="text-label-1 leading-relaxed"
style={{ color: '#94a3b8' }}
>
{step}
@ -1432,7 +1438,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{screen.inputs && screen.inputs.length > 0 && (
<div className="mt-4">
<div
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
style={{ color: '#475569' }}
>
@ -1441,7 +1447,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
className="rounded overflow-hidden"
style={{ border: '1px solid #1e2a45' }}
>
<table className="w-full text-[12px]">
<table className="w-full text-label-1">
<thead>
<tr style={{ background: '#0f1729' }}>
<th
@ -1494,7 +1500,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
<td className="px-3 py-2">
{input.required ? (
<span
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
className="text-caption font-bold px-1.5 py-0.5 rounded"
style={{
background: 'rgba(239,68,68,0.1)',
color: '#f87171',
@ -1505,7 +1511,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</span>
) : (
<span
className="text-[10px] px-1.5 py-0.5 rounded"
className="text-caption px-1.5 py-0.5 rounded"
style={{
background: 'rgba(100,116,139,0.1)',
color: '#64748b',
@ -1531,7 +1537,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{screen.notes && screen.notes.length > 0 && (
<div className="mt-4">
<div
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
style={{ color: '#475569' }}
>
@ -1544,7 +1550,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
style={{ background: '#f59e0b' }}
/>
<span
className="text-[12px] leading-relaxed"
className="text-label-1 leading-relaxed"
style={{ color: '#94a3b8' }}
>
{note}
@ -1590,7 +1596,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
/>
<button
onClick={() => setLightboxSrc(null)}
className="absolute top-2 right-2 w-8 h-8 rounded flex items-center justify-center text-[13px] font-bold"
className="absolute top-2 right-2 w-8 h-8 rounded flex items-center justify-center text-title-4 font-bold"
style={{
background: 'rgba(15,23,41,0.85)',
color: '#94a3b8',

파일 보기

@ -1,6 +1,6 @@
import { create } from 'zustand'
import { api } from '../services/api'
import { haversineDistance, polygonAreaKm2 } from '../utils/geo'
import { create } from 'zustand';
import { api } from '../services/api';
import { haversineDistance, polygonAreaKm2 } from '../utils/geo';
export interface MapTypeItem {
mapKey: string;
@ -50,7 +50,7 @@ const DEFAULT_MAP_TYPES: MapTypeItem[] = [
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
]
];
let measureIdCounter = 0;
@ -67,17 +67,17 @@ export const useMapStore = create<MapState>((set, get) => ({
}),
loadMapTypes: async () => {
try {
const res = await api.get<MapTypeItem[]>('/map-base/active')
const types = res.data
const current = get().mapToggles
const newToggles: Partial<MapToggles> = {}
const res = await api.get<MapTypeItem[]>('/map-base/active');
const types = res.data;
const current = get().mapToggles;
const newToggles: Partial<MapToggles> = {};
for (const t of types) {
if (t.mapKey in current) {
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false;
}
}
// 모든 토글 기본 off (기본지도 표시)
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } });
} catch {
// API 실패 시 fallback 유지
}
@ -88,8 +88,7 @@ export const useMapStore = create<MapState>((set, get) => ({
measureInProgress: [],
measurements: [],
setMeasureMode: (mode) =>
set({ measureMode: mode, measureInProgress: [] }),
setMeasureMode: (mode) => set({ measureMode: mode, measureInProgress: [] }),
addMeasurePoint: (pt) => {
const { measureMode, measureInProgress } = get();
@ -99,7 +98,10 @@ export const useMapStore = create<MapState>((set, get) => ({
const dist = haversineDistance(next[0], next[1]);
const id = `measure-${++measureIdCounter}`;
set((s) => ({
measurements: [...s.measurements, { id, mode: 'distance', points: [next[0], next[1]], value: dist }],
measurements: [
...s.measurements,
{ id, mode: 'distance', points: [next[0], next[1]], value: dist },
],
measureInProgress: [],
}));
} else {
@ -116,7 +118,10 @@ export const useMapStore = create<MapState>((set, get) => ({
const area = polygonAreaKm2(measureInProgress);
const id = `measure-${++measureIdCounter}`;
set((s) => ({
measurements: [...s.measurements, { id, mode: 'area', points: [...measureInProgress], value: area }],
measurements: [
...s.measurements,
{ id, mode: 'area', points: [...measureInProgress], value: area },
],
measureInProgress: [],
}));
},
@ -124,6 +129,5 @@ export const useMapStore = create<MapState>((set, get) => ({
removeMeasurement: (id) =>
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })),
clearAllMeasurements: () =>
set({ measurements: [], measureInProgress: [], measureMode: null }),
}))
clearAllMeasurements: () => set({ measurements: [], measureInProgress: [], measureMode: null }),
}));

파일 보기

@ -77,6 +77,7 @@
--font-size-heading-2: 1.5rem;
--font-size-heading-3: 1.375rem;
--font-size-title-1: 1.125rem;
--font-size-subtitle: 0.9375rem;
--font-size-title-2: 1rem;
--font-size-title-3: 0.875rem;
--font-size-title-4: 0.8125rem;

파일 보기

@ -41,34 +41,34 @@
}
.wing-section-header {
@apply text-[13px] font-bold font-korean mb-2;
@apply text-title-4 font-bold font-korean mb-2;
}
.wing-section-desc {
@apply text-[10px] font-korean leading-relaxed;
@apply text-caption font-korean leading-relaxed;
color: var(--fg-disabled);
}
/* ── Typography ── */
.wing-title {
@apply text-[15px] font-bold font-korean;
@apply text-subtitle font-bold font-korean;
}
.wing-subtitle {
@apply text-[10px] font-korean mt-0.5;
@apply text-caption font-korean mt-0.5;
color: var(--fg-disabled);
}
.wing-label {
@apply text-[11px] font-semibold font-korean;
@apply text-label-2 font-semibold font-korean;
}
.wing-value {
@apply text-[11px] font-mono font-semibold;
@apply text-label-2 font-mono font-semibold;
}
.wing-meta {
@apply text-[9px] font-korean;
@apply text-caption font-korean;
color: var(--fg-disabled);
}
@ -83,12 +83,12 @@
/* ── Badge ── */
.wing-badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold font-korean;
@apply inline-flex items-center px-2 py-0.5 rounded text-caption font-bold font-korean;
}
/* ── Button ── */
.wing-btn {
@apply px-3 py-1.5 rounded-sm text-[11px] font-semibold cursor-pointer font-korean border-none;
@apply px-3 py-1.5 rounded-sm text-label-2 font-semibold cursor-pointer font-korean border-none;
transition: all 0.15s;
}
@ -134,7 +134,7 @@
/* ── Input ── */
.wing-input {
@apply w-full rounded-sm text-[11px] font-korean outline-none;
@apply w-full rounded-sm text-label-2 font-korean outline-none;
padding: 6px 10px;
background: var(--bg-base);
border: 1px solid var(--stroke-default);
@ -151,7 +151,7 @@
/* ── Table ── */
.wing-table {
@apply w-full text-[10px] font-korean;
@apply w-full text-caption font-korean;
border-collapse: collapse;
}
@ -232,11 +232,11 @@
}
.wing-kv-label {
@apply text-[10px] font-korean;
@apply text-caption font-korean;
color: var(--fg-disabled);
}
.wing-kv-value {
@apply text-[11px] font-semibold font-mono;
@apply text-label-2 font-semibold font-mono;
}
}

파일 보기

@ -30,13 +30,13 @@ export const ComponentsContent = () => {
style={{ opacity: 0.4 }}
>
<span
className="text-[#64748b] font-sans text-[10px] leading-[15px] font-bold uppercase"
className="text-[#64748b] font-sans text-caption leading-[15px] font-bold uppercase"
style={{ letterSpacing: '1px' }}
>
© 2024 WING-OPS
</span>
<span
className="text-[#22d3ee] font-korean text-[10px] leading-[15px] font-medium uppercase"
className="text-[#22d3ee] font-korean text-caption leading-[15px] font-medium uppercase"
style={{ letterSpacing: '1px' }}
>
v2.4

파일 보기

@ -29,13 +29,12 @@ const ButtonsThumbnail = ({ isDark }: { isDark: boolean }) => {
{buttons.map(({ label, bg, border, color }) => (
<div
key={label}
className="w-full rounded flex items-center justify-center"
className="w-full rounded flex items-center justify-center text-label-1"
style={{
height: '32px',
backgroundColor: bg,
border: `1.5px solid ${border}`,
color,
fontSize: '12px',
fontWeight: 600,
}}
>
@ -112,6 +111,109 @@ const TextInputsThumbnail = ({ isDark }: { isDark: boolean }) => {
);
};
const FloatThumbnail = ({ isDark }: { isDark: boolean }) => {
const backdropBg = isDark ? 'rgba(0,0,0,0.40)' : 'rgba(0,0,0,0.18)';
const dialogBg = isDark ? '#1a2236' : '#ffffff';
const dialogBorder = isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0';
const accent = isDark ? '#4cd7f6' : '#06b6d4';
const lineBg = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const successGreen = '#22c55e';
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-3 px-6">
{/* 미니 모달 */}
<div
className="w-full rounded-md flex items-center justify-center"
style={{ height: '60px', backgroundColor: backdropBg }}
>
<div
className="rounded"
style={{
width: '80%',
height: '40px',
backgroundColor: dialogBg,
border: `1px solid ${dialogBorder}`,
borderTop: `2px solid ${accent}`,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '4px',
padding: '6px 8px',
}}
>
<div
className="rounded"
style={{ height: '5px', width: '50%', backgroundColor: lineBg }}
/>
<div
className="rounded"
style={{ height: '5px', width: '70%', backgroundColor: lineBg }}
/>
</div>
</div>
{/* 미니 드롭다운 */}
<div className="w-full flex flex-col items-start gap-0.5" style={{ paddingLeft: '8px' }}>
<div
className="rounded"
style={{
width: '60%',
height: '16px',
backgroundColor: isDark ? 'rgba(255,255,255,0.07)' : '#e2e8f0',
border: `1px solid ${dialogBorder}`,
}}
/>
<div
className="rounded"
style={{
width: '60%',
height: '28px',
backgroundColor: dialogBg,
border: `1px solid ${dialogBorder}`,
display: 'flex',
flexDirection: 'column',
gap: '2px',
padding: '3px 6px',
}}
>
<div
className="rounded"
style={{ height: '4px', width: '80%', backgroundColor: accent, opacity: 0.5 }}
/>
<div
className="rounded"
style={{ height: '4px', width: '60%', backgroundColor: lineBg }}
/>
<div
className="rounded"
style={{ height: '4px', width: '70%', backgroundColor: lineBg }}
/>
</div>
</div>
{/* 미니 토스트 */}
<div className="w-full flex justify-end" style={{ paddingRight: '8px' }}>
<div
className="rounded"
style={{
width: '55%',
height: '16px',
backgroundColor: dialogBg,
border: `1px solid ${dialogBorder}`,
borderLeft: `3px solid ${successGreen}`,
display: 'flex',
alignItems: 'center',
paddingLeft: '6px',
}}
>
<div
className="rounded"
style={{ height: '4px', width: '60%', backgroundColor: lineBg }}
/>
</div>
</div>
</div>
);
};
// ---------- 카드 정의 ----------
const OVERVIEW_CARDS: OverviewCard[] = [
@ -125,6 +227,11 @@ const OVERVIEW_CARDS: OverviewCard[] = [
label: 'Text Field',
thumbnail: (isDark) => <TextInputsThumbnail isDark={isDark} />,
},
{
id: 'float',
label: 'Float',
thumbnail: (isDark) => <FloatThumbnail isDark={isDark} />,
},
];
// ---------- Props ----------

파일 보기

@ -32,7 +32,7 @@ const SectionTitle = ({ num, title, sub, rightNode, theme }: SectionTitleProps)
</div>
{sub && (
<p
className="font-mono text-[10px] leading-[15px] uppercase"
className="font-mono text-caption leading-[15px] uppercase"
style={{ letterSpacing: theme.sectionSubSpacing, color: theme.sectionSub }}
>
{sub}
@ -46,7 +46,7 @@ const TYPO_ROWS: TypoRow[] = [
{
size: '9px / Meta',
sampleNode: (t) => (
<span className="font-korean text-[9px]" style={{ color: t.typoSampleText }}>
<span className="font-korean text-caption" style={{ color: t.typoSampleText }}>
Meta info
</span>
),
@ -55,7 +55,7 @@ const TYPO_ROWS: TypoRow[] = [
{
size: '10px / Table',
sampleNode: (t) => (
<span className="font-korean text-[10px]" style={{ color: t.typoSampleText }}>
<span className="font-korean text-caption" style={{ color: t.typoSampleText }}>
Table data
</span>
),
@ -68,7 +68,7 @@ const TYPO_ROWS: TypoRow[] = [
className="inline-flex items-center rounded-md border border-solid py-2 px-4"
style={{ backgroundColor: t.typoActionBg, borderColor: t.typoActionBorder }}
>
<span className="font-korean text-[11px] font-medium" style={{ color: t.typoActionText }}>
<span className="font-korean text-label-2 font-medium" style={{ color: t.typoActionText }}>
/ Input/Button text
</span>
</span>
@ -78,7 +78,7 @@ const TYPO_ROWS: TypoRow[] = [
{
size: '13px / Header',
sampleNode: (t) => (
<span className="font-korean text-[13px] font-bold" style={{ color: t.textPrimary }}>
<span className="font-korean text-title-4 font-bold" style={{ color: t.textPrimary }}>
Section Header
</span>
),
@ -87,7 +87,7 @@ const TYPO_ROWS: TypoRow[] = [
{
size: '15px / Title',
sampleNode: (t) => (
<span className="font-korean text-[15px] font-bold" style={{ color: t.textPrimary }}>
<span className="font-korean text-subtitle font-bold" style={{ color: t.textPrimary }}>
Panel Title
</span>
),
@ -97,10 +97,10 @@ const TYPO_ROWS: TypoRow[] = [
size: 'Data / Mono',
sampleNode: (t) => (
<div className="flex flex-col gap-1">
<span className="font-mono text-[11px]" style={{ color: t.typoDataText }}>
<span className="font-mono text-label-2" style={{ color: t.typoDataText }}>
1,234.56 km²
</span>
<span className="font-mono text-[11px]" style={{ color: t.typoCoordText }}>
<span className="font-mono text-label-2" style={{ color: t.typoCoordText }}>
35° 06' 12&quot; N
</span>
</div>
@ -202,7 +202,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
{item.hex}
</span>
<span
className="font-korean text-[11px] leading-[16.5px]"
className="font-korean text-label-2 leading-[16.5px]"
style={{ color: t.textSecondary }}
>
{item.desc}
@ -229,7 +229,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
boxShadow: t.borderCardShadow,
}}
>
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
{item.token}
</span>
<span
@ -259,7 +259,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
>
{t.textTokens.map((item) => (
<div key={item.token} className="flex flex-col gap-[3px]">
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
{item.token}
</span>
<span className={item.sampleClass}>{item.sampleText}</span>
@ -302,7 +302,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
>
{item.name}
</span>
<span className="font-mono text-[10px]" style={{ color: t.textMuted }}>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{item.token} / {item.color}
</span>
</div>
@ -316,7 +316,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
}}
>
<span
className="font-korean text-[11px] font-medium"
className="font-korean text-label-2 font-medium"
style={{ color: item.badgeText }}
>
{item.badge}
@ -348,12 +348,12 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
}}
/>
<span
className="font-korean text-[13px] font-bold flex-1"
className="font-korean text-title-4 font-bold flex-1"
style={{ color: item.color }}
>
{item.label}
</span>
<span className="font-mono text-[10px] opacity-40" style={{ color: item.color }}>
<span className="font-mono text-caption opacity-40" style={{ color: item.color }}>
{item.hex}
</span>
</div>
@ -370,7 +370,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
rightNode={
<div className="flex flex-row gap-2 items-center">
<span
className="rounded-sm py-0.5 px-2 font-korean text-[10px] font-bold"
className="rounded-sm py-0.5 px-2 font-korean text-caption font-bold"
style={{ backgroundColor: t.fontBadgePrimaryBg, color: t.fontBadgePrimaryText }}
>
PretendardGOV
@ -391,7 +391,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
style={{ textAlign: i === 2 ? 'right' : 'left', borderColor: t.tableRowBorder }}
>
<span
className="font-mono text-[10px] font-medium uppercase"
className="font-mono text-caption font-medium uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
{col}
@ -412,7 +412,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
>
{/* Size */}
<div className="flex-1 py-4 px-8">
<span className="font-mono text-[10px]" style={{ color: t.typoSizeText }}>
<span className="font-mono text-caption" style={{ color: t.typoSizeText }}>
{row.size}
</span>
</div>
@ -420,7 +420,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
<div className="flex-1 py-4 px-8">{row.sampleNode(t)}</div>
{/* Properties */}
<div className="flex-1 py-4 px-8 text-right" style={{ opacity: 0.5 }}>
<span className="font-mono text-[10px]" style={{ color: t.typoPropertiesText }}>
<span className="font-mono text-caption" style={{ color: t.typoPropertiesText }}>
{row.properties}
</span>
</div>
@ -447,7 +447,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
}}
>
<span
className="font-korean text-[10px] font-bold uppercase"
className="font-korean text-caption font-bold uppercase"
style={{ letterSpacing: '1px', color: t.textAccent }}
>
Small Elements
@ -475,7 +475,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
}}
>
<span
className="font-korean text-[10px] font-bold uppercase"
className="font-korean text-caption font-bold uppercase"
style={{ letterSpacing: '1px', color: t.textAccent }}
>
Structural Panels
@ -503,7 +503,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
{['Precision Engineering', 'Safety Compliant', 'Optimized v8.42'].map((label) => (
<span
key={label}
className="font-mono text-[10px] uppercase"
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.footerText }}
>
{label}
@ -513,13 +513,13 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
{/* 우측 */}
<div className="flex flex-row gap-2 items-center">
<span
className="font-mono text-[10px] uppercase"
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.footerText }}
>
Generated for Terminal:
</span>
<span
className="font-mono text-[10px] font-medium uppercase"
className="font-mono text-caption font-medium uppercase"
style={{ letterSpacing: '1px', color: t.footerAccent }}
>
1440x900_PR_MKT

파일 보기

@ -50,10 +50,10 @@ export const DesignHeader = ({
}}
>
<span
className="font-sans text-[10px] leading-[15px] uppercase"
className="font-sans text-caption leading-[15px] uppercase"
style={{ letterSpacing: '2px', color: theme.textMuted }}
>
Design System v1.0
Design System v1.1
</span>
</div>
</div>

파일 보기

@ -13,6 +13,7 @@ import FoundationsOverview from './FoundationsOverview';
import ComponentsOverview from './ComponentsOverview';
import { ButtonContent } from './ButtonContent';
import { TextFieldContent } from './TextFieldContent';
import { FloatContent } from './FloatContent';
import { getTheme } from './designTheme';
import type { ThemeMode } from './designTheme';
@ -69,6 +70,8 @@ export const DesignPage = () => {
return <ButtonContent theme={theme} />;
case 'text-field':
return <TextFieldContent theme={theme} />;
case 'float':
return <FloatContent theme={theme} />;
default:
return <ComponentsContent />;
}

파일 보기

@ -2,7 +2,7 @@ import type { DesignTheme } from './designTheme';
import type { DesignTab } from './DesignHeader';
export type FoundationsMenuItemId = 'overview' | 'color' | 'typography' | 'radius' | 'layout';
export type ComponentsMenuItemId = 'overview' | 'buttons' | 'text-field';
export type ComponentsMenuItemId = 'overview' | 'buttons' | 'text-field' | 'float';
export type MenuItemId = FoundationsMenuItemId | ComponentsMenuItemId;
interface MenuItem {
@ -22,6 +22,7 @@ const COMPONENTS_MENU: MenuItem[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'buttons', label: 'Buttons' },
{ id: 'text-field', label: 'Text Field' },
{ id: 'float', label: 'Float' },
];
const SIDEBAR_CONFIG: Record<DesignTab, { title: string; subtitle: string; menu: MenuItem[] }> = {

파일 보기

@ -0,0 +1,100 @@
// FloatContent.tsx — Float 서브탭 래퍼
import { useState } from 'react';
import type { DesignTheme } from './designTheme';
import { FloatModalContent } from './float/FloatModalContent';
import { FloatDropdownContent } from './float/FloatDropdownContent';
import { FloatOverlayContent } from './float/FloatOverlayContent';
import { FloatToastContent } from './float/FloatToastContent';
type FloatSubTab = 'modal' | 'dropdown' | 'overlay' | 'toast';
const SUB_TABS: { id: FloatSubTab; label: string; desc: string }[] = [
{ id: 'modal', label: 'Modal', desc: 'Dialog · Confirm' },
{ id: 'dropdown', label: 'Dropdown', desc: 'ComboBox · Select' },
{ id: 'overlay', label: 'Overlay', desc: 'Map Layer · Popup' },
{ id: 'toast', label: 'Toast', desc: 'Notification · Alert' },
];
interface FloatContentProps {
theme: DesignTheme;
}
export const FloatContent = ({ theme }: FloatContentProps) => {
const [activeSubTab, setActiveSubTab] = useState<FloatSubTab>('modal');
const t = theme;
const isDark = t.mode === 'dark';
const renderSubContent = () => {
switch (activeSubTab) {
case 'modal':
return <FloatModalContent theme={t} />;
case 'dropdown':
return <FloatDropdownContent theme={t} />;
case 'overlay':
return <FloatOverlayContent theme={t} />;
case 'toast':
return <FloatToastContent theme={t} />;
}
};
return (
<div className="flex flex-col h-full">
{/* 서브탭 헤더 */}
<div
className="px-8 pt-6 pb-0 border-b border-solid shrink-0"
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
>
<div className="flex flex-col gap-4">
<div>
<h1 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Float
</h1>
<p className="font-korean text-sm leading-5 mt-1" style={{ color: t.textSecondary }}>
UI Modal, Dropdown, Overlay, Toast
</p>
</div>
{/* 서브탭 바 */}
<nav className="flex flex-row gap-1">
{SUB_TABS.map(({ id, label, desc }) => {
const isActive = activeSubTab === id;
return (
<button
key={id}
type="button"
onClick={() => setActiveSubTab(id)}
className="flex flex-col items-start px-4 pb-3 pt-1 cursor-pointer bg-transparent relative"
style={{
borderBottom: isActive ? `2px solid ${t.textAccent}` : '2px solid transparent',
}}
>
<span
className="font-sans text-sm font-bold leading-5"
style={{ color: isActive ? t.textAccent : t.textMuted }}
>
{label}
</span>
<span
className="font-mono text-caption leading-4"
style={{
color: isActive ? t.textAccent : t.textMuted,
opacity: isActive ? 0.7 : 0.5,
}}
>
{desc}
</span>
</button>
);
})}
</nav>
</div>
</div>
{/* 서브탭 콘텐츠 */}
<div className="flex-1 overflow-y-auto">{renderSubContent()}</div>
</div>
);
};
export default FloatContent;

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -118,7 +118,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
{(['이름', '값', 'Preview'] as const).map((col) => (
<div key={col} className="py-3 px-4">
<span
className="font-mono text-[10px] font-medium uppercase"
className="font-mono text-caption font-medium uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
{col}
@ -153,7 +153,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
</span>
{token.isCustom && (
<span
className="font-mono text-[9px] rounded px-1.5 py-0.5"
className="font-mono text-caption rounded px-1.5 py-0.5"
style={{
color: isDark ? '#f97316' : '#c2410c',
backgroundColor: isDark ? 'rgba(249,115,22,0.10)' : 'rgba(249,115,22,0.08)',
@ -166,7 +166,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
{/* 값 */}
<div className="py-4 px-4">
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>
<span className="font-mono text-label-2" style={{ color: t.textPrimary }}>
{token.value}
</span>
</div>

파일 보기

@ -378,7 +378,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
>
<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"
className="font-mono text-label-2 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)',
@ -401,11 +401,11 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
<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="font-mono text-caption 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="font-mono text-caption 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>
@ -484,7 +484,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
{row.letterSpacing}
</span>
<span
className="font-mono text-[10px] rounded border border-solid px-1.5 py-0.5 w-fit"
className="font-mono text-caption rounded border border-solid px-1.5 py-0.5 w-fit"
style={{
color: t.textAccent,
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
@ -515,7 +515,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
>
{row.token}
</div>
<div className="font-mono text-[10px] mt-0.5" style={{ color: t.textMuted }}>
<div className="font-mono text-caption mt-0.5" style={{ color: t.textMuted }}>
{row.size} · {row.weight} · {row.lineHeight}
</div>
</div>
@ -767,7 +767,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
{row.value}
</span>
<span
className="font-mono text-[10px] rounded border border-solid px-1.5 py-0.5 w-fit"
className="font-mono text-caption rounded border border-solid px-1.5 py-0.5 w-fit"
style={{
color: t.textAccent,
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',

파일 보기

@ -19,7 +19,7 @@ const buttonRows: ButtonRow[] = [
'linear-gradient(120.41deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
}}
>
<div className="text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-white text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
</div>
</div>
@ -33,7 +33,7 @@ const buttonRows: ButtonRow[] = [
boxShadow: '0px 0px 12px 0px rgba(6, 182, 212, 0.4)',
}}
>
<div className="text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-white text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
</div>
</div>
@ -43,7 +43,7 @@ const buttonRows: ButtonRow[] = [
className="bg-[#334155] rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative"
style={{ opacity: 0.5 }}
>
<div className="text-[#64748b] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#64748b] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
</div>
</div>
@ -53,21 +53,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-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#c0c8dc] text-center font-korean text-label-2 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-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#c0c8dc] text-center font-korean text-label-2 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(192,200,220,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-label-2 font-medium relative flex items-center justify-center">
</div>
</div>
@ -77,21 +77,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-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#c0c8dc] text-center font-korean text-label-2 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-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#c0c8dc] text-center font-korean text-label-2 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(192,200,220,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-label-2 font-medium relative flex items-center justify-center">
</div>
</div>
@ -102,7 +102,7 @@ const buttonRows: ButtonRow[] = [
defaultBtn: (
<div className="bg-[rgba(59,130,246,0.08)] rounded-md border border-solid border-[rgba(59,130,246,0.30)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative">
<img className="shrink-0 relative overflow-visible" src={pdfFileIcon} alt="PDF 아이콘" />
<div className="text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#3b82f6] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
PDF
</div>
</div>
@ -113,7 +113,7 @@ const buttonRows: ButtonRow[] = [
style={{ boxShadow: '0px 0px 8px 0px rgba(59, 130, 246, 0.2)' }}
>
<img className="shrink-0 relative overflow-visible" src={pdfFileIcon} alt="PDF 아이콘" />
<div className="text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#3b82f6] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
PDF
</div>
</div>
@ -125,7 +125,7 @@ const buttonRows: ButtonRow[] = [
src={pdfFileDisabledIcon}
alt="PDF 아이콘 (비활성)"
/>
<div className="text-[rgba(59,130,246,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[rgba(59,130,246,0.40)] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
PDF
</div>
</div>
@ -135,7 +135,7 @@ const buttonRows: ButtonRow[] = [
label: '경고 (삭제)',
defaultBtn: (
<div className="bg-[rgba(239,68,68,0.10)] rounded-md border border-solid border-[rgba(239,68,68,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-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#ef4444] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
</div>
</div>
@ -145,14 +145,14 @@ const buttonRows: ButtonRow[] = [
className="bg-[rgba(239,68,68,0.20)] rounded-md border border-solid border-[rgba(239,68,68,0.50)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative"
style={{ boxShadow: '0px 0px 8px 0px rgba(239, 68, 68, 0.15)' }}
>
<div className="text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[#ef4444] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
</div>
</div>
),
disabledBtn: (
<div className="bg-[rgba(239,68,68,0.05)] rounded-md border border-solid border-[rgba(239,68,68,0.15)] 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(239,68,68,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
<div className="text-[rgba(239,68,68,0.40)] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
</div>
</div>

파일 보기

@ -32,7 +32,7 @@ export const CardSection = () => {
{/* 카드 헤더 */}
<div className="border-l-2 border-[#06b6d4] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div
className="text-[#64748b] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start"
className="text-[#64748b] text-left font-korean text-caption leading-[15px] font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1px' }}
>
@ -55,12 +55,12 @@ export const CardSection = () => {
</div>
<div className="flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
<div className="flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div className="text-[#dfe2f3] text-left font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-start">
<div className="text-[#dfe2f3] text-left font-korean text-label-2 leading-[16.5px] font-medium relative flex items-center justify-start">
{item.label}
</div>
</div>
<div className="flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div className="text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start">
<div className="text-[#64748b] text-left font-sans text-caption leading-[15px] font-normal relative flex items-center justify-start">
{item.progress}
</div>
</div>
@ -78,7 +78,7 @@ export const CardSection = () => {
'linear-gradient(97.29deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
}}
>
<div className="text-white text-center font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-center">
<div className="text-white text-center font-korean text-label-2 leading-[16.5px] font-medium relative flex items-center justify-center">
</div>
</div>
@ -108,7 +108,7 @@ export const CardSection = () => {
<div className="flex flex-row items-start justify-between self-stretch shrink-0 relative">
<div className="flex flex-col gap-[4.5px] items-start justify-start shrink-0 relative">
<div
className="text-[#22d3ee] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start"
className="text-[#22d3ee] text-left font-korean text-caption leading-[15px] font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1px' }}
>
@ -139,14 +139,14 @@ export const CardSection = () => {
<div className="flex flex-row items-center justify-between self-stretch shrink-0 relative">
{/* 정상 가동중 뱃지 */}
<div className="bg-[rgba(34,197,94,0.10)] rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-col gap-0 items-start justify-start shrink-0 relative">
<div className="text-[#22c55e] text-left font-korean text-[9px] leading-[13.5px] font-medium relative flex items-center justify-start">
<div className="text-[#22c55e] text-left font-korean text-caption leading-[13.5px] font-medium relative flex items-center justify-start">
</div>
</div>
{/* 대응팀 배치 아웃라인 버튼 */}
<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-[#c0c8dc] 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-caption leading-[15px] font-medium relative flex items-center justify-center">
</div>
</div>

파일 보기

@ -85,7 +85,7 @@ export const IconBadgeSection = () => {
</div>
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
<div
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
className="text-[#64748b] text-left font-sans font-bold text-caption leading-[15px] uppercase relative flex items-center justify-start"
style={{ letterSpacing: '0.9px' }}
>
{btn.label}
@ -97,7 +97,7 @@ export const IconBadgeSection = () => {
{/* 카드 푸터 */}
<div className="bg-[rgba(15,23,42,0.30)] pt-4 pr-6 pb-4 pl-6 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div className="text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start">
<div className="text-[#64748b] text-left font-sans text-caption leading-[15px] font-normal relative flex items-center justify-start">
Standard dimensions: 36x36px with radius-md (6px)
</div>
</div>
@ -127,7 +127,7 @@ export const IconBadgeSection = () => {
<div className="flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative">
<div className="border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
className="text-[#64748b] text-left font-sans font-bold text-caption leading-[15px] uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1px' }}
>
Operational Status
@ -141,7 +141,7 @@ export const IconBadgeSection = () => {
style={{ backgroundColor: badge.bg }}
>
<div
className="text-left font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-start"
className="text-left font-korean text-caption leading-[15px] font-medium relative flex items-center justify-start"
style={{ color: badge.color }}
>
{badge.label}
@ -155,7 +155,7 @@ export const IconBadgeSection = () => {
<div className="flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative">
<div className="border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
className="text-[#64748b] text-left font-sans font-bold text-caption leading-[15px] uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1px' }}
>
Data Classification
@ -174,7 +174,7 @@ export const IconBadgeSection = () => {
></div>
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
<div
className="text-left font-sans font-bold text-[10px] leading-[15px] relative flex items-center justify-start"
className="text-left font-sans font-bold text-caption leading-[15px] relative flex items-center justify-start"
style={{ color: tag.color }}
>
{tag.label}

파일 보기

@ -321,21 +321,21 @@ export const DARK_THEME: DesignTheme = {
{
token: 'text-1',
sampleText: '주요 텍스트 Primary Text',
sampleClass: 'text-[#edf0f7] font-korean text-[15px] font-bold',
sampleClass: 'text-[#edf0f7] font-korean text-subtitle font-bold',
desc: 'Headings, active values, and primary labels.',
descColor: 'rgba(237,240,247,0.60)',
},
{
token: 'text-2',
sampleText: '보조 텍스트 Secondary Text',
sampleClass: 'text-[#c0c8dc] font-korean text-[15px] font-medium',
sampleClass: 'text-[#c0c8dc] font-korean text-subtitle 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]',
sampleClass: 'text-[#9ba3b8] font-korean text-subtitle',
desc: 'Disabled states, placeholders, and captions.',
descColor: 'rgba(155,163,184,0.60)',
},
@ -353,7 +353,7 @@ export const LIGHT_THEME: DesignTheme = {
headerBg: '#ffffff',
headerBorder: '#e2e8f0',
textPrimary: '#0f172a',
textPrimary: '#000000',
textSecondary: '#64748b',
textMuted: '#94a3b8',
textAccent: '#06b6d4',
@ -498,21 +498,21 @@ export const LIGHT_THEME: DesignTheme = {
{
token: 'text-1',
sampleText: '주요 텍스트 Primary Text',
sampleClass: 'text-[#0f172a] font-korean text-[15px] font-bold',
sampleClass: 'text-[#0f172a] font-korean text-subtitle font-bold',
desc: 'Headings, active values, and primary labels.',
descColor: '#64748b',
},
{
token: 'text-2',
sampleText: '보조 텍스트 Secondary Text',
sampleClass: 'text-[#475569] font-korean text-[15px] font-medium',
sampleClass: 'text-[#475569] font-korean text-subtitle font-medium',
desc: 'Supporting labels and secondary information.',
descColor: '#64748b',
},
{
token: 'text-3',
sampleText: '비활성 텍스트 Muted Text',
sampleClass: 'text-[#94a3b8] font-korean text-[15px]',
sampleClass: 'text-[#94a3b8] font-korean text-subtitle',
desc: 'Disabled states, placeholders, and captions.',
descColor: '#94a3b8',
},

파일 보기

@ -0,0 +1,440 @@
// FloatDropdownContent.tsx — Dropdown/ComboBox 카탈로그
import { useState } from 'react';
import type { DesignTheme } from '../designTheme';
import { ComboBox } from '@common/components/ui/ComboBox';
interface FloatDropdownContentProps {
theme: DesignTheme;
}
const DEMO_OPTIONS = [
{ value: 'option1', label: '연속 유출 (Continuous)' },
{ value: 'option2', label: '순간 유출 (Instantaneous)' },
{ value: 'option3', label: '밀도가스 유출 (Dense Gas)' },
{ value: 'option4', label: '수중 유출 (Subsurface)' },
{ value: 'option5', label: '증발 유출 (Evaporative)' },
];
const ALGORITHM_OPTIONS = [
{ value: 'slick', label: 'Slick Formation Model' },
{ value: 'gnome', label: 'GNOME (NOAA)' },
{ value: 'medslik', label: 'MEDSLIK-II' },
];
export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
const t = theme;
const isDark = t.mode === 'dark';
const [demoValue, setDemoValue] = useState('option1');
const [algoValue, setAlgoValue] = useState('slick');
return (
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
{/* ── 개요 ── */}
<div className="flex flex-col gap-3">
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
Dropdown
</h2>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
{' '}
<code
className="font-mono text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
position: absolute
</code>
. 5 .
{' '}
<code
className="font-mono text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
ComboBox
</code>
.
</p>
</div>
{/* ── Live Preview ── */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Live Preview
</h3>
<span
className="font-mono text-caption px-2 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)',
color: '#22c55e',
}}
>
interactive
</span>
</div>
<div
className="rounded-lg border border-solid p-6 flex flex-col gap-6"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-2">
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
</span>
<ComboBox
value={demoValue}
onChange={setDemoValue}
options={DEMO_OPTIONS}
placeholder="유형 선택"
/>
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
: {DEMO_OPTIONS.find((o) => o.value === demoValue)?.label}
</span>
</div>
<div className="flex flex-col gap-2">
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
</span>
<ComboBox
value={algoValue}
onChange={setAlgoValue}
options={ALGORITHM_OPTIONS}
placeholder="알고리즘 선택"
/>
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
: {ALGORITHM_OPTIONS.find((o) => o.value === algoValue)?.label}
</span>
</div>
</div>
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
{' '}
<code className="font-mono" style={{ color: t.textAccent }}>
@common/components/ui/ComboBox
</code>
. .
</p>
</div>
</div>
{/* ── Anatomy ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Anatomy
</h3>
<div className="grid grid-cols-2 gap-6">
{/* 구조 다이어그램 */}
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
Structure
</span>
<div className="flex flex-col gap-0.5 p-4">
{/* 트리거 */}
<div
className="rounded border border-solid flex items-center justify-between px-3 py-2"
style={{
backgroundColor: isDark ? '#1b1f2c' : '#ffffff',
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
}}
>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
</span>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
</span>
</div>
{/* 리스트 */}
<div
className="rounded border border-solid flex flex-col overflow-hidden mt-0.5"
style={{
backgroundColor: isDark ? '#1b1f2c' : '#ffffff',
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
}}
>
{['옵션 A (선택됨)', '옵션 B', '옵션 C', '옵션 D'].map((opt, i) => (
<div
key={opt}
className="px-3 py-1.5"
style={{
backgroundColor:
i === 0
? isDark
? 'rgba(76,215,246,0.10)'
: 'rgba(6,182,212,0.07)'
: 'transparent',
borderTop: i === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
borderLeft: i === 0 ? `2px solid ${t.textAccent}` : '2px solid transparent',
}}
>
<span
className="font-korean text-caption"
style={{ color: i === 0 ? t.textAccent : t.textSecondary }}
>
{opt}
</span>
</div>
))}
</div>
</div>
</div>
{/* 위치 지정 규칙 */}
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
Position Rules
</span>
<div className="flex flex-col gap-2">
{[
{ label: 'trigger', rule: 'position: relative', desc: '드롭다운 기준점' },
{
label: 'list',
rule: 'position: absolute, top: calc(100% + 2px)',
desc: '트리거 바로 아래',
},
{ label: 'z-index', rule: 'z-[1000]', desc: '모달(9999) 아래, 일반 UI 위' },
{ label: 'max-height', rule: '200px + overflow-y: auto', desc: '스크롤 한계' },
{ label: 'animation', rule: 'fadeSlideDown 0.15s ease-out', desc: '부드러운 등장' },
].map((item) => (
<div key={item.label} className="flex items-start gap-2">
<span
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
{item.label}
</span>
<div className="flex flex-col gap-0.5">
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{item.rule}
</span>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
{item.desc}
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* ── 상태 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
</h3>
<div className="grid grid-cols-4 gap-3">
{[
{
label: 'Default',
desc: '닫힘, 값 미선택',
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
textColor: t.textMuted,
},
{
label: 'Open',
desc: '리스트 표시 중',
borderColor: t.textAccent,
textColor: t.textAccent,
},
{
label: 'Selected',
desc: '값 선택됨',
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
textColor: t.textPrimary,
},
{
label: 'Disabled',
desc: '비활성',
borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#f1f5f9',
textColor: isDark ? 'rgba(140,144,159,0.40)' : '#cbd5e1',
},
].map((state) => (
<div
key={state.label}
className="rounded-lg border border-solid p-3 flex flex-col gap-2"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span className="font-mono text-caption font-bold" style={{ color: t.textPrimary }}>
{state.label}
</span>
<div
className="rounded border border-solid flex items-center justify-between px-2 py-1.5"
style={{
borderColor: state.borderColor,
opacity: state.label === 'Disabled' ? 0.45 : 1,
}}
>
<span className="font-korean text-caption" style={{ color: state.textColor }}>
{state.label === 'Selected' ? '연속 유출' : '선택하세요'}
</span>
<span className="font-mono text-caption" style={{ color: state.textColor }}>
{state.label === 'Open' ? '▲' : '▼'}
</span>
</div>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
{state.desc}
</span>
</div>
))}
</div>
</div>
{/* ── Props 테이블 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Props (ComboBox)
</h3>
<div
className="rounded-lg border border-solid overflow-hidden"
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
>
<div
className="grid"
style={{
gridTemplateColumns: '140px 160px 80px 1fr',
backgroundColor: t.tableHeaderBg,
borderBottom: `1px solid ${t.tableRowBorder}`,
}}
>
{['Prop', 'Type', 'Required', 'Description'].map((col) => (
<div key={col} className="py-2.5 px-4">
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
{col}
</span>
</div>
))}
</div>
{[
{ prop: 'value', type: 'string | number', required: 'Y', desc: '현재 선택값' },
{
prop: 'onChange',
type: '(value: string) => void',
required: 'Y',
desc: '선택 변경 콜백',
},
{
prop: 'options',
type: 'ComboBoxOption[]',
required: 'Y',
desc: '{ value, label } 배열',
},
{ prop: 'placeholder', type: 'string', required: 'N', desc: '미선택 상태 표시 텍스트' },
{ prop: 'className', type: 'string', required: 'N', desc: '트리거 추가 스타일' },
].map((row, idx) => (
<div
key={row.prop}
className="grid items-center"
style={{
gridTemplateColumns: '140px 160px 80px 1fr',
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
}}
>
<div className="py-2.5 px-4">
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
{row.prop}
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
{row.type}
</span>
</div>
<div className="py-2.5 px-4">
<span
className="font-mono text-caption"
style={{ color: row.required === 'Y' ? '#22c55e' : t.textMuted }}
>
{row.required}
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
{row.desc}
</span>
</div>
</div>
))}
</div>
</div>
{/* ── 사용 가이드라인 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
</h3>
<div className="grid grid-cols-2 gap-3">
{[
{
title: 'clickOutside 처리 필수',
desc: 'useEffect + mousedown 이벤트로 외부 클릭 감지. ComboBox 내부에 구현됨.',
type: 'rule',
},
{
title: '5개 이상 선택지',
desc: '4개 이하는 Radio 버튼 또는 버튼 그룹으로 대체. 너무 많은 옵션은 검색 필터 추가 고려.',
type: 'rule',
},
{
title: '모달 내부 사용 시',
desc: '모달 z-index(9999) 내부에 있으면 드롭다운 z-[1000]이 자연스럽게 모달 위에 렌더링됨.',
type: 'info',
},
{
title: '너비 상속',
desc: '드롭다운 리스트는 트리거와 동일한 너비. left: 0, right: 0으로 너비 상속.',
type: 'info',
},
].map((item) => (
<div
key={item.title}
className="rounded border border-solid px-4 py-3 flex flex-col gap-1"
style={{
backgroundColor: t.cardBg,
borderColor:
item.type === 'rule'
? isDark
? 'rgba(76,215,246,0.20)'
: 'rgba(6,182,212,0.20)'
: t.cardBorder,
}}
>
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
{item.title}
</span>
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
{item.desc}
</span>
</div>
))}
</div>
</div>
</div>
);
};
export default FloatDropdownContent;

파일 보기

@ -0,0 +1,668 @@
// FloatModalContent.tsx — Modal + Confirm 카탈로그
import { useState } from 'react';
import type { DesignTheme } from '../designTheme';
interface FloatModalContentProps {
theme: DesignTheme;
}
type ModalSize = 'sm' | 'md' | 'lg' | 'full';
const SIZE_CONFIG: Record<ModalSize, { label: string; width: string; desc: string }> = {
sm: { label: 'Small', width: '380px', desc: '입력 폼, 간단한 확인' },
md: { label: 'Medium', width: '520px', desc: '상세 파라미터, 재계산' },
lg: { label: 'Large', width: '720px', desc: '복잡한 폼, 미디어 뷰어' },
full: { label: 'Full', width: '95vw', desc: '매뉴얼, 전체 화면 콘텐츠' },
};
const MODAL_INVENTORY = [
{
component: 'HNSRecalcModal',
zIndex: 'z-[9999]',
trigger: '버튼 클릭',
source: 'tabs/hns/components/',
},
{
component: 'RecalcModal',
zIndex: 'z-[9999]',
trigger: '재계산 버튼',
source: 'tabs/prediction/components/',
},
{
component: 'BacktrackModal',
zIndex: 'z-[9999]',
trigger: '역추적 분석',
source: 'tabs/prediction/components/',
},
{
component: 'MediaModal',
zIndex: 'z-[10000]',
trigger: '미디어 클릭',
source: 'tabs/incidents/components/',
},
{
component: 'SimulationErrorModal',
zIndex: 'z-50 ⚠️',
trigger: '오류 발생',
source: 'tabs/prediction/components/',
},
{
component: 'TemplateFormEditor',
zIndex: 'z-50 ⚠️',
trigger: '템플릿 편집',
source: 'tabs/reports/components/',
},
{
component: 'Admin 모달 (Layer/Map/Perm)',
zIndex: 'z-50 ⚠️',
trigger: '관리 작업',
source: 'tabs/admin/components/',
},
{
component: 'UserManualPopup',
zIndex: 'z-[9999]',
trigger: '도움말 버튼',
source: 'common/components/ui/',
},
];
export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
const t = theme;
const isDark = t.mode === 'dark';
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeSize, setActiveSize] = useState<ModalSize>('md');
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const overlayBg = isDark ? 'rgba(0,0,0,0.65)' : 'rgba(0,0,0,0.45)';
const modalBg = isDark ? '#1b1f2c' : '#ffffff';
const modalBorder = isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0';
const modalWidth = activeSize === 'full' ? '95vw' : SIZE_CONFIG[activeSize].width;
const modalHeight = activeSize === 'full' ? '92vh' : 'auto';
return (
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
{/* ── 개요 ── */}
<div className="flex flex-col gap-3">
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
Modal
</h2>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<code
className="font-mono text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
fixed inset-0
</code>{' '}
. · .
(size) (variant) , <strong>Confirm</strong> Modal의 .
</p>
</div>
{/* ── Live Preview ── */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Live Preview
</h3>
<span
className="font-mono text-caption px-2 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)',
color: '#22c55e',
}}
>
interactive
</span>
</div>
{/* 컨트롤 */}
<div
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
{/* 사이즈 선택 */}
<div className="flex flex-col gap-2">
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
Size Variant
</span>
<div className="flex gap-2 flex-wrap">
{(Object.keys(SIZE_CONFIG) as ModalSize[]).map((size) => (
<button
key={size}
type="button"
onClick={() => setActiveSize(size)}
className="px-3 py-1.5 rounded border border-solid font-mono text-caption transition-colors"
style={{
backgroundColor:
activeSize === size
? isDark
? 'rgba(76,215,246,0.15)'
: 'rgba(6,182,212,0.10)'
: 'transparent',
borderColor: activeSize === size ? t.textAccent : t.cardBorder,
color: activeSize === size ? t.textAccent : t.textMuted,
}}
>
{SIZE_CONFIG[size].label}
<span className="ml-1.5 opacity-60">{SIZE_CONFIG[size].width}</span>
</button>
))}
</div>
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
{SIZE_CONFIG[activeSize].desc}
</span>
</div>
{/* 버튼 그룹 */}
<div className="flex gap-3 flex-wrap">
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="px-4 py-2 rounded border border-solid font-korean text-sm font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.15)' : 'rgba(6,182,212,0.12)',
borderColor: t.textAccent,
color: t.textAccent,
}}
>
</button>
<button
type="button"
onClick={() => setIsConfirmOpen(true)}
className="px-4 py-2 rounded border border-solid font-korean text-sm font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.10)' : 'rgba(239,68,68,0.06)',
borderColor: 'rgba(239,68,68,0.40)',
color: '#ef4444',
}}
>
Confirm ( )
</button>
</div>
</div>
</div>
{/* ── Confirm 서브컴포넌트 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Confirm Modal
</h3>
<div
className="rounded-lg border border-solid p-5 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
Confirm은 <strong>Modal의 variant</strong>. +
+ / 2 . (, ) .
</p>
<div
className="rounded border border-solid px-4 py-3"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.06)' : 'rgba(239,68,68,0.04)',
borderColor: 'rgba(239,68,68,0.20)',
}}
>
<p className="font-mono text-caption" style={{ color: '#ef4444' }}>
window.confirm admin, board 4 OS confirm
ConfirmDialog로
</p>
</div>
<div className="flex flex-col gap-1.5">
{[
{ label: 'variant', value: '"confirm"', desc: 'Modal 컴포넌트의 prop으로 전달' },
{ label: 'title', value: '"항목을 삭제하시겠습니까?"', desc: '액션을 명확히 서술' },
{
label: 'message',
value: '"삭제된 데이터는 복구할 수 없습니다."',
desc: '부가 설명 (선택)',
},
{ label: 'onConfirm', value: '() => handleDelete()', desc: '확인 버튼 콜백' },
].map((row) => (
<div key={row.label} className="flex items-start gap-3">
<span
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
{row.label}
</span>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{row.value}
</span>
<span className="font-korean text-caption ml-auto" style={{ color: t.textMuted }}>
{row.desc}
</span>
</div>
))}
</div>
</div>
</div>
{/* ── Anatomy ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Anatomy
</h3>
<div className="grid grid-cols-2 gap-6">
{/* 구조 다이어그램 */}
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
Structure
</span>
<div
className="rounded flex items-center justify-center p-4"
style={{
backgroundColor: isDark ? 'rgba(0,0,0,0.50)' : 'rgba(0,0,0,0.06)',
minHeight: '200px',
}}
>
<div
className="rounded-lg border border-solid flex flex-col overflow-hidden w-full"
style={{ backgroundColor: modalBg, borderColor: modalBorder }}
>
<div
className="flex items-center justify-between px-3 py-2 border-b border-solid"
style={{ borderColor: modalBorder }}
>
<span className="font-korean text-caption" style={{ color: t.textPrimary }}>
</span>
<div
className="w-4 h-4 rounded flex items-center justify-center"
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.25)' : '#f1f5f9' }}
>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 px-3 py-3">
{[75, 100, 60].map((w, i) => (
<div
key={i}
className="rounded"
style={{
height: '7px',
width: `${w}%`,
backgroundColor: isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0',
}}
/>
))}
</div>
<div
className="flex items-center justify-end gap-2 px-3 py-2 border-t border-solid"
style={{ borderColor: modalBorder }}
>
<div
className="rounded px-2 py-1"
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.20)' : '#f1f5f9' }}
>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
</span>
</div>
<div
className="rounded px-2 py-1"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.12)',
}}
>
<span className="font-korean text-caption" style={{ color: t.textAccent }}>
</span>
</div>
</div>
</div>
</div>
</div>
{/* CSS 클래스 레퍼런스 */}
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
CSS Classes
</span>
<div className="flex flex-col gap-2">
{[
{
cls: '.wing-overlay',
styles: 'fixed inset-0, z-index: 10000',
desc: '백드롭 오버레이',
},
{
cls: '.wing-modal',
styles: 'rounded-xl, bg-surface, border + shadow',
desc: '다이얼로그 컨테이너',
},
{
cls: '.wing-modal-header',
styles: 'flex justify-between, px-5, py-[14px], border-b',
desc: '헤더 (타이틀 + 닫기)',
},
].map((item) => (
<div
key={item.cls}
className="rounded border border-solid px-3 py-2"
style={{ borderColor: t.cardBorder }}
>
<div className="flex items-center gap-2 mb-1">
<span
className="font-mono text-caption rounded px-1.5 py-0.5"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
{item.cls}
</span>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
{item.desc}
</span>
</div>
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
{item.styles}
</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* ── Z-Index 규칙 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Z-Index
</h3>
<div className="flex flex-col gap-2">
{[
{
range: 'z-[9999]',
status: '표준',
desc: '일반 Modal — 표준값, 신규 Modal은 이 값 사용',
color: '#22c55e',
},
{
range: 'z-[10000]',
status: '허용',
desc: '모달 위 모달 (MediaModal, IncidentsView) — 필요 시만',
color: '#eab308',
},
{
range: 'z-50',
status: '비표준 ⚠️',
desc: 'SimulationErrorModal, Admin 모달, TemplateFormEditor — z-[9999]로 통일 필요',
color: '#ef4444',
},
].map((row) => (
<div
key={row.range}
className="flex items-start gap-4 rounded border border-solid px-4 py-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-sm rounded border border-solid px-2 py-0.5 shrink-0"
style={{ color: t.textAccent, borderColor: t.cardBorder }}
>
{row.range}
</span>
<span
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0"
style={{ color: row.color, backgroundColor: `${row.color}15` }}
>
{row.status}
</span>
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
{row.desc}
</span>
</div>
))}
</div>
</div>
{/* ── 인벤토리 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
</h3>
<div
className="rounded-lg border border-solid overflow-hidden"
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
>
<div
className="grid"
style={{
gridTemplateColumns: '1fr 120px 130px 1fr',
backgroundColor: t.tableHeaderBg,
borderBottom: `1px solid ${t.tableRowBorder}`,
}}
>
{['Component', 'Z-Index', 'Trigger', 'Source'].map((col) => (
<div key={col} className="py-2.5 px-4">
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
{col}
</span>
</div>
))}
</div>
{MODAL_INVENTORY.map((item, idx) => (
<div
key={item.component}
className="grid items-center"
style={{
gridTemplateColumns: '1fr 120px 130px 1fr',
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
}}
>
<div className="py-2.5 px-4">
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
{item.component}
</span>
</div>
<div className="py-2.5 px-4">
<span
className="font-mono text-caption rounded border border-solid px-1.5 py-0.5"
style={{
color: item.zIndex.includes('⚠️') ? '#ef4444' : t.textAccent,
borderColor: item.zIndex.includes('⚠️') ? 'rgba(239,68,68,0.30)' : t.cardBorder,
}}
>
{item.zIndex}
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
{item.trigger}
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{item.source}
</span>
</div>
</div>
))}
</div>
</div>
{/* ── 실제 Modal 렌더링 ── */}
{isModalOpen && (
<div
className="fixed inset-0 flex items-center justify-center z-[9999]"
style={{ backgroundColor: overlayBg, backdropFilter: 'blur(4px)' }}
onClick={(e) => {
if (e.target === e.currentTarget) setIsModalOpen(false);
}}
>
<div
className="flex flex-col rounded-xl border border-solid overflow-hidden"
style={{
width: modalWidth,
maxHeight: modalHeight,
backgroundColor: modalBg,
borderColor: modalBorder,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
>
<div
className="flex items-center justify-between px-5 py-4 border-b border-solid shrink-0"
style={{ borderColor: modalBorder }}
>
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
Modal Preview {SIZE_CONFIG[activeSize].label} ({SIZE_CONFIG[activeSize].width})
</span>
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="w-7 h-7 rounded flex items-center justify-center hover:opacity-70 transition-opacity"
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.25)' : '#f1f5f9' }}
>
<span className="font-mono text-sm" style={{ color: t.textMuted }}>
</span>
</button>
</div>
<div className="px-5 py-5 flex flex-col gap-3 overflow-y-auto">
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
{' '}
<code
className="font-mono text-xs px-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
fixed inset-0, z-[9999]
</code>{' '}
. .
</p>
<div className="flex flex-col gap-2">
{['파라미터 입력 필드', '선택 항목', '추가 설정값'].map((label) => (
<div
key={label}
className="rounded border border-solid px-3 py-2.5"
style={{ borderColor: t.cardBorder }}
>
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
{label}
</span>
</div>
))}
</div>
</div>
<div
className="flex items-center justify-end gap-2 px-5 py-4 border-t border-solid shrink-0"
style={{ borderColor: modalBorder }}
>
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 rounded border border-solid font-korean text-sm transition-opacity hover:opacity-70"
style={{ borderColor: t.cardBorder, color: t.textMuted }}
>
</button>
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 rounded font-korean text-sm font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.14)',
color: t.textAccent,
}}
>
</button>
</div>
</div>
</div>
)}
{/* ── 실제 Confirm 렌더링 ── */}
{isConfirmOpen && (
<div
className="fixed inset-0 flex items-center justify-center z-[9999]"
style={{ backgroundColor: overlayBg, backdropFilter: 'blur(4px)' }}
>
<div
className="flex flex-col rounded-xl border border-solid overflow-hidden"
style={{
width: '360px',
backgroundColor: modalBg,
borderColor: modalBorder,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
>
<div
className="flex items-center justify-between px-5 py-4 border-b border-solid"
style={{ borderColor: modalBorder }}
>
<div className="flex items-center gap-2">
<span style={{ color: '#ef4444', fontSize: '16px' }}></span>
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
?
</span>
</div>
</div>
<div className="px-5 py-4">
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
. ?
</p>
</div>
<div
className="flex items-center justify-end gap-2 px-5 py-4 border-t border-solid"
style={{ borderColor: modalBorder }}
>
<button
type="button"
onClick={() => setIsConfirmOpen(false)}
className="px-4 py-2 rounded border border-solid font-korean text-sm transition-opacity hover:opacity-70"
style={{ borderColor: t.cardBorder, color: t.textMuted }}
>
</button>
<button
type="button"
onClick={() => setIsConfirmOpen(false)}
className="px-4 py-2 rounded font-korean text-sm font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.12)',
color: '#ef4444',
}}
>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default FloatModalContent;

파일 보기

@ -0,0 +1,428 @@
// FloatOverlayContent.tsx — Map Overlay + Map Popup 카탈로그
import type { DesignTheme } from '../designTheme';
interface FloatOverlayContentProps {
theme: DesignTheme;
}
const OVERLAY_CASES = [
{
component: 'BacktrackReplayBar',
position: '하단 중앙',
zIndex: 'z-40',
pointerEvents: 'auto',
desc: '역추적 재생 컨트롤 바. 재생/일시정지/슬라이더.',
source: 'common/components/map/BacktrackReplayBar.tsx',
},
{
component: 'MeasureOverlay',
position: '마커 위치',
zIndex: 'z-40',
pointerEvents: 'auto',
desc: '거리 측정 마커 "지우기" 버튼. MapLibre Marker 컴포넌트 활용.',
source: 'common/components/map/MeasureOverlay.tsx',
},
{
component: 'OilDetectionOverlay',
position: 'inset-0 + 우하단 정보',
zIndex: 'z-[15]',
pointerEvents: 'none',
desc: '유류 탐지 결과 마스크 렌더링. OffscreenCanvas 기반. 정보 패널만 클릭 가능.',
source: 'tabs/aerial/components/OilDetectionOverlay.tsx',
},
{
component: 'WeatherMapOverlay',
position: 'absolute inset-0',
zIndex: 'map layer',
pointerEvents: 'none',
desc: '기상 데이터 레이어 오버레이.',
source: 'tabs/weather/components/WeatherMapOverlay.tsx',
},
{
component: 'OceanForecastOverlay',
position: 'absolute inset-0',
zIndex: 'map layer',
pointerEvents: 'none',
desc: '해양 예측 레이어 오버레이.',
source: 'tabs/weather/components/OceanForecastOverlay.tsx',
},
];
export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
const t = theme;
const isDark = t.mode === 'dark';
const mapMockBg = isDark ? '#0f1a2e' : '#c8d8e8';
const mapGridColor = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)';
return (
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
{/* ── 개요 ── */}
<div className="flex flex-col gap-3">
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
Overlay
</h2>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<code
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
position: absolute
</code>
UI. UI를 . Modal과
, .
</p>
</div>
{/* ── Overlay vs Modal 비교 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Overlay vs Modal
</h3>
<div
className="rounded-lg border border-solid overflow-hidden"
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
>
<div
className="grid"
style={{
gridTemplateColumns: '160px 1fr 1fr',
backgroundColor: t.tableHeaderBg,
borderBottom: `1px solid ${t.tableRowBorder}`,
}}
>
{['속성', 'Overlay', 'Modal'].map((col) => (
<div key={col} className="py-2.5 px-4">
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
{col}
</span>
</div>
))}
</div>
{[
{ attr: 'position', overlay: 'absolute (지도 기준)', modal: 'fixed (뷰포트 기준)' },
{ attr: '백드롭', overlay: '없음', modal: 'rgba(0,0,0,0.65) + blur' },
{ attr: '클릭 차단', overlay: 'pointer-events: none (일반)', modal: '전체 화면 차단' },
{ attr: 'z-index', overlay: 'z-40 (지도 UI 위)', modal: 'z-[9999] (최상위)' },
{ attr: '크기 기준', overlay: '지도 컨테이너 = 100%', modal: '고정 너비 (380~720px)' },
{
attr: '닫기 방식',
overlay: '기능 비활성화 시 사라짐',
modal: '닫기 버튼 / 백드롭 클릭',
},
].map((row, idx) => (
<div
key={row.attr}
className="grid items-center"
style={{
gridTemplateColumns: '160px 1fr 1fr',
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
}}
>
<div className="py-2.5 px-4">
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{row.attr}
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
{row.overlay}
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
{row.modal}
</span>
</div>
</div>
))}
</div>
</div>
{/* ── 지도 목업 다이어그램 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Overlay
</h3>
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
</span>
{/* 지도 목업 */}
<div
className="relative rounded overflow-hidden"
style={{ backgroundColor: mapMockBg, minHeight: '280px' }}
>
{/* 격자 배경 (지도 모사) */}
<div
className="absolute inset-0"
style={{
backgroundImage: `linear-gradient(${mapGridColor} 1px, transparent 1px), linear-gradient(90deg, ${mapGridColor} 1px, transparent 1px)`,
backgroundSize: '40px 40px',
}}
/>
{/* 지도 레이블 */}
<div className="absolute top-3 left-3">
<span
className="font-mono text-caption"
style={{ color: isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)' }}
>
MapView (position: relative)
</span>
</div>
{/* OilDetectionOverlay — 전체 영역 */}
<div
className="absolute inset-0 pointer-events-none"
style={{ border: `1.5px dashed rgba(6,182,212,0.35)`, borderRadius: '4px' }}
>
<div className="absolute top-10 left-3">
<span
className="font-mono text-caption rounded px-1.5 py-0.5"
style={{ backgroundColor: 'rgba(6,182,212,0.15)', color: t.textAccent }}
>
OilDetectionOverlay inset-0, z-[15], pointer-events:none
</span>
</div>
</div>
{/* MeasureOverlay 마커 */}
<div className="absolute" style={{ top: '80px', left: '120px' }}>
<div
className="rounded-full w-3 h-3 border-2 border-solid"
style={{ backgroundColor: '#ef4444', borderColor: '#ffffff' }}
/>
<div
className="rounded border border-solid px-2 py-0.5 mt-1"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.85)' : 'rgba(239,68,68,0.90)',
borderColor: 'transparent',
}}
>
<span className="font-korean text-caption" style={{ color: '#ffffff' }}>
</span>
</div>
<div className="absolute -top-4 left-8">
<span
className="font-mono text-caption rounded px-1 py-0.5"
style={{
backgroundColor: 'rgba(239,68,68,0.15)',
color: '#ef4444',
whiteSpace: 'nowrap',
}}
>
MeasureOverlay Marker
</span>
</div>
</div>
{/* BacktrackReplayBar — 하단 중앙 */}
<div
className="absolute bottom-3 left-1/2 rounded border border-solid px-4 py-2 flex items-center gap-3"
style={{
transform: 'translateX(-50%)',
backgroundColor: isDark ? 'rgba(23,27,40,0.92)' : 'rgba(255,255,255,0.92)',
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
backdropFilter: 'blur(6px)',
}}
>
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
</span>
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
</span>
<div
className="w-24 h-1 rounded"
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0' }}
>
<div className="h-1 w-10 rounded" style={{ backgroundColor: t.textAccent }} />
</div>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
</span>
</div>
<div className="absolute bottom-14 left-1/2" style={{ transform: 'translateX(-50%)' }}>
<span
className="font-mono text-caption rounded px-1.5 py-0.5"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.12)' : 'rgba(6,182,212,0.10)',
color: t.textAccent,
whiteSpace: 'nowrap',
}}
>
BacktrackReplayBar bottom-3 center, z-40
</span>
</div>
</div>
</div>
</div>
{/* ── Map Popup 서브패턴 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Map Popup
</h3>
<div
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<strong>ScatPopup</strong> . Modal(fixed
) , ·
.
</p>
<div className="grid grid-cols-3 gap-3">
{[
{
label: '위치 계산',
value: 'map.project(lngLat)',
desc: '지도 좌표 → 픽셀 좌표 변환',
},
{ label: '위치 업데이트', value: 'map.on("move")', desc: '패닝/줌 시 재계산' },
{ label: 'z-index', value: 'z-[9999]', desc: '다른 오버레이 위' },
].map((item) => (
<div
key={item.label}
className="rounded border border-solid px-3 py-2.5 flex flex-col gap-1"
style={{ borderColor: t.cardBorder }}
>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{item.label}
</span>
<span className="font-mono text-xs" style={{ color: t.textAccent }}>
{item.value}
</span>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
{item.desc}
</span>
</div>
))}
</div>
<div
className="rounded border border-solid px-3 py-2"
style={{
backgroundColor: isDark ? 'rgba(234,179,8,0.06)' : 'rgba(234,179,8,0.04)',
borderColor: 'rgba(234,179,8,0.25)',
}}
>
<span className="font-korean text-xs" style={{ color: '#eab308' }}>
주의: ScatPopup은 MapLibre GL JS의 Popup/Marker React DOM으로 .
position: absolute로 .
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
Source:
</span>
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
tabs/scat/components/ScatPopup.tsx
</span>
</div>
</div>
</div>
{/* ── 사용 사례 목록 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
</h3>
<div
className="rounded-lg border border-solid overflow-hidden"
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
>
<div
className="grid"
style={{
gridTemplateColumns: '200px 120px 80px 100px 1fr',
backgroundColor: t.tableHeaderBg,
borderBottom: `1px solid ${t.tableRowBorder}`,
}}
>
{['Component', 'Position', 'Z-Index', 'Events', 'Description'].map((col) => (
<div key={col} className="py-2.5 px-3">
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
{col}
</span>
</div>
))}
</div>
{OVERLAY_CASES.map((item, idx) => (
<div
key={item.component}
className="grid items-start"
style={{
gridTemplateColumns: '200px 120px 80px 100px 1fr',
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
}}
>
<div className="py-2.5 px-3">
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
{item.component}
</span>
</div>
<div className="py-2.5 px-3">
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
{item.position}
</span>
</div>
<div className="py-2.5 px-3">
<span
className="font-mono text-caption rounded border border-solid px-1.5 py-0.5"
style={{ color: t.textAccent, borderColor: t.cardBorder }}
>
{item.zIndex}
</span>
</div>
<div className="py-2.5 px-3">
<span
className="font-mono text-caption rounded px-1.5 py-0.5"
style={{
color: item.pointerEvents === 'none' ? t.textMuted : '#22c55e',
backgroundColor:
item.pointerEvents === 'none'
? isDark
? 'rgba(140,144,159,0.10)'
: 'rgba(148,163,184,0.10)'
: isDark
? 'rgba(34,197,94,0.10)'
: 'rgba(34,197,94,0.08)',
}}
>
{item.pointerEvents}
</span>
</div>
<div className="py-2.5 px-3">
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
{item.desc}
</span>
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default FloatOverlayContent;

파일 보기

@ -0,0 +1,427 @@
// FloatToastContent.tsx — Toast 컴포넌트 카탈로그
import { useState, useEffect } from 'react';
import type { DesignTheme } from '../designTheme';
interface FloatToastContentProps {
theme: DesignTheme;
}
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface ToastItem {
id: number;
type: ToastType;
message: string;
progress: number;
}
const TOAST_CONFIG: Record<ToastType, { color: string; bg: string; icon: string; label: string }> =
{
success: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', icon: '✓', label: 'Success' },
error: { color: '#ef4444', bg: 'rgba(239,68,68,0.12)', icon: '✕', label: 'Error' },
info: { color: '#06b6d4', bg: 'rgba(6,182,212,0.12)', icon: '', label: 'Info' },
warning: { color: '#eab308', bg: 'rgba(234,179,8,0.12)', icon: '⚠', label: 'Warning' },
};
const DEMO_MESSAGES: Record<ToastType, string> = {
success: '저장이 완료되었습니다.',
error: '요청 처리 중 오류가 발생했습니다.',
info: '시뮬레이션이 시작되었습니다.',
warning: '미저장 변경사항이 있습니다.',
};
const TOAST_DURATION = 3000;
let toastIdCounter = 0;
export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
const t = theme;
const isDark = t.mode === 'dark';
const [toasts, setToasts] = useState<ToastItem[]>([]);
const addToast = (type: ToastType) => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, type, message: DEMO_MESSAGES[type], progress: 100 }]);
};
const removeToast = (id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
useEffect(() => {
if (toasts.length === 0) return;
const interval = setInterval(() => {
setToasts((prev) =>
prev
.map((toast) => ({ ...toast, progress: toast.progress - 100 / (TOAST_DURATION / 100) }))
.filter((toast) => toast.progress > 0),
);
}, 100);
return () => clearInterval(interval);
}, [toasts.length]);
const toastBg = isDark ? '#1b1f2c' : '#ffffff';
const toastBorder = isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0';
return (
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
{/* ── 개요 ── */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
Toast
</h2>
<span
className="font-mono text-caption rounded px-2 py-0.5"
style={{
backgroundColor: isDark ? 'rgba(234,179,8,0.10)' : 'rgba(234,179,8,0.08)',
color: '#eab308',
}}
>
</span>
</div>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
.
<code
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
fixed bottom-right
</code>
. {' '}
<code
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
color: '#ef4444',
}}
>
window.alert
</code>
console.log로 .
</p>
</div>
{/* ── Live Preview ── */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Live Preview
</h3>
<span
className="font-mono text-caption px-2 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)',
color: '#22c55e',
}}
>
interactive
</span>
</div>
<div
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
Toast가 . 3 .
</p>
<div className="flex gap-2 flex-wrap">
{(Object.keys(TOAST_CONFIG) as ToastType[]).map((type) => {
const cfg = TOAST_CONFIG[type];
return (
<button
key={type}
type="button"
onClick={() => addToast(type)}
className="px-4 py-2 rounded border border-solid font-mono text-caption font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: cfg.bg,
borderColor: `${cfg.color}40`,
color: cfg.color,
}}
>
{cfg.icon} {cfg.label}
</button>
);
})}
</div>
{toasts.length > 0 && (
<p className="font-mono text-caption" style={{ color: t.textMuted }}>
Toast: {toasts.length}
</p>
)}
</div>
</div>
{/* ── Anatomy ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Anatomy
</h3>
<div className="grid grid-cols-2 gap-6">
{/* 구조 목업 */}
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
Structure
</span>
<div
className="rounded relative flex items-end justify-end"
style={{
backgroundColor: isDark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.04)',
padding: '16px',
minHeight: '160px',
}}
>
{/* 성공 Toast 목업 */}
<div className="flex flex-col gap-1.5 w-full max-w-[220px]">
{(['success', 'info', 'error'] as ToastType[]).map((type, i) => {
const cfg = TOAST_CONFIG[type];
return (
<div
key={type}
className="rounded border border-solid flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: toastBg,
borderColor: toastBorder,
borderLeft: `3px solid ${cfg.color}`,
opacity: 1 - i * 0.2,
}}
>
<span
className="font-mono text-caption shrink-0"
style={{ color: cfg.color }}
>
{cfg.icon}
</span>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
{DEMO_MESSAGES[type].slice(0, 14)}
</span>
</div>
);
})}
</div>
</div>
</div>
{/* 위치 규칙 */}
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
Position Rules
</span>
<div className="flex flex-col gap-2">
{[
{ label: 'position', value: 'fixed', desc: '뷰포트 기준 고정' },
{ label: 'bottom', value: '24px', desc: '화면 하단에서 24px' },
{ label: 'right', value: '24px', desc: '화면 우측에서 24px' },
{ label: 'z-index', value: 'z-60', desc: '콘텐츠 위, Modal(9999) 아래' },
{ label: 'width', value: '320px (고정)', desc: '일정한 너비 유지' },
{ label: 'gap', value: '8px (스택)', desc: '복수 Toast 간격' },
].map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0 w-24 text-right"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
{item.label}
</span>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{item.value}
</span>
<span className="font-korean text-caption ml-auto" style={{ color: t.textMuted }}>
{item.desc}
</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* ── 타입별 색상 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
</h3>
<div className="grid grid-cols-4 gap-3">
{(Object.entries(TOAST_CONFIG) as [ToastType, (typeof TOAST_CONFIG)[ToastType]][]).map(
([type, cfg]) => (
<div
key={type}
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{
backgroundColor: t.cardBg,
borderColor: t.cardBorder,
borderLeft: `3px solid ${cfg.color}`,
}}
>
<div className="flex items-center gap-2">
<span className="font-mono text-lg" style={{ color: cfg.color }}>
{cfg.icon}
</span>
<span className="font-mono text-sm font-bold" style={{ color: cfg.color }}>
{cfg.label}
</span>
</div>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{cfg.color}
</span>
<span
className="font-korean text-caption leading-5"
style={{ color: t.textSecondary }}
>
{type === 'success' && '저장 완료, 복사 완료, 전송 성공'}
{type === 'error' && 'API 오류, 저장 실패, 권한 없음'}
{type === 'info' && '작업 시작, 업데이트 알림'}
{type === 'warning' && '미저장 변경, 만료 임박'}
</span>
</div>
),
)}
</div>
</div>
{/* ── 구현 패턴 제안 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
useToast Hook
</h3>
<div
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
Toast는 <strong>Zustand store + useToast hook</strong>{' '}
. ToastContainer는 App.tsx .
</p>
<div className="flex flex-col gap-3">
{[
{
title: 'toastStore.ts',
code: 'const useToastStore = create<ToastStore>()\naddToast(type, message, duration?)\nremoveToast(id)',
desc: 'Zustand store — Toast 큐 관리',
},
{
title: 'useToast.ts',
code: 'const { success, error, info, warning } = useToast()\nsuccess("저장 완료") // duration 기본값 3000ms',
desc: '컴포넌트에서 호출하는 hook',
},
{
title: 'ToastContainer.tsx',
code: '<div className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2">\n {toasts.map(t => <ToastItem key={t.id} {...t} />)}\n</div>',
desc: 'App.tsx 최상위에 배치',
},
].map((item) => (
<div
key={item.title}
className="rounded border border-solid p-3 flex flex-col gap-1.5"
style={{ borderColor: t.cardBorder }}
>
<div className="flex items-center justify-between">
<span
className="font-mono text-caption font-bold"
style={{ color: t.textPrimary }}
>
{item.title}
</span>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
{item.desc}
</span>
</div>
<pre
className="font-mono text-caption leading-5 rounded p-2 overflow-x-auto"
style={{
backgroundColor: isDark ? 'rgba(0,0,0,0.30)' : 'rgba(0,0,0,0.04)',
color: t.textSecondary,
}}
>
{item.code}
</pre>
</div>
))}
</div>
</div>
</div>
{/* ── 실제 Toast 렌더링 (fixed 위치) ── */}
{toasts.length > 0 && (
<div
className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2"
style={{ width: '300px' }}
>
{toasts.map((toast) => {
const cfg = TOAST_CONFIG[toast.type];
return (
<div
key={toast.id}
className="rounded border border-solid flex flex-col overflow-hidden"
style={{
backgroundColor: toastBg,
borderColor: toastBorder,
borderLeft: `3px solid ${cfg.color}`,
boxShadow: '0 4px 16px rgba(0,0,0,0.30)',
}}
>
<div className="flex items-center gap-2.5 px-3 py-2.5">
<span className="font-mono text-base shrink-0" style={{ color: cfg.color }}>
{cfg.icon}
</span>
<span className="font-korean text-sm flex-1" style={{ color: t.textPrimary }}>
{toast.message}
</span>
<button
type="button"
onClick={() => removeToast(toast.id)}
className="w-5 h-5 rounded flex items-center justify-center shrink-0 hover:opacity-70 transition-opacity"
style={{ color: t.textMuted }}
>
<span className="font-mono text-caption"></span>
</button>
</div>
{/* Progress bar */}
<div
style={{
height: '2px',
backgroundColor: isDark ? 'rgba(66,71,84,0.30)' : '#f1f5f9',
}}
>
<div
style={{
height: '2px',
width: `${toast.progress}%`,
backgroundColor: cfg.color,
transition: 'width 0.1s linear',
}}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default FloatToastContent;

파일 보기

@ -7,7 +7,7 @@ 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-fg-sub font-semibold">{label}</div>
<div className="text-[11px] font-korean text-fg-disabled"> .</div>
<div className="text-label-2 font-korean text-fg-disabled"> .</div>
</div>
);

파일 보기

@ -37,7 +37,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
<button
key={item.id}
onClick={() => onSelect(item.id)}
className="w-full text-left px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
className="w-full text-left px-3 py-1.5 text-label-2 font-korean transition-colors cursor-pointer rounded-[3px]"
style={{
paddingLeft: `${12 + depth * 14}px`,
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
@ -65,7 +65,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
if (firstLeaf) onSelect(firstLeaf.id);
}
}}
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
className="w-full flex items-center justify-between px-3 py-1.5 text-label-2 font-korean transition-colors cursor-pointer rounded-[3px]"
style={{
paddingLeft: `${12 + depth * 14}px`,
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-sub)',
@ -74,7 +74,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
>
<span>{item.label}</span>
<span
className="text-[9px] text-fg-disabled transition-transform"
className="text-caption text-fg-disabled transition-transform"
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
>
@ -123,7 +123,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
{/* 섹션 헤더 */}
<button
onClick={() => toggle(section.id)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-[11px] font-bold font-korean transition-colors cursor-pointer"
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-label-2 font-bold font-korean transition-colors cursor-pointer"
style={{
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
@ -132,7 +132,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
<span className="text-sm">{section.icon}</span>
<span className="flex-1 text-left">{section.label}</span>
<span
className="text-[9px] text-fg-disabled transition-transform"
className="text-caption text-fg-disabled transition-transform"
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
>

파일 보기

@ -130,7 +130,7 @@ function AssetUploadPanel() {
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
dragging
? 'border-color-accent bg-[rgba(6,182,212,0.05)]'
: 'border-stroke hover:border-color-accent/50 bg-bg-elevated'
: 'border-stroke hover:border-[rgba(6,182,212,0.5)] bg-bg-elevated'
}`}
>
<div className="text-3xl mb-2 opacity-40">📁</div>
@ -143,7 +143,7 @@ function AssetUploadPanel() {
<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">
<div className="text-caption text-fg-disabled font-korean mb-3">
(.xlsx), CSV · 10MB
</div>
<button
@ -170,7 +170,7 @@ function AssetUploadPanel() {
{/* 자산 분류 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<select
@ -189,7 +189,7 @@ function AssetUploadPanel() {
{/* 대상 관할 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<select
@ -208,7 +208,7 @@ function AssetUploadPanel() {
{/* 업로드 방식 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<div className="flex gap-4">
@ -271,7 +271,7 @@ function AssetUploadPanel() {
</div>
<div>
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
<div className="text-[10px] text-fg-disabled font-korean mt-0.5">
<div className="text-caption text-fg-disabled font-korean mt-0.5">
{p.desc}
</div>
</div>
@ -287,7 +287,7 @@ function AssetUploadPanel() {
</div>
<div className="px-5 py-4 space-y-2">
{uploadHistory.length === 0 ? (
<div className="text-[11px] text-fg-disabled font-korean text-center py-4">
<div className="text-label-2 text-fg-disabled font-korean text-center py-4">
.
</div>
) : (
@ -298,12 +298,12 @@ function AssetUploadPanel() {
>
<div>
<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">
<div className="text-caption 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
className="px-2 py-0.5 rounded-full text-caption font-semibold
bg-[rgba(34,197,94,0.15)] text-color-success flex-shrink-0"
>

파일 보기

@ -273,7 +273,7 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
<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 ${
className={`inline-block px-2 py-0.5 rounded-full text-caption font-medium ${
post.categoryCd === 'NOTICE'
? 'bg-red-500/15 text-red-400'
: post.categoryCd === 'QNA'
@ -285,7 +285,7 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
</span>
</td>
<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.pinnedYn === 'Y' && <span className="text-caption text-orange-400 mr-1">[]</span>}
{post.title}
</td>
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>

파일 보기

@ -167,47 +167,47 @@ function CleanupEquipPanel() {
<table className="w-full">
<thead>
<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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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'}`}
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '방제선' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : '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'}`}
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '유회수기' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : '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'}`}
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : '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'}`}
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '방제차량' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : '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'}`}
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '살포장치' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
>
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean">
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean">
</th>
</tr>
@ -228,51 +228,51 @@ function CleanupEquipPanel() {
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">
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono text-center">
{(safePage - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3">
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
className={`text-caption px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
>
{org.type}
</span>
</td>
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
<td className="px-4 py-3 text-label-2 text-fg-sub font-korean">
{regionShort(org.jurisdiction)}
</td>
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
<td className="px-4 py-3 text-label-2 text-fg font-korean font-semibold">
{org.name}
</td>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
<td className="px-4 py-3 text-label-2 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-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '방제선' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '유회수기' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '이송펌프' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '방제차량' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '살포장치' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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-color-accent">
<td className="px-4 py-3 text-label-2 font-mono text-center font-bold text-color-accent">
{org.totalAssets.toLocaleString()}
</td>
</tr>
@ -286,7 +286,7 @@ function CleanupEquipPanel() {
{/* 합계 */}
{!loading && filtered.length > 0 && (
<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">
<span className="text-caption text-fg-disabled font-korean font-semibold mr-auto">
({filtered.length} )
</span>
{[
@ -301,15 +301,15 @@ function CleanupEquipPanel() {
return (
<div
key={t.label}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-color-accent/10' : ''}`}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-[rgba(6,182,212,0.1)]' : ''}`}
>
<span
className={`text-[9px] font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}
className={`text-caption 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'}`}
className={`text-caption font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}
>
{t.value.toLocaleString()}
{t.unit}
@ -323,7 +323,7 @@ function CleanupEquipPanel() {
{/* 페이지네이션 */}
{!loading && filtered.length > 0 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
<span className="text-[11px] text-fg-disabled font-korean">
<span className="text-label-2 text-fg-disabled font-korean">
{(safePage - 1) * PAGE_SIZE + 1}{Math.min(safePage * PAGE_SIZE, filtered.length)} /
{filtered.length}
</span>
@ -331,7 +331,7 @@ function CleanupEquipPanel() {
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safePage === 1}
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"
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
>
&lt;
</button>
@ -339,7 +339,7 @@ function CleanupEquipPanel() {
<button
key={p}
onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
style={
p === safePage
? {
@ -356,7 +356,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-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
>
&gt;
</button>

파일 보기

@ -256,7 +256,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 ${
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${
row.activeYn === 'Y'
? 'text-emerald-400 bg-emerald-500/10'
: 'text-t3 bg-bg-elevated'
@ -269,7 +269,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.clctDate ?? '-'}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-2 py-0.5 rounded text-[11px] font-medium ${status.color}`}
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${status.color}`}
>
{status.label}
</span>

파일 보기

@ -149,7 +149,7 @@ const DispersingZonePanel = () => {
/>
</button>
{/* 펼침 화살표 */}
<span className="text-fg-disabled text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
<span className="text-fg-disabled text-caption shrink-0">{isExpanded ? '▲' : '▼'}</span>
</div>
{/* 펼침 영역 */}
@ -159,10 +159,10 @@ const DispersingZonePanel = () => {
<tbody>
{info.rows.map((row) => (
<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">
<td className="py-2 pr-2 text-label-2 text-fg-disabled font-korean whitespace-nowrap align-top w-24">
{row.key}
</td>
<td className="py-2 text-[11px] text-fg-sub font-korean leading-relaxed">
<td className="py-2 text-label-2 text-fg-sub font-korean leading-relaxed">
{row.value}
</td>
</tr>
@ -196,11 +196,11 @@ const DispersingZonePanel = () => {
<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-fg-sub font-korean"></span>
<span className="text-label-2 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-fg-sub font-korean"></span>
<span className="text-label-2 text-fg-sub font-korean"></span>
</div>
</div>
</div>
@ -210,7 +210,7 @@ const DispersingZonePanel = () => {
{/* 헤더 */}
<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>
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean"> </p>
</div>
{/* 구역 카드 목록 */}

파일 보기

@ -187,7 +187,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
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';
const labelCls = 'block text-label-2 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">
@ -321,7 +321,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 에러 */}
{formError && (
<div className="px-6 pb-2">
<p className="text-[11px] text-red-400 font-korean">{formError}</p>
<p className="text-label-2 text-red-400 font-korean">{formError}</p>
</div>
)}
{/* 버튼 */}
@ -502,34 +502,34 @@ const LayerPanel = () => {
<table className="w-full">
<thead>
<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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-center text-label-2 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">
<th className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-center text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-center text-label-2 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 className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-28 whitespace-nowrap">
</th>
</tr>
@ -555,7 +555,7 @@ const LayerPanel = () => {
{(page - 1) * PAGE_SIZE + idx + 1}
</td>
{/* 레이어코드 */}
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">{item.layerCd}</td>
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
{/* 레이어명 */}
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
{/* 레이어전체명 */}
@ -566,12 +566,12 @@ const LayerPanel = () => {
</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-color-accent border border-[rgba(6,182,212,0.3)]">
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-caption 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-fg-sub font-mono">
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
</td>
{/* 정렬순서 */}
@ -579,7 +579,7 @@ const LayerPanel = () => {
{item.sortOrd}
</td>
{/* 등록일시 */}
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
{item.regDtm ?? '-'}
</td>
{/* 사용여부 토글 */}
@ -598,7 +598,7 @@ const LayerPanel = () => {
item.useYn === 'Y' && item.parentUseYn !== 'N'
? 'bg-color-accent'
: item.useYn === 'Y' && item.parentUseYn === 'N'
? 'bg-color-accent/40'
? 'bg-[rgba(6,182,212,0.4)]'
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
}`}
>
@ -637,27 +637,27 @@ const LayerPanel = () => {
{/* 페이지네이션 */}
{!loading && totalPages > 1 && (
<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">
<span className="text-label-2 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-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
className="px-2.5 py-1 text-label-2 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-fg-disabled">
<span key={`e${i}`} className="px-1.5 text-label-2 text-fg-disabled">
</span>
) : (
<button
key={btn}
onClick={() => setPage(btn)}
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
className={`px-2.5 py-1 text-label-2 rounded transition-all ${
page === btn
? '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)]'
@ -670,7 +670,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-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>

파일 보기

@ -100,7 +100,7 @@ function MapBaseModal({
<div className="px-6 py-4 space-y-4">
{/* 지도 이름 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
@ -114,7 +114,7 @@ function MapBaseModal({
{/* 지도 키 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
@ -129,7 +129,7 @@ function MapBaseModal({
{/* 지도 레벨 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<select
@ -148,7 +148,7 @@ function MapBaseModal({
{/* 파일 소스 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<input
@ -162,7 +162,7 @@ function MapBaseModal({
{/* 상세 설명 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<textarea
@ -176,7 +176,7 @@ function MapBaseModal({
{/* 사용여부 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<div className="flex items-center gap-3">
@ -200,7 +200,7 @@ function MapBaseModal({
</div>
{/* 에러 */}
{modalError && <p className="text-[11px] text-red-400 font-korean">{modalError}</p>}
{modalError && <p className="text-label-2 text-red-400 font-korean">{modalError}</p>}
</div>
{/* 모달 푸터 */}
@ -363,7 +363,7 @@ function MapBasePanel() {
{/* 메시지 */}
{message && (
<div
className={`mx-6 mt-2 px-3 py-2 text-[11px] rounded-md font-korean ${
className={`mx-6 mt-2 px-3 py-2 text-label-2 rounded-md font-korean ${
message.type === 'success'
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
@ -404,7 +404,7 @@ function MapBasePanel() {
<td className="py-3 text-center text-fg-disabled">{(page - 1) * 10 + idx + 1}</td>
<td className="py-3 pl-4">
<span className="text-fg font-korean">{item.mapNm}</span>
<span className="ml-2 text-[10px] text-fg-disabled font-mono">
<span className="ml-2 text-caption text-fg-disabled font-mono">
{item.mapKey}
</span>
</td>

파일 보기

@ -189,8 +189,8 @@ function MenusPanel() {
{activeMenu ? (
<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-fg font-korean">
<span className="text-title-2">{activeMenu.icon}</span>
<span className="text-title-4 font-semibold text-fg font-korean">
{activeMenu.label}
</span>
</div>

파일 보기

@ -342,19 +342,19 @@ function ConnectionBadge({
if (isNormal) {
return (
<div className="flex flex-col items-start gap-0.5">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold bg-blue-600 text-white">
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-blue-600 text-white">
ON
</span>
{lastMessageTime && <span className="text-[10px] text-t3">{lastMessageTime}</span>}
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
</div>
);
}
return (
<div className="flex flex-col items-start gap-0.5">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold bg-orange-500 text-white">
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-orange-500 text-white">
OFF
</span>
{lastMessageTime && <span className="text-[10px] text-t3">{lastMessageTime}</span>}
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
</div>
);
}

파일 보기

@ -133,7 +133,7 @@ function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) {
const isDisabled = state === 'forced-denied' || readOnly;
const baseClasses =
'w-5 h-5 rounded border text-[10px] font-bold transition-all flex items-center justify-center';
'w-5 h-5 rounded border text-caption font-bold transition-all flex items-center justify-center';
let classes: string;
let icon: string;
@ -240,14 +240,14 @@ function TreeRow({
</svg>
</button>
) : (
<span className="w-4 mr-1 flex-shrink-0 text-center text-fg-disabled text-[9px]">
<span className="w-4 mr-1 flex-shrink-0 text-center text-fg-disabled text-caption">
{node.level > 0 ? '├' : ''}
</span>
)}
{node.icon && <span className="mr-1 flex-shrink-0 text-[11px]">{node.icon}</span>}
{node.icon && <span className="mr-1 flex-shrink-0 text-label-2">{node.icon}</span>}
<div className="min-w-0">
<div
className={`text-[11px] font-korean truncate ${node.level === 0 ? 'font-bold text-fg' : 'font-medium text-fg-sub'}`}
className={`text-label-2 font-korean truncate ${node.level === 0 ? 'font-bold text-fg' : 'font-medium text-fg-sub'}`}
>
{node.name}
</div>
@ -295,29 +295,29 @@ function TreeRow({
function PermLegend() {
return (
<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"
className="flex items-center gap-3 px-4 py-1.5 border-b border-stroke bg-bg-surface text-caption 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-color-accent text-color-accent text-center text-[8px] leading-3">
<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-caption leading-3">
</span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-[8px] leading-3">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-caption leading-3">
</span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-[8px] leading-3">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-caption leading-3">
</span>
</span>
<span className="flex items-center gap-1">
<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 className="inline-block w-3 h-3 rounded border bg-bg-elevated border-stroke text-fg-disabled opacity-40 text-center text-caption leading-3">
</span>
@ -418,14 +418,14 @@ function RolePermTab({
setShowCreateForm(true);
setCreateError('');
}}
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"
className="px-3 py-1.5 text-label-2 font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
+
</button>
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all font-korean ${
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all font-korean ${
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'
@ -434,7 +434,7 @@ function RolePermTab({
{saving ? '저장 중...' : '변경사항 저장'}
</button>
{saveError && (
<span className="text-[11px] text-color-danger font-korean">{saveError}</span>
<span className="text-label-2 text-color-danger font-korean">{saveError}</span>
)}
</div>
@ -450,7 +450,7 @@ function RolePermTab({
<div key={role.sn} className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={() => setSelectedRoleSn(role.sn)}
className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all font-korean ${
className={`px-2.5 py-1 text-label-2 font-semibold rounded-md transition-all font-korean ${
isSelected
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
: 'border border-stroke text-fg-disabled hover:border-stroke'
@ -469,19 +469,21 @@ function RolePermTab({
onBlur={() => handleSaveRoleName(role.sn)}
onClick={(e) => e.stopPropagation()}
autoFocus
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"
className="w-20 px-1 py-0 text-label-2 font-semibold bg-bg-elevated border border-color-accent rounded text-center text-fg focus:outline-none font-korean"
/>
) : (
<span onDoubleClick={() => handleStartEditName(role)}>{role.name}</span>
)}
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
{role.isDefault && <span className="ml-1 text-[9px] text-color-accent"></span>}
<span className="ml-1 text-caption font-mono opacity-50">{role.code}</span>
{role.isDefault && (
<span className="ml-1 text-caption text-color-accent"></span>
)}
</button>
{isSelected && (
<div className="flex items-center gap-0.5">
<button
onClick={() => toggleDefault(role.sn)}
className={`px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
className={`px-1.5 py-0.5 text-caption rounded transition-all font-korean ${
role.isDefault
? 'bg-[rgba(6,182,212,0.15)] text-color-accent'
: 'text-fg-disabled hover:text-fg-sub'
@ -525,13 +527,15 @@ function RolePermTab({
<table className="w-full">
<thead>
<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 className="px-3 py-1.5 text-left text-caption 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-fg-sub">{OPER_LABELS[oper]}</div>
<div className="text-[8px] text-fg-disabled font-korean">
<div className="text-caption font-semibold text-fg-sub">
{OPER_LABELS[oper]}
</div>
<div className="text-caption text-fg-disabled font-korean">
{OPER_FULL_LABELS[oper]}
</div>
</th>
@ -567,7 +571,7 @@ function RolePermTab({
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<label className="text-[11px] text-fg-disabled font-korean block mb-1">
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
</label>
<input
@ -579,12 +583,12 @@ function RolePermTab({
placeholder="CUSTOM_ROLE"
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-fg-disabled mt-1 font-korean">
<p className="text-caption text-fg-disabled mt-1 font-korean">
, , ( )
</p>
</div>
<div>
<label className="text-[11px] text-fg-disabled font-korean block mb-1">
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
</label>
<input
@ -596,7 +600,7 @@ function RolePermTab({
/>
</div>
<div>
<label className="text-[11px] text-fg-disabled font-korean block mb-1">
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
()
</label>
<input
@ -608,7 +612,7 @@ function RolePermTab({
/>
</div>
{createError && (
<div className="px-3 py-2 text-[11px] text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
<div className="px-3 py-2 text-label-2 text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
{createError}
</div>
)}
@ -792,7 +796,9 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
<div className="flex flex-col flex-1 min-h-0">
{/* 사용자 검색/선택 */}
<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>
<label className="text-caption text-fg-disabled font-korean block mb-1.5">
</label>
<div className="relative" ref={dropdownRef}>
<input
type="text"
@ -823,17 +829,17 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
<div className="text-xs font-semibold text-fg font-korean truncate">
{user.name}
{user.rank && (
<span className="ml-1 text-[10px] text-fg-disabled font-korean">
<span className="ml-1 text-caption text-fg-disabled font-korean">
{user.rank}
</span>
)}
</div>
<div className="text-[10px] text-fg-disabled font-mono truncate">
<div className="text-caption text-fg-disabled font-mono truncate">
{user.account}
</div>
</div>
{user.orgName && (
<span className="text-[10px] text-fg-disabled font-korean flex-shrink-0 truncate max-w-[100px]">
<span className="text-caption text-fg-disabled font-korean flex-shrink-0 truncate max-w-[100px]">
{user.orgName}
</span>
)}
@ -857,11 +863,11 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
style={{ flexShrink: 0 }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-fg-sub font-korean"> </span>
<span className="text-caption 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 ${
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all font-korean ${
rolesDirty
? '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'
@ -878,7 +884,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
<label
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',
'flex items-center gap-1 px-2 py-1 rounded-md border cursor-pointer transition-all font-korean text-label-2 select-none',
isChecked ? '' : 'border-stroke text-fg-disabled hover:border-text-2',
].join(' ')}
style={
@ -894,7 +900,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
className="w-3 h-3 accent-primary-cyan"
/>
<span>{role.name}</span>
<span className="text-[9px] font-mono opacity-60">{role.code}</span>
<span className="text-caption font-mono opacity-60">{role.code}</span>
</label>
);
})}
@ -903,7 +909,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
{/* 유효 권한 매트릭스 (읽기 전용) */}
<div
className="px-4 py-1.5 border-b border-stroke bg-bg-surface text-[9px] text-fg-disabled font-korean"
className="px-4 py-1.5 border-b border-stroke bg-bg-surface text-caption text-fg-disabled font-korean"
style={{ flexShrink: 0 }}
>
<span className="font-semibold text-fg-sub"> ( )</span>
@ -917,15 +923,15 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
<table className="w-full">
<thead>
<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 className="px-4 py-3 text-left text-label-2 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-fg-sub">
<div className="text-label-2 font-semibold text-fg-sub">
{OPER_LABELS[oper]}
</div>
<div className="text-[9px] text-fg-disabled font-korean">
<div className="text-caption text-fg-disabled font-korean">
{OPER_FULL_LABELS[oper]}
</div>
</th>
@ -1189,7 +1195,7 @@ function PermissionsPanel() {
>
<div>
<h1 className="text-sm font-bold text-fg font-korean"> </h1>
<p className="text-[10px] text-fg-disabled mt-0.5 font-korean">
<p className="text-caption text-fg-disabled mt-0.5 font-korean">
× CRUD
</p>
</div>

파일 보기

@ -183,31 +183,31 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<table className="w-full">
<thead>
<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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-center text-label-2 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">
<th className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-center text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-20">
</th>
</tr>
@ -231,7 +231,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<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-fg-sub font-mono">{item.layerCd}</td>
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
<span className="block truncate" title={item.layerFullNm}>
@ -239,17 +239,17 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
</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-color-accent border border-[rgba(6,182,212,0.3)]">
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-caption 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-fg-sub font-mono">
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
</td>
<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-fg-disabled font-mono">
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
{item.regDtm ?? '-'}
</td>
<td className="px-4 py-3 text-center">
@ -285,27 +285,27 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
{/* 페이지네이션 */}
{!loading && totalPages > 1 && (
<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">
<span className="text-label-2 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-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
className="px-2.5 py-1 text-label-2 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-fg-disabled">
<span key={`e${i}`} className="px-1.5 text-label-2 text-fg-disabled">
</span>
) : (
<button
key={btn}
onClick={() => setPage(btn)}
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
className={`px-2.5 py-1 text-label-2 rounded transition-all ${
page === btn
? '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)]'
@ -318,7 +318,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-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>

파일 보기

@ -75,7 +75,7 @@ function SettingsPanel() {
<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 className="text-label-2 text-fg-disabled mt-0.5 font-korean">
</p>
</div>
@ -84,8 +84,8 @@ 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-fg font-korean"> </div>
<p className="text-[11px] text-fg-disabled mt-1 font-korean leading-relaxed">
<div className="text-title-4 font-semibold text-fg font-korean"> </div>
<p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed">
{' '}
<span className="text-green-400 font-semibold">ACTIVE</span> .
{' '}
@ -111,10 +111,10 @@ 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-fg font-korean">
<div className="text-title-4 font-semibold text-fg font-korean">
</div>
<p className="text-[11px] text-fg-disabled mt-1 font-korean leading-relaxed">
<p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed">
{' '}
<span className="text-color-accent font-semibold"> </span>
. .
@ -141,16 +141,16 @@ function SettingsPanel() {
<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">
<p className="text-label-2 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-fg font-korean mb-1">
<div className="text-title-4 font-semibold text-fg font-korean mb-1">
</div>
<p className="text-[11px] text-fg-disabled font-korean leading-relaxed mb-3">
<p className="text-label-2 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>
@ -202,7 +202,7 @@ function SettingsPanel() {
.map((domain) => (
<span
key={domain}
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
className="inline-flex items-center gap-1 px-2 py-1 text-caption font-mono rounded-md"
style={{
background: 'rgba(6,182,212,0.1)',
color: 'var(--color-accent)',
@ -223,7 +223,7 @@ function SettingsPanel() {
<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 flex-col gap-3 text-label-1 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'}`}

파일 보기

@ -103,31 +103,31 @@ 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-elevated border border-stroke rounded px-2 text-fg focus:border-color-accent focus:outline-none"
className="w-full h-8 text-title-4 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-fg-disabled font-mono mt-0.5">{menu.id}</div>
<div className="text-caption 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-color-accent border border-color-accent rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
className="shrink-0 px-2 py-1 text-caption font-semibold text-color-accent border border-color-accent rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
>
</button>
</>
) : (
<>
<span className="text-[16px] shrink-0">{menu.icon}</span>
<span className="text-title-2 shrink-0">{menu.icon}</span>
<div className="flex-1 min-w-0">
<div
className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-fg' : 'text-fg-disabled'}`}
className={`text-title-4 font-semibold font-korean ${menu.enabled ? 'text-fg' : 'text-fg-disabled'}`}
>
{menu.label}
</div>
<div className="text-[10px] text-fg-disabled font-mono">{menu.id}</div>
<div className="text-caption text-fg-disabled font-mono">{menu.id}</div>
</div>
<button
onClick={() => onEditStart(menu.id)}
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"
className="shrink-0 w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-label-2 flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all"
title="라벨/아이콘 편집"
>

파일 보기

@ -107,7 +107,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-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
@ -121,7 +121,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 비밀번호 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
@ -135,7 +135,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 사용자명 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
@ -149,7 +149,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 직급 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<input
@ -163,7 +163,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 소속 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<select
@ -183,7 +183,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 이메일 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<input
@ -197,12 +197,12 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
{/* 역할 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<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-fg-disabled font-korean px-1 py-1"> </p>
<p className="text-caption text-fg-disabled font-korean px-1 py-1"> </p>
) : (
allRoles.map((role, idx) => {
const color = getRoleColor(role.code, idx);
@ -220,7 +220,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
<span className="text-xs font-korean" style={{ color }}>
{role.name}
</span>
<span className="text-[10px] text-fg-disabled font-mono">{role.code}</span>
<span className="text-caption text-fg-disabled font-mono">{role.code}</span>
</label>
);
})
@ -229,7 +229,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
</div>
{/* 에러 메시지 */}
{error && <p className="text-[11px] text-red-400 font-korean">{error}</p>}
{error && <p className="text-label-2 text-red-400 font-korean">{error}</p>}
</div>
{/* 푸터 */}
@ -333,7 +333,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<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>
<p className="text-caption text-fg-disabled font-mono mt-0.5">{user.account}</p>
</div>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
<svg
@ -352,12 +352,12 @@ 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-fg-sub font-korean mb-3">
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-3">
</h3>
<div className="space-y-3">
<div>
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
<label className="block text-caption text-fg-disabled font-korean mb-1">
</label>
<input
@ -369,7 +369,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
<label className="block text-caption text-fg-disabled font-korean mb-1">
</label>
<input
@ -381,7 +381,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
/>
</div>
<div>
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
<label className="block text-caption text-fg-disabled font-korean mb-1">
</label>
<select
@ -402,7 +402,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-color-accent 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-label-2 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>
@ -414,12 +414,12 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
{/* 비밀번호 초기화 */}
<div>
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-3">
<h3 className="text-label-2 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-fg-disabled font-korean mb-1">
<label className="block text-caption text-fg-disabled font-korean mb-1">
</label>
<input
@ -433,20 +433,20 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<button
onClick={handleResetPassword}
disabled={resetPwLoading || !newPassword.trim()}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-yellow-400 text-yellow-400 hover:bg-[rgba(250,204,21,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-yellow-400 text-yellow-400 hover:bg-[rgba(250,204,21,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{resetPwLoading ? '초기화 중...' : resetPwDone ? '초기화 완료' : '비밀번호 초기화'}
</button>
<button
onClick={handleUnlock}
disabled={unlockLoading || user.status !== 'LOCKED'}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
title={user.status !== 'LOCKED' ? '잠금 상태가 아닙니다' : ''}
>
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
</button>
</div>
<p className="text-[9px] text-fg-disabled font-korean mt-1.5">
<p className="text-caption text-fg-disabled font-korean mt-1.5">
.
</p>
</div>
@ -456,12 +456,12 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
{/* 계정 잠금 해제 */}
<div>
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-2"> </h3>
<h3 className="text-label-2 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}`}
className={`inline-flex items-center gap-1.5 text-label-2 font-semibold font-korean ${(statusLabels[user.status] || statusLabels.INACTIVE).color}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${(statusLabels[user.status] || statusLabels.INACTIVE).dot}`}
@ -469,13 +469,13 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
{(statusLabels[user.status] || statusLabels.INACTIVE).label}
</span>
{user.failCount > 0 && (
<span className="text-[10px] text-red-400 font-korean">
<span className="text-caption text-red-400 font-korean">
( {user.failCount})
</span>
)}
</div>
{user.status === 'LOCKED' && (
<p className="text-[9px] text-fg-disabled font-korean mt-1">
<p className="text-caption text-fg-disabled font-korean mt-1">
5
</p>
)}
@ -484,7 +484,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<button
onClick={handleUnlock}
disabled={unlockLoading}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{unlockLoading ? '해제 중...' : '잠금 해제'}
</button>
@ -494,8 +494,8 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
{/* 기타 정보 (읽기 전용) */}
<div>
<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">
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-caption font-korean">
<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>
@ -520,7 +520,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
{/* 메시지 */}
{message && (
<div
className={`px-3 py-2 text-[11px] rounded-md font-korean ${
className={`px-3 py-2 text-label-2 rounded-md font-korean ${
message.type === 'success'
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
@ -685,7 +685,7 @@ function UsersPanel() {
</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">
<span className="px-2.5 py-1 text-caption 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">
{pendingCount}
</span>
)}
@ -747,31 +747,31 @@ function UsersPanel() {
<table className="w-full">
<thead>
<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 className="px-4 py-3 text-left text-label-2 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">
<th className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-right text-label-2 font-semibold text-fg-disabled font-korean">
</th>
</tr>
@ -796,12 +796,12 @@ function UsersPanel() {
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">
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono text-center">
{rowNum}
</td>
{/* ID(account) */}
<td className="px-4 py-3 text-[12px] text-fg-sub font-mono">
<td className="px-4 py-3 text-label-1 text-fg-sub font-mono">
{user.account}
</td>
@ -809,24 +809,24 @@ function UsersPanel() {
<td className="px-4 py-3">
<button
onClick={() => setDetailUser(user)}
className="text-[12px] text-color-accent font-semibold font-korean hover:underline"
className="text-label-1 text-color-accent font-semibold font-korean hover:underline"
>
{user.name}
</button>
</td>
{/* 직급 */}
<td className="px-4 py-3 text-[12px] text-fg-sub font-korean">
<td className="px-4 py-3 text-label-1 text-fg-sub font-korean">
{user.rank || '-'}
</td>
{/* 소속 */}
<td className="px-4 py-3 text-[12px] text-fg-sub font-korean">
<td className="px-4 py-3 text-label-1 text-fg-sub font-korean">
{user.orgAbbr || user.orgName || '-'}
</td>
{/* 이메일 */}
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
{user.email || '-'}
</td>
@ -846,7 +846,7 @@ function UsersPanel() {
return (
<span
key={roleCode}
className="px-2 py-0.5 text-[10px] font-semibold rounded-md font-korean"
className="px-2 py-0.5 text-caption font-semibold rounded-md font-korean"
style={{
background: `${color}20`,
color: color,
@ -858,11 +858,11 @@ function UsersPanel() {
);
})
) : (
<span className="text-[10px] text-fg-disabled font-korean">
<span className="text-caption text-fg-disabled font-korean">
</span>
)}
<span className="text-[10px] text-fg-disabled ml-0.5">
<span className="text-caption text-fg-disabled ml-0.5">
<svg
width="10"
height="10"
@ -881,7 +881,7 @@ function UsersPanel() {
ref={roleDropdownRef}
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-fg-disabled font-korean font-semibold mb-1.5 px-1">
<div className="text-caption text-fg-disabled font-korean font-semibold mb-1.5 px-1">
</div>
{allRoles.map((role, roleIdx) => {
@ -900,7 +900,7 @@ function UsersPanel() {
<span className="text-xs font-korean" style={{ color }}>
{role.name}
</span>
<span className="text-[10px] text-fg-disabled font-mono">
<span className="text-caption text-fg-disabled font-mono">
{role.code}
</span>
</label>
@ -909,14 +909,14 @@ function UsersPanel() {
<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-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover font-korean"
className="px-3 py-1 text-caption 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-color-accent 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-caption 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>
@ -929,7 +929,7 @@ function UsersPanel() {
{/* 승인상태 */}
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${statusInfo.color}`}
className={`inline-flex items-center gap-1.5 text-caption font-semibold font-korean ${statusInfo.color}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{statusInfo.label}
@ -943,13 +943,13 @@ function UsersPanel() {
<>
<button
onClick={() => handleApprove(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
>
</button>
<button
onClick={() => handleReject(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
>
</button>
@ -958,7 +958,7 @@ function UsersPanel() {
{user.status === 'LOCKED' && (
<button
onClick={() => handleUnlock(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
>
</button>
@ -966,7 +966,7 @@ function UsersPanel() {
{user.status === 'ACTIVE' && (
<button
onClick={() => handleDeactivate(user.id)}
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"
className="px-2 py-1 text-caption font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
@ -974,7 +974,7 @@ function UsersPanel() {
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
<button
onClick={() => handleActivate(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
className="px-2 py-1 text-caption font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
>
</button>
@ -993,7 +993,7 @@ function UsersPanel() {
{/* 페이지네이션 */}
{!loading && totalPages > 1 && (
<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">
<span className="text-label-2 text-fg-disabled font-korean">
{(currentPage - 1) * PAGE_SIZE + 1}{Math.min(currentPage * PAGE_SIZE, totalCount)} /{' '}
{totalCount}
</span>
@ -1001,7 +1001,7 @@ function UsersPanel() {
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
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"
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
@ -1020,14 +1020,14 @@ function UsersPanel() {
}, [])
.map((item, i) =>
item === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-[11px] text-fg-disabled">
<span key={`ellipsis-${i}`} className="px-2 text-label-2 text-fg-disabled">
</span>
) : (
<button
key={item}
onClick={() => setCurrentPage(item as number)}
className="px-2.5 py-1 text-[11px] border rounded transition-all font-mono"
className="px-2.5 py-1 text-label-2 border rounded transition-all font-mono"
style={
currentPage === item
? {
@ -1045,7 +1045,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-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>

파일 보기

@ -148,37 +148,37 @@ function VesselMaterialsPanel() {
<table className="w-full">
<thead>
<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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-left text-label-2 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 className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-color-accent bg-[rgba(6,182,212,0.05)]">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
<th className="px-4 py-3 text-center text-label-2 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 className="px-4 py-3 text-center text-label-2 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 className="px-4 py-3 text-center text-label-2 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 className="px-4 py-3 text-center text-label-2 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 className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean">
</th>
</tr>
@ -199,41 +199,41 @@ function VesselMaterialsPanel() {
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">
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono text-center">
{(safePage - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3">
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
className={`text-caption px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
>
{org.type}
</span>
</td>
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
<td className="px-4 py-3 text-label-2 text-fg-sub font-korean">
{regionShort(org.jurisdiction)}
</td>
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
<td className="px-4 py-3 text-label-2 text-fg font-korean font-semibold">
{org.name}
</td>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
<td className="px-4 py-3 text-label-2 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-color-accent font-semibold bg-color-accent/5">
<td className="px-4 py-3 text-label-2 font-mono text-center text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]">
{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-fg-sub">
<td className="px-4 py-3 text-label-2 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-fg-sub">
<td className="px-4 py-3 text-label-2 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-fg-sub">
<td className="px-4 py-3 text-label-2 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-fg-sub">
<td className="px-4 py-3 text-label-2 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-color-accent">
<td className="px-4 py-3 text-label-2 font-mono text-center font-bold text-color-accent">
{org.totalAssets.toLocaleString()}
</td>
</tr>
@ -247,7 +247,7 @@ function VesselMaterialsPanel() {
{/* 합계 */}
{!loading && filtered.length > 0 && (
<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">
<span className="text-caption text-fg-disabled font-korean font-semibold mr-auto">
({filtered.length} )
</span>
{[
@ -290,15 +290,15 @@ function VesselMaterialsPanel() {
].map((t) => (
<div
key={t.label}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-color-accent/10' : ''}`}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-[rgba(6,182,212,0.1)]' : ''}`}
>
<span
className={`text-[9px] font-korean ${t.active ? 'text-color-accent' : 'text-fg-disabled'}`}
className={`text-caption 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'}`}
className={`text-caption font-mono font-bold ${t.active ? 'text-color-accent' : 'text-fg'}`}
>
{t.value.toLocaleString()}
{t.unit}
@ -311,7 +311,7 @@ function VesselMaterialsPanel() {
{/* 페이지네이션 */}
{!loading && filtered.length > 0 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
<span className="text-[11px] text-fg-disabled font-korean">
<span className="text-label-2 text-fg-disabled font-korean">
{(safePage - 1) * PAGE_SIZE + 1}{Math.min(safePage * PAGE_SIZE, filtered.length)} /
{filtered.length}
</span>
@ -319,7 +319,7 @@ function VesselMaterialsPanel() {
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safePage === 1}
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"
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
>
&lt;
</button>
@ -327,7 +327,7 @@ function VesselMaterialsPanel() {
<button
key={p}
onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
style={
p === safePage
? {
@ -344,7 +344,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-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
>
&gt;
</button>

파일 보기

@ -180,10 +180,10 @@ export default function VesselSignalPanel() {
className="flex flex-col justify-center mb-4"
style={{ height: 20 }}
>
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>
<span className="text-label-1 font-semibold leading-tight" style={{ color: c }}>
{src}
</span>
<span className="text-[10px] font-mono text-text-4 mt-0.5">{st.rate}%</span>
<span className="text-caption font-mono text-text-4 mt-0.5">{st.rate}%</span>
</div>
);
})}
@ -196,14 +196,14 @@ export default function VesselSignalPanel() {
{HOURS.map((h) => (
<span
key={h}
className="absolute text-[10px] text-fg-disabled font-mono"
className="absolute text-caption 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-fg-disabled font-mono"
className="absolute text-caption text-fg-disabled font-mono"
style={{ right: 0 }}
>
24

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -19,6 +19,7 @@ interface CCTVPlayerProps {
coordDc?: string | null;
sourceNm?: string | null;
cellIndex?: number;
onClose?: () => void;
oilDetectionEnabled?: boolean;
vesselDetectionEnabled?: boolean;
intrusionDetectionEnabled?: boolean;
@ -44,9 +45,8 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
cameraNm,
streamUrl,
sttsCd,
coordDc,
sourceNm,
cellIndex = 0,
onClose,
oilDetectionEnabled = false,
vesselDetectionEnabled = false,
intrusionDetectionEnabled = false,
@ -251,10 +251,12 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
return (
<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-fg-disabled opacity-70">
<div className="text-label-2 font-korean text-fg-disabled opacity-70">
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
</div>
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
<div className="text-caption font-korean text-fg-disabled opacity-40 mt-1">
{cameraNm}
</div>
</div>
);
}
@ -264,10 +266,12 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
return (
<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-fg-disabled opacity-50">
<div className="text-caption 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 className="text-caption font-korean text-fg-disabled opacity-30 mt-1">
{cameraNm}
</div>
</div>
);
}
@ -277,11 +281,13 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
return (
<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-color-danger opacity-70"> </div>
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
<div className="text-caption font-korean text-color-danger opacity-70"> </div>
<div className="text-caption 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-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
className="mt-2 px-2.5 py-1 rounded text-caption font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
>
</button>
@ -295,7 +301,7 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
{playerState === 'loading' && (
<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-fg-disabled opacity-50"> ...</div>
<div className="text-caption font-korean text-fg-disabled opacity-50"> ...</div>
</div>
)}
@ -343,16 +349,22 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
{vesselDetectionEnabled && (
<div
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-caption font-bold"
style={{
background: 'color-mix(in srgb, var(--color-info) 30%, transparent)',
color: 'var(--color-info)',
}}
>
🚢
</div>
)}
{intrusionDetectionEnabled && (
<div
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-caption font-bold"
style={{
background: 'color-mix(in srgb, var(--color-warning) 30%, transparent)',
color: 'var(--color-warning)',
}}
>
🚨
</div>
@ -362,22 +374,45 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
{/* OSD 오버레이 */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
<span className="text-caption font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
{cameraNm}
</span>
{sttsCd === 'LIVE' && (
<span
className="text-[8px] font-bold px-1 py-0.5 rounded text-color-danger"
style={{ background: 'rgba(239,68,68,.3)' }}
className="text-caption font-bold px-1 py-0.5 rounded text-color-danger"
style={{ background: 'color-mix(in srgb, var(--color-danger) 30%, transparent)' }}
>
REC
</span>
)}
</div>
<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">
{/* 닫기 (지도로 돌아가기) */}
{onClose && (
<button
onClick={onClose}
className="absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded bg-black/60 hover:bg-black/80 text-white/70 hover:text-white cursor-pointer transition-colors z-20"
title="지도로 돌아가기"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 12H5" />
<path d="M12 19l-7-7 7-7" />
</svg>
</button>
)}
{/* <div className="absolute bottom-2 left-2 text-caption font-mono px-1.5 py-0.5 rounded text-fg-disabled bg-black/70 z-20">
{coordDc ?? ''}
{sourceNm ? ` · ${sourceNm}` : ''}
</div>
</div> */}
</div>
);
},

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -13,17 +13,9 @@ function formatDtm(dtm: string | null): string {
const equipIcon = (t: string) => (t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰');
const equipTagCls = (t: string) =>
t === 'drone'
? 'bg-[rgba(59,130,246,0.12)] text-color-info'
: t === 'plane'
? 'bg-[rgba(34,197,94,0.12)] text-color-success'
: 'bg-[rgba(168,85,247,0.12)] text-color-tertiary';
const equipTagCls = () => 'text-fg';
const mediaTagCls = (t: string) =>
t === '영상'
? 'bg-[rgba(239,68,68,0.12)] text-color-danger'
: 'bg-[rgba(234,179,8,0.12)] text-color-caution';
const mediaTagCls = () => 'text-fg';
const FilterBtn = ({
label,
@ -36,10 +28,10 @@ const FilterBtn = ({
}) => (
<button
onClick={onClick}
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
className={`px-2.5 py-1 text-caption font-semibold rounded font-korean transition-colors ${
active
? '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'
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)]'
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
}`}
>
{label}
@ -181,7 +173,7 @@ 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-fg-disabled font-korean"> :</span>
<span className="text-label-2 text-fg-disabled font-korean"> :</span>
<FilterBtn
label="전체"
active={equipFilter === 'all'}
@ -203,7 +195,7 @@ export function MediaManagement() {
onClick={() => setEquipFilter('satellite')}
/>
<span className="w-px h-4 bg-border mx-1" />
<span className="text-[11px] text-fg-disabled font-korean">:</span>
<span className="text-label-2 text-fg-disabled font-korean">:</span>
<FilterBtn
label="📷 사진"
active={typeFilter.has('photo')}
@ -221,7 +213,7 @@ export function MediaManagement() {
placeholder="파일명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
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"
className="px-3 py-1.5 bg-bg-base border border-stroke rounded-sm text-fg font-korean text-label-2 outline-none w-40 focus:border-color-accent"
/>
<select
value={sortBy}
@ -242,7 +234,7 @@ export function MediaManagement() {
icon: '📸',
value: loading ? '…' : String(mediaItems.length),
label: '총 파일',
color: 'text-color-accent',
color: 'text-fg',
},
{
icon: '🛸',
@ -261,19 +253,19 @@ export function MediaManagement() {
].map((s, i) => (
<div
key={i}
className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-card border border-stroke rounded-sm"
className="flex-1 flex items-center gap-2.5 px-4 py-3 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-fg-disabled font-korean">{s.label}</div>
<div className="text-caption text-fg-disabled font-korean">{s.label}</div>
</div>
</div>
))}
</div>
{/* File Table */}
<div className="flex-1 bg-bg-card border border-stroke rounded-md overflow-hidden flex flex-col">
<div className="flex-1 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>
@ -287,7 +279,7 @@ export function MediaManagement() {
<col style={{ width: 145 }} />
<col style={{ width: 85 }} />
<col style={{ width: 95 }} />
<col style={{ width: 50 }} />
<col style={{ width: 60 }} />
</colgroup>
<thead>
<tr className="border-b border-stroke bg-bg-elevated">
@ -300,32 +292,32 @@ export function MediaManagement() {
/>
</th>
<th className="px-1 py-2.5" />
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
<th className="px-2 py-2.5 text-caption 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 className="px-2 py-2.5 text-caption 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 className="px-2 py-2.5 text-caption 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 className="px-2 py-2.5 text-caption 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 className="px-2 py-2.5 text-caption 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 className="px-2 py-2.5 text-caption 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 className="px-2 py-2.5 text-caption 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 className="px-2 py-2.5 text-caption 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 className="px-2 py-2.5 text-caption font-semibold text-fg-disabled text-center">
</th>
</tr>
</thead>
@ -334,7 +326,7 @@ export function MediaManagement() {
<tr>
<td
colSpan={11}
className="px-4 py-8 text-center text-[11px] text-fg-disabled font-korean"
className="px-4 py-8 text-center text-label-2 text-fg-disabled font-korean"
>
...
</td>
@ -344,7 +336,7 @@ export function MediaManagement() {
<tr
key={f.aerialMediaSn}
onClick={() => toggleId(f.aerialMediaSn)}
className={`border-b border-stroke/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
className={`border-b border-stroke cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
}`}
>
@ -357,37 +349,33 @@ 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-fg font-korean truncate">
<td className="px-2 py-2 text-caption 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">
<td className="px-2 py-2 text-caption text-color-accent font-mono truncate">
{f.locDc ?? '—'}
</td>
<td className="px-2 py-2 text-[11px] font-semibold text-fg font-korean truncate">
<td className="px-2 py-2 text-label-2 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)}`}
>
<span className={`text-caption font-semibold font-korean ${equipTagCls()}`}>
{f.equipNm}
</span>
</td>
<td className="px-2 py-2">
<span
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaTpCd)}`}
>
<span className={`text-caption font-semibold font-korean ${mediaTagCls()}`}>
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
</span>
</td>
<td className="px-2 py-2 text-[11px] font-mono">{formatDtm(f.takngDtm)}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-label-2 font-mono">{formatDtm(f.takngDtm)}</td>
<td className="px-2 py-2 text-label-2 font-mono">{f.fileSz ?? '—'}</td>
<td className="px-2 py-2 text-label-2 font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<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-color-accent border border-color-accent/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors disabled:opacity-50"
className="px-2 py-1 text-caption rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors disabled:opacity-50"
>
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
</button>
@ -402,26 +390,26 @@ export function MediaManagement() {
{/* Bottom Actions */}
<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">
<div className="text-label-2 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-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
className="px-3 py-1.5 text-label-2 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-color-accent border border-color-accent/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean disabled:opacity-50"
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] 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-color-tertiary border border-color-tertiary/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean"
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean"
>
🔬
</button>
@ -434,10 +422,10 @@ export function MediaManagement() {
<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-fg-sub mb-1">
<div className="text-title-4 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-fg-sub mb-4">
<div className="text-title-4 font-korean text-fg-sub mb-4">
<span className="text-color-success font-bold">{downloadResult.success}</span>
{downloadResult.total - downloadResult.success > 0 && (
@ -453,7 +441,7 @@ export function MediaManagement() {
</div>
<button
onClick={() => setDownloadResult(null)}
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"
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
>
</button>
@ -477,12 +465,12 @@ export function MediaManagement() {
</button>
</div>
<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="border-2 border-dashed border-stroke-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-[rgba(6,182,212,0.4)] transition-colors">
<div className="text-3xl mb-2 opacity-50">📁</div>
<div className="text-[13px] font-semibold mb-1 font-korean">
<div className="text-title-4 font-semibold mb-1 font-korean">
</div>
<div className="text-[11px] text-fg-disabled font-korean">
<div className="text-label-2 text-fg-disabled font-korean">
JPG, TIFF, GeoTIFF, MP4, MOV · 2GB
</div>
</div>
@ -520,9 +508,11 @@ export function MediaManagement() {
/>
</div>
<button
className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer"
className="w-full py-3 rounded-sm text-sm font-bold font-korean cursor-pointer hover:brightness-125 transition-all"
style={{
background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))',
background: 'rgba(6,182,212,0.15)',
border: '1px solid rgba(6,182,212,0.3)',
color: 'var(--color-accent)',
}}
>
📤

파일 보기

@ -240,7 +240,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-fg-disabled mb-4 font-korean">
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
.
</div>
@ -266,12 +266,12 @@ export function OilAreaAnalysis() {
{/* 선택된 이미지 목록 */}
{selectedFiles.length > 0 && (
<>
<div className="text-[11px] font-bold mb-1.5 font-korean"> </div>
<div className="text-label-2 font-bold mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1 mb-3">
{selectedFiles.map((file, i) => (
<div key={`${file.name}-${i}`}>
<div
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
className={`flex items-center gap-2 px-2 py-1.5 bg-bg-card border rounded-sm text-label-2 font-korean cursor-pointer transition-colors
${selectedImageIndex === i ? 'border-color-accent' : 'border-stroke'}`}
onClick={() => setSelectedImageIndex(i)}
>
@ -291,7 +291,7 @@ export function OilAreaAnalysis() {
</button>
</div>
{selectedImageIndex === i && imageExifs[i] !== undefined && (
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-base border border-stroke/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-label-2 font-korean">
<MetaRow label="파일 크기" value={formatFileSize(file.size)} />
<MetaRow
label="해상도"
@ -368,7 +368,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-color-danger 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-label-2 text-color-danger font-korean">
{error}
</div>
)}
@ -377,7 +377,7 @@ export function OilAreaAnalysis() {
<button
onClick={handleStitch}
disabled={!canStitch}
className="w-full py-2.5 mb-2 rounded-sm text-[12px] font-bold font-korean cursor-pointer transition-colors
className="w-full py-2.5 mb-2 rounded-sm text-label-1 font-bold font-korean cursor-pointer transition-colors
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"
>
@ -388,7 +388,7 @@ export function OilAreaAnalysis() {
<button
onClick={handleAnalyze}
disabled={!canAnalyze}
className="w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none transition-colors
className="w-full py-3 rounded-sm text-title-4 font-bold font-korean cursor-pointer border-none transition-colors
disabled:opacity-40 disabled:cursor-not-allowed text-white"
style={
canAnalyze
@ -403,7 +403,7 @@ export function OilAreaAnalysis() {
{/* ── Right Panel ── */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 3×2 이미지 그리드 */}
<div className="text-[11px] font-bold mb-2 font-korean"> </div>
<div className="text-label-2 font-bold mb-2 font-korean"> </div>
<div className="grid grid-cols-3 gap-1.5 mb-3">
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
<div
@ -426,21 +426,21 @@ export function OilAreaAnalysis() {
/>
</div>
<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">
<div className="text-caption text-fg-sub truncate font-korean flex-1 min-w-0">
{selectedFiles[i]?.name}
</div>
{imageExifs[i] === undefined ? (
<div className="text-[10px] text-fg-disabled font-korean shrink-0">
<div className="text-caption text-fg-disabled font-korean shrink-0">
GPS ...
</div>
) : imageExifs[i]?.lat !== null ? (
<div className="text-[10px] text-color-accent font-mono leading-tight text-right shrink-0">
<div className="text-caption 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-fg-disabled font-korean shrink-0">
<div className="text-caption text-fg-disabled font-korean shrink-0">
GPS
</div>
)}
@ -456,7 +456,7 @@ export function OilAreaAnalysis() {
</div>
{/* 합성 결과 */}
<div className="text-[11px] font-bold mb-2 font-korean"> </div>
<div className="text-label-2 font-bold mb-2 font-korean"> </div>
<div
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center"
style={{ minHeight: '160px', flex: '1 1 0' }}
@ -468,7 +468,7 @@ export function OilAreaAnalysis() {
className="max-w-full max-h-full object-contain"
/>
) : (
<div className="text-[12px] text-fg-disabled font-korean text-center px-4">
<div className="text-label-1 text-fg-disabled font-korean text-center px-4">
{isStitching
? '⏳ 이미지를 합성하고 있습니다...'
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}

파일 보기

@ -85,11 +85,11 @@ const OilDetectionOverlay = memo(
{/* 에러 표시 */}
{error && (
<div
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
className="px-2 py-0.5 rounded text-caption font-semibold font-korean"
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
background: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 50%, transparent)',
color: 'var(--color-danger)',
}}
>
@ -101,13 +101,13 @@ const OilDetectionOverlay = memo(
<>
{result.regions.map((region) => {
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : 'var(--color-danger)';
const label = OIL_CLASS_NAMES[region.classId] || region.className;
return (
<div
key={region.classId}
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
className="px-2 py-0.5 rounded text-caption font-semibold font-korean"
style={{
background: `${color}33`,
border: `1px solid ${color}80`,
@ -120,11 +120,11 @@ const OilDetectionOverlay = memo(
})}
{/* 합계 */}
<div
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
className="px-2 py-0.5 rounded text-caption font-semibold font-korean"
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
background: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 50%, transparent)',
color: 'var(--color-danger)',
}}
>
: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
@ -135,11 +135,11 @@ const OilDetectionOverlay = memo(
{/* 감지 없음 */}
{!hasRegions && !isAnalyzing && !error && (
<div
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
className="px-2 py-0.5 rounded text-caption font-semibold font-korean"
style={{
background: 'rgba(34,197,94,0.15)',
border: '1px solid rgba(34,197,94,0.35)',
color: '#4ade80',
background: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-success) 35%, transparent)',
color: 'var(--color-success)',
}}
>
@ -148,7 +148,7 @@ const OilDetectionOverlay = memo(
{/* 분석 중 */}
{isAnalyzing && (
<span className="text-[9px] font-korean text-fg-disabled animate-pulse px-1">
<span className="text-caption font-korean text-fg-disabled animate-pulse px-1">
...
</span>
)}

파일 보기

@ -1,139 +1,218 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
import 'maplibre-gl/dist/maplibre-gl.css'
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
import type { DroneStreamItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer'
import type { CCTVPlayerHandle } from './CCTVPlayer'
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
import { useMapStore } from '@common/store/mapStore'
import { useState, useEffect, useCallback, useRef } from 'react';
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi';
import type { DroneStreamItem } from '../services/aerialApi';
import { CCTVPlayer } from './CCTVPlayer';
import type { CCTVPlayerHandle } from './CCTVPlayer';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
/** 함정 위치 + 드론 비행 위치 */
const DRONE_POSITIONS: Record<string, { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }> = {
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } },
'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.4800, lon: 126.5600 } },
'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } },
}
const DRONE_POSITIONS: Record<
string,
{ ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }
> = {
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.11, lon: 129.11 } },
'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.48, lon: 126.56 } },
'mokpo-3015': { ship: { lat: 34.778, lon: 126.378 }, drone: { lat: 34.805, lon: 126.41 } },
};
export function RealtimeDrone() {
const [streams, setStreams] = useState<DroneStreamItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null)
const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null)
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
const currentMapStyle = useBaseMapStyle()
const mapToggles = useMapStore((s) => s.mapToggles)
const [streams, setStreams] = useState<DroneStreamItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null);
const [gridMode, setGridMode] = useState(1);
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([]);
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null);
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]);
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const showMap = activeCells.length === 0
const showMap = activeCells.length === 0;
const loadStreams = useCallback(async () => {
try {
const items = await fetchDroneStreams()
setStreams(items)
const items = await fetchDroneStreams();
setStreams(items);
// Update selected stream and active cells with latest status
setSelectedStream(prev => prev ? items.find(s => s.id === prev.id) ?? prev : prev)
setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell))
setSelectedStream((prev) => (prev ? (items.find((s) => s.id === prev.id) ?? prev) : prev));
setActiveCells((prev) => prev.map((cell) => items.find((s) => s.id === cell.id) ?? cell));
} catch {
// Fallback: show configured streams as idle
setStreams([
{ id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산', status: 'idle', hlsUrl: null, error: null },
{ id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천', status: 'idle', hlsUrl: null, error: null },
{ id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포', status: 'idle', hlsUrl: null, error: null },
])
{
id: 'busan-1501',
name: '1501함 드론',
shipName: '부산서 1501함',
droneModel: 'DJI M300 RTK',
ip: '10.26.7.213',
rtspUrl: 'rtsp://10.26.7.213:554/stream0',
region: '부산',
status: 'idle',
hlsUrl: null,
error: null,
},
{
id: 'incheon-3008',
name: '3008함 드론',
shipName: '인천서 3008함',
droneModel: 'DJI M30T',
ip: '10.26.5.21',
rtspUrl: 'rtsp://10.26.5.21:554/stream0',
region: '인천',
status: 'idle',
hlsUrl: null,
error: null,
},
{
id: 'mokpo-3015',
name: '3015함 드론',
shipName: '목포서 3015함',
droneModel: 'DJI Mavic 3E',
ip: '10.26.7.85',
rtspUrl: 'rtsp://10.26.7.85:554/stream0',
region: '목포',
status: 'idle',
hlsUrl: null,
error: null,
},
]);
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
loadStreams()
}, [loadStreams])
loadStreams();
}, [loadStreams]);
// Poll status every 3 seconds when any stream is starting
useEffect(() => {
const hasStarting = streams.some(s => s.status === 'starting')
if (!hasStarting) return
const timer = setInterval(loadStreams, 3000)
return () => clearInterval(timer)
}, [streams, loadStreams])
const hasStarting = streams.some((s) => s.status === 'starting');
if (!hasStarting) return;
const timer = setInterval(loadStreams, 3000);
return () => clearInterval(timer);
}, [streams, loadStreams]);
const handleStartStream = async (id: string) => {
try {
await startDroneStreamApi(id)
await startDroneStreamApi(id);
// Immediately update to 'starting' state
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s))
setStreams((prev) =>
prev.map((s) => (s.id === id ? { ...s, status: 'starting' as const, error: null } : s)),
);
// Poll for status update
setTimeout(loadStreams, 2000)
setTimeout(loadStreams, 2000);
} catch {
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s))
}
setStreams((prev) =>
prev.map((s) =>
s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s,
),
);
}
};
const handleStopStream = async (id: string) => {
try {
await stopDroneStreamApi(id)
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s))
setActiveCells(prev => prev.filter(c => c.id !== id))
await stopDroneStreamApi(id);
setStreams((prev) =>
prev.map((s) =>
s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s,
),
);
setActiveCells((prev) => prev.filter((c) => c.id !== id));
} catch {
// ignore
}
}
};
const handleSelectStream = (stream: DroneStreamItem) => {
setSelectedStream(stream)
setSelectedStream(stream);
if (stream.status === 'streaming' && stream.hlsUrl) {
if (gridMode === 1) {
setActiveCells([stream])
setActiveCells([stream]);
} else {
setActiveCells(prev => {
if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream]
return prev
})
}
setActiveCells((prev) => {
if (prev.length < gridMode && !prev.find((c) => c.id === stream.id))
return [...prev, stream];
return prev;
});
}
}
};
const statusInfo = (status: string) => {
switch (status) {
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)' }
}
case 'streaming':
return {
label: '송출중',
color: 'var(--color-success)',
bg: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
};
case 'starting':
return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' };
case 'error':
return {
label: '오류',
color: 'var(--color-danger)',
bg: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
};
default:
return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' };
}
};
const gridCols = gridMode === 1 ? 1 : 2
const totalCells = gridMode
const gridCols = gridMode === 1 ? 1 : 2;
const totalCells = gridMode;
return (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
<div
className="flex h-full overflow-hidden"
style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}
>
{/* 좌측: 드론 스트림 목록 */}
<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-stroke shrink-0 bg-bg-elevated">
<div className="flex items-center justify-between mb-2">
<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)' }} />
<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-card border border-stroke rounded text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
></button>
className="px-2 py-0.5 text-caption 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-caption text-fg-disabled 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(--stroke-light) 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-fg-disabled font-korean"> ...</div>
) : streams.map(stream => {
const si = statusInfo(stream.status)
const isSelected = selectedStream?.id === stream.id
<div className="px-3.5 py-4 text-label-2 text-fg-disabled font-korean">
...
</div>
) : (
streams.map((stream) => {
const si = statusInfo(stream.status);
const isSelected = selectedStream?.id === stream.id;
return (
<div
key={stream.id}
@ -148,23 +227,32 @@ export function RealtimeDrone() {
<div className="flex items-center gap-2">
<div className="text-sm">🚁</div>
<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 className="text-label-2 font-normal text-fg font-korean">
{stream.shipName}{' '}
<span className="text-caption font-normal">({stream.droneModel})</span>
</div>
<div className="text-caption text-fg-disabled font-mono">{stream.ip}</div>
</div>
</div>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
className="text-caption font-bold px-1.5 py-0.5 rounded-full"
style={{ background: si.bg, color: si.color }}
>{si.label}</span>
>
{si.label}
</span>
</div>
<div className="flex items-center gap-1.5">
<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>
<span className="text-caption text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">
{stream.region}
</span>
<span className="text-caption 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-color-danger font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
<div className="mt-1.5 text-caption text-color-danger font-korean px-1.5 py-1 rounded bg-[color-mix(in srgb, var(--color-danger) 6%, transparent)]">
{stream.error}
</div>
)}
@ -173,28 +261,36 @@ export function RealtimeDrone() {
<div className="mt-2 flex gap-1.5">
{stream.status === 'idle' || stream.status === 'error' ? (
<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(--color-success)' }}
> </button>
onClick={(e) => {
e.stopPropagation();
handleStartStream(stream.id);
}}
className="flex-1 px-2 py-1 text-caption font-bold font-korean rounded border cursor-pointer transition-colors bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-fg hover:bg-[rgba(6,182,212,0.15)]"
>
<span className="text-color-success"></span>
</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(--color-danger)' }}
> </button>
onClick={(e) => {
e.stopPropagation();
handleStopStream(stream.id);
}}
className="flex-1 px-2 py-1 text-caption font-bold font-korean rounded border cursor-pointer transition-colors bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-fg hover:bg-[rgba(6,182,212,0.15)]"
>
<span className="text-color-danger"></span>
</button>
)}
</div>
</div>
)
})}
);
})
)}
</div>
{/* 하단 안내 */}
<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 className="text-caption text-fg-disabled font-korean leading-relaxed">
RTSP . ViewLink .
</div>
</div>
</div>
@ -208,13 +304,35 @@ export function RealtimeDrone() {
{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(--color-success)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-success)' }} />LIVE
<div
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-caption font-bold shrink-0"
style={{
background: 'color-mix(in srgb, var(--color-success) 14%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-success) 35%, transparent)',
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(--color-accent)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-accent)' }} />
<div
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-caption 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>
@ -224,23 +342,31 @@ export function RealtimeDrone() {
{[
{ mode: 1, icon: '▣', label: '1화면' },
{ mode: 4, icon: '⊞', label: '4분할' },
].map(g => (
].map((g) => (
<button
key={g.mode}
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
onClick={() => {
setGridMode(g.mode);
setActiveCells((prev) => prev.slice(0, g.mode));
}}
title={g.label}
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
style={gridMode === g.mode
className="px-2 py-1 text-label-2 cursor-pointer border-none transition-colors"
style={
gridMode === g.mode
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
}
>{g.icon}</button>
>
{g.icon}
</button>
))}
</div>
<button
onClick={() => playerRefs.current.forEach(r => r?.capture())}
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>
onClick={() => playerRefs.current.forEach((r) => r?.capture())}
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-label-1 font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
>
📷
</button>
</div>
</div>
@ -254,67 +380,175 @@ export function RealtimeDrone() {
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{streams.map(stream => {
const pos = DRONE_POSITIONS[stream.id]
if (!pos) return null
const statusColor = stream.status === 'streaming' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8'
{streams.map((stream) => {
const pos = DRONE_POSITIONS[stream.id];
if (!pos) return null;
const statusColor =
stream.status === 'streaming'
? 'var(--color-success)'
: stream.status === 'starting'
? 'var(--color-accent)'
: stream.status === 'error'
? 'var(--color-danger)'
: 'var(--fg-disabled)';
return (
<Marker
key={stream.id}
longitude={(pos.ship.lon + pos.drone.lon) / 2}
latitude={(pos.ship.lat + pos.drone.lat) / 2}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
onClick={(e) => {
e.originalEvent.stopPropagation();
setMapPopup(stream);
}}
>
<div className="cursor-pointer group" title={stream.shipName}>
<svg width="130" height="85" viewBox="0 0 130 85" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-105" style={{ overflow: 'visible' }}>
<svg
width="130"
height="85"
viewBox="0 0 130 85"
fill="none"
className="drop-shadow-lg transition-transform group-hover:scale-105"
style={{ overflow: 'visible' }}
>
{/* 연결선 (점선) */}
<line x1="28" y1="52" x2="88" y2="30" stroke={statusColor} strokeWidth="1.2" strokeDasharray="4 3" opacity="0.5" />
<line
x1="28"
y1="52"
x2="88"
y2="30"
stroke={statusColor}
strokeWidth="1.2"
strokeDasharray="4 3"
opacity="0.5"
/>
{/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */}
<polygon points="28,38 18,58 38,58" fill={statusColor} opacity="0.85" />
<polygon points="28,38 18,58 38,58" fill="none" stroke="#fff" strokeWidth="0.8" opacity="0.5" />
<polygon
points="28,38 18,58 38,58"
fill="none"
stroke="var(--static-white)"
strokeWidth="0.8"
opacity="0.5"
/>
{/* 함정명 라벨 */}
<rect x="3" y="61" width="50" height="13" rx="3" fill="rgba(0,0,0,.75)" />
<text x="28" y="70.5" textAnchor="middle" fill="#fff" fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.shipName.replace(/서 /, ' ')}</text>
<text
x="28"
y="70.5"
textAnchor="middle"
fill="var(--static-white)"
fontSize="7"
fontFamily="var(--font-korean)"
fontWeight="bold"
>
{stream.shipName.replace(/서 /, ' ')}
</text>
{/* ── 드론: 쿼드콥터 아이콘 ── */}
{/* 외곽 원 */}
<circle cx="88" cy="30" r="18" fill="rgba(10,14,24,.7)" stroke={statusColor} strokeWidth="1.5" />
<circle
cx="88"
cy="30"
r="18"
fill="rgba(10,14,24,.7)"
stroke={statusColor}
strokeWidth="1.5"
/>
{/* X자 팔 */}
<line x1="76" y1="18" x2="100" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
<line x1="100" y1="18" x2="76" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
<line
x1="76"
y1="18"
x2="100"
y2="42"
stroke={statusColor}
strokeWidth="1.2"
opacity="0.5"
/>
<line
x1="100"
y1="18"
x2="76"
y2="42"
stroke={statusColor}
strokeWidth="1.2"
opacity="0.5"
/>
{/* 프로펠러 4개 (회전 애니메이션) */}
<ellipse cx="76" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 76 18" to="360 76 18" dur="1.5s" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 76 18"
to="360 76 18"
dur="1.5s"
repeatCount="indefinite"
/>
</ellipse>
<ellipse cx="100" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 100 18" to="-360 100 18" dur="1.5s" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 18"
to="-360 100 18"
dur="1.5s"
repeatCount="indefinite"
/>
</ellipse>
<ellipse cx="76" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 76 42" to="-360 76 42" dur="1.5s" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 76 42"
to="-360 76 42"
dur="1.5s"
repeatCount="indefinite"
/>
</ellipse>
<ellipse cx="100" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 100 42" to="360 100 42" dur="1.5s" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 42"
to="360 100 42"
dur="1.5s"
repeatCount="indefinite"
/>
</ellipse>
{/* 본체 */}
<circle cx="88" cy="30" r="6" fill={statusColor} opacity="0.8" />
{/* 카메라 렌즈 */}
<circle cx="88" cy="30" r="3" fill="#fff" opacity="0.9" />
<circle cx="88" cy="30" r="3" fill="var(--static-white)" opacity="0.9" />
<circle cx="88" cy="30" r="1.5" fill={statusColor} />
{/* 송출중 REC LED */}
{stream.status === 'streaming' && (
<circle cx="100" cy="16" r="3" fill="#ef4444">
<animate attributeName="opacity" values="1;0.2;1" dur="1s" repeatCount="indefinite" />
<circle cx="100" cy="16" r="3" fill="var(--color-danger)">
<animate
attributeName="opacity"
values="1;0.2;1"
dur="1s"
repeatCount="indefinite"
/>
</circle>
)}
{/* 드론 모델명 */}
<rect x="65" y="51" width="46" height="12" rx="3" fill="rgba(0,0,0,.75)" />
<text x="88" y="60" textAnchor="middle" fill={statusColor} fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.droneModel.split(' ').slice(-1)[0]}</text>
<text
x="88"
y="60"
textAnchor="middle"
fill={statusColor}
fontSize="7"
fontFamily="var(--font-korean)"
fontWeight="bold"
>
{stream.droneModel.split(' ').slice(-1)[0]}
</text>
</svg>
</div>
</Marker>
)
);
})}
{/* 드론 클릭 팝업 */}
{mapPopup && DRONE_POSITIONS[mapPopup.id] && (
@ -327,56 +561,104 @@ export function RealtimeDrone() {
offset={36}
className="cctv-dark-popup"
>
<div className="p-2.5" style={{ minWidth: 170, background: 'var(--bg-card)', 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-fg">{mapPopup.shipName}</div>
<div className="text-label-2 font-bold text-fg">{mapPopup.shipName}</div>
</div>
<div className="text-caption text-fg-disabled mb-0.5">
{mapPopup.droneModel}
</div>
<div className="text-caption text-fg-disabled 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 }}
> {statusInfo(mapPopup.status).label}</span>
<span
className="text-caption font-bold px-1.5 py-px rounded-full"
style={{
background: statusInfo(mapPopup.status).bg,
color: statusInfo(mapPopup.status).color,
}}
>
{statusInfo(mapPopup.status).label}
</span>
</div>
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? (
<button
onClick={() => { handleStartStream(mapPopup.id); handleSelectStream(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{ background: 'rgba(34,197,94,.15)', borderColor: 'rgba(34,197,94,.3)', color: '#4ade80' }}
> </button>
onClick={() => {
handleStartStream(mapPopup.id);
handleSelectStream(mapPopup);
setMapPopup(null);
}}
className="w-full px-2 py-1.5 rounded text-label-1 font-bold cursor-pointer border transition-colors"
style={{
background: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-success) 30%, transparent)',
color: 'var(--color-success)',
}}
>
</button>
) : mapPopup.status === 'streaming' ? (
<button
onClick={() => { handleSelectStream(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
> </button>
onClick={() => {
handleSelectStream(mapPopup);
setMapPopup(null);
}}
className="w-full px-2 py-1.5 rounded text-label-1 font-bold cursor-pointer border transition-colors"
style={{
background: 'rgba(6,182,212,.15)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}}
>
</button>
) : (
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse"> ...</div>
<div className="text-caption text-color-accent font-korean text-center animate-pulse">
...
</div>
)}
</div>
</Popup>
)}
</Map>
{/* 지도 위 안내 배지 */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
<div
className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-label-1 font-bold font-korean z-10"
style={{
background: 'rgba(0,0,0,.7)',
color: 'rgba(255,255,255,.7)',
backdropFilter: 'blur(4px)',
}}
>
🚁 ({streams.length})
</div>
</div>
) : (
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
<div
className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
style={{
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
}}>
}}
>
{Array.from({ length: totalCells }).map((_, i) => {
const stream = activeCells[i]
const stream = activeCells[i];
return (
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
<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 }}
ref={(el) => {
playerRefs.current[i] = el;
}}
cameraNm={stream.shipName}
streamUrl={stream.hlsUrl}
sttsCd="LIVE"
@ -387,47 +669,69 @@ 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-color-accent font-korean animate-pulse">RTSP ...</div>
<div className="text-[8px] text-fg-disabled font-mono">{stream.ip}:554</div>
<div className="text-label-1 text-color-accent font-korean animate-pulse">
RTSP ...
</div>
<div className="text-caption 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-color-danger font-korean"> </div>
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">{stream.error}</div>
<div className="text-label-1 text-color-danger font-korean"> </div>
<div className="text-caption 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-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
></button>
className="mt-1 px-2.5 py-1 rounded text-caption 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-fg-disabled font-korean opacity-40">
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
<div className="text-label-1 text-fg-disabled font-korean opacity-40">
{streams.length > 0
? '스트림을 시작하고 선택하세요'
: '드론 스트림을 선택하세요'}
</div>
)}
</div>
)
);
})}
</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 className="text-label-1 text-fg-disabled font-korean">
: <b className="text-fg">{selectedStream?.shipName ?? ''}</b>
</div>
<div className="text-label-1 text-fg-disabled font-korean">
IP:{' '}
<span className="text-color-accent font-mono text-caption">
{selectedStream?.ip ?? ''}
</span>
</div>
<div className="text-label-1 text-fg-disabled font-korean">
: <span className="text-fg-sub">{selectedStream?.region ?? ''}</span>
</div>
<div className="ml-auto text-caption text-fg-disabled font-korean">
RTSP HLS · ViewLink
</div>
</div>
</div>
{/* 우측: 정보 패널 */}
<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-stroke text-[11px] font-bold text-fg font-korean bg-bg-elevated shrink-0">
<div className="px-3 py-2 border-b border-stroke text-label-2 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(--stroke-light) 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">
{[
@ -440,50 +744,76 @@ 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-base rounded text-[9px]">
<div
key={i}
className="flex justify-between px-2 py-1 bg-bg-base rounded text-caption"
>
<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-base rounded text-[8px]">
<div className="px-2 py-1 bg-bg-base rounded text-caption">
<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 className="font-mono text-color-accent break-all">
{selectedStream.hlsUrl}
</div>
</div>
)}
</div>
) : (
<div className="text-[10px] text-fg-disabled font-korean"> </div>
<div className="text-label-1 text-fg-disabled font-korean">
</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-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 className="text-label-1 font-bold text-fg-sub font-korean mb-2">
🔗
</div>
<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)' }}>RTSPHLS</span>
<div className="flex flex-col gap-1.5">
<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-caption text-fg-sub font-korean">ViewLink 3.5</span>
<span className="text-caption text-fg"> RTSP</span>
</div>
<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-caption text-fg-sub font-korean">FFmpeg </span>
<span className="text-caption text-fg">RTSPHLS</span>
</div>
</div>
</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="text-label-1 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-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' },
{ label: '전체', value: streams.length },
{
label: '송출중',
value: streams.filter((s) => s.status === 'streaming').length,
},
{
label: '연결중',
value: streams.filter((s) => s.status === 'starting').length,
},
{
label: '오류',
value: streams.filter((s) => s.status === 'error').length,
},
].map((item, i) => (
<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 className="text-caption text-fg-disabled font-korean">{item.label}</div>
<div className="text-sm font-mono text-fg">{item.value}</div>
</div>
))}
</div>
@ -491,5 +821,5 @@ export function RealtimeDrone() {
</div>
</div>
</div>
)
);
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -396,7 +396,7 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
</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"
className="h-full bg-[rgba(6,182,212,0.4)] rounded-full"
style={{ width: '64%', animation: 'pulse 2s infinite' }}
/>
</div>
@ -717,7 +717,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
</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"
className="h-full bg-[rgba(239,68,68,0.4)] rounded-full"
style={{ width: '52%', animation: 'pulse 2s infinite' }}
/>
</div>
@ -746,15 +746,15 @@ export function SensorAnalysis() {
<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-stroke">
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">
<div className="text-caption 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 ${
className={`flex-1 py-1.5 text-center text-caption font-semibold rounded cursor-pointer border transition-colors font-korean ${
subTab === 'vessel'
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-color-accent/20'
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.2)]'
: 'text-fg-disabled bg-bg-base border-stroke'
}`}
>
@ -762,9 +762,9 @@ export function SensorAnalysis() {
</button>
<button
onClick={() => setSubTab('pollution')}
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
className={`flex-1 py-1.5 text-center text-caption font-semibold rounded cursor-pointer border transition-colors font-korean ${
subTab === 'pollution'
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-color-accent/20'
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.2)]'
: 'text-fg-disabled bg-bg-base border-stroke'
}`}
>
@ -778,20 +778,20 @@ 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-color-accent/20'
? 'bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.2)]'
: 'border-transparent hover:bg-white/[0.02]'
}`}
>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-bold text-fg font-korean">{item.name}</div>
<div className="text-[8px] text-fg-disabled font-mono">
<div className="text-caption font-bold text-fg font-korean">{item.name}</div>
<div className="text-caption text-fg-disabled font-mono">
{item.id} · {item.points} pts
</div>
</div>
<span
className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-color-success' : 'text-color-warning'}`}
className={`text-caption font-semibold ${item.status === 'complete' ? 'text-color-success' : 'text-color-warning'}`}
>
{item.status === 'complete' ? '완료' : '처리중'}
{item.status === 'complete' ? '완료' : '처리중'}
</span>
</div>
))}
@ -800,15 +800,15 @@ 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-fg-disabled mb-1.5 uppercase tracking-wider">
<div className="text-caption 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-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' },
{ label: 'D-01 정면', sensor: '광학', color: 'text-fg-sub' },
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-fg-sub' },
{ label: 'D-03 우현', sensor: '광학', color: 'text-fg-sub' },
{ label: 'D-02 상부', sensor: 'IR', color: 'text-fg-sub' },
].map((src, i) => (
<div
key={i}
@ -816,13 +816,13 @@ export function SensorAnalysis() {
>
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}
style={{ background: 'var(--bg-base)' }}
>
<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-fg-disabled font-korean">
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-caption text-fg-disabled font-korean">
<span>{src.label}</span>
<span className={src.color}>{src.sensor}</span>
</div>
@ -863,7 +863,7 @@ export function SensorAnalysis() {
className="absolute bottom-16 left-4"
style={{ fontSize: '9px', fontFamily: 'var(--font-mono)' }}
>
<div style={{ color: '#ef4444' }}>X </div>
<div style={{ color: 'var(--color-danger)' }}>X </div>
<div className="text-green-500">Y </div>
<div className="text-blue-500">Z </div>
</div>
@ -871,13 +871,13 @@ export function SensorAnalysis() {
{/* Title */}
<div className="absolute top-3 left-3 z-[2]">
<div className="text-[10px] font-bold text-fg-disabled uppercase tracking-wider">
<div className="text-caption 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">
<div className="text-title-4 font-bold text-color-accent my-1 font-korean">
{selectedItem.name}
</div>
<div className="text-[9px] text-fg-disabled font-mono">
<div className="text-caption text-fg-disabled font-mono">
34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}
</div>
</div>
@ -892,10 +892,10 @@ export function SensorAnalysis() {
<button
key={m.id}
onClick={() => setViewMode(m.id)}
className={`px-2.5 py-1.5 text-[10px] font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
className={`px-2.5 py-1.5 text-caption font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
viewMode === m.id
? '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'
? 'bg-[rgba(6,182,212,0.2)] border-[rgba(6,182,212,0.5)] text-color-accent'
: 'bg-black/40 border-[rgba(6,182,212,0.2)] text-fg-disabled hover:bg-black/60 hover:border-[rgba(6,182,212,0.4)]'
}`}
>
{m.label}
@ -906,7 +906,7 @@ export function SensorAnalysis() {
{/* Bottom Stats */}
<div
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-3 bg-black/50 backdrop-blur-lg px-4 py-2 rounded-md border z-[2]"
style={{ borderColor: 'rgba(6,182,212,0.15)' }}
style={{ borderColor: 'var(--stroke-default)' }}
>
{[
{ value: selectedItem.points, label: '포인트' },
@ -917,7 +917,7 @@ export function SensorAnalysis() {
].map((s, i) => (
<div key={i} className="text-center">
<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 className="text-caption text-fg-disabled mt-0.5 font-korean">{s.label}</div>
</div>
))}
</div>
@ -927,10 +927,10 @@ export function SensorAnalysis() {
<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-stroke">
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">
<div className="text-caption font-bold text-fg-disabled mb-2 uppercase tracking-wider">
📊
</div>
<div className="flex flex-col gap-1.5 text-[10px]">
<div className="flex flex-col gap-1.5 text-caption">
{(selectedItem.type === 'vessel'
? [
['대상', selectedItem.name],
@ -962,26 +962,26 @@ export function SensorAnalysis() {
{/* AI Detection Results */}
<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 className="text-caption 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-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: 78, color: 'bg-color-success' },
{ label: '손상 감지', confidence: 45, color: 'bg-color-success' },
{ label: '화물 분석', confidence: 62, color: 'bg-color-success' },
]
: [
{ 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' },
{ label: '두께 추정', confidence: 72, color: 'bg-color-success' },
{ label: '확산 예측', confidence: 68, color: 'bg-color-success' },
]
).map((r, i) => (
<div key={i}>
<div className="flex justify-between text-[9px] mb-0.5">
<div className="flex justify-between text-caption mb-0.5">
<span className="text-fg-disabled font-korean">{r.label}</span>
<span className="font-mono font-semibold text-fg">{r.confidence}%</span>
</div>
@ -998,10 +998,10 @@ export function SensorAnalysis() {
{/* Comparison / Measurements */}
<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 className="text-caption font-bold text-fg-disabled mb-2 uppercase tracking-wider">
📐 3D
</div>
<div className="flex flex-col gap-1 text-[10px]">
<div className="flex flex-col gap-1 text-caption">
{(selectedItem.type === 'vessel'
? [
['전장 (LOA)', '84.7 m'],
@ -1020,7 +1020,7 @@ export function SensorAnalysis() {
).map(([k, v], i) => (
<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>
<span className="font-mono font-semibold text-fg">{v}</span>
</div>
))}
</div>
@ -1029,14 +1029,15 @@ 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"
className="w-full py-2.5 rounded-sm text-label-2 font-semibold font-korean text-color-accent border cursor-pointer mb-2 transition-colors"
style={{
background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))',
border: '1px solid rgba(6,182,212,.3)',
background: 'rgba(6,182,212,.08)',
}}
>
📊
</button>
<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">
<button className="w-full py-2 border border-stroke bg-bg-card text-fg-sub rounded-sm text-label-2 font-semibold font-korean cursor-pointer hover:bg-bg-surface-hover transition-colors">
📥 3D
</button>
</div>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,5 +1,4 @@
import { useState, useEffect } from 'react';
import { typeTagCls } from './assetTypes';
import { fetchOrganizations, fetchOrganizationDetail } from '../services/assetsApi';
import type { AssetOrgCompat } from '../services/assetsApi';
import AssetMap from './AssetMap';
@ -96,20 +95,20 @@ function AssetManagement() {
<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 ${
className={`px-3 py-1.5 text-label-2 font-semibold rounded-sm font-korean transition-colors ${
viewMode === 'list'
? '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'
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]'
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
}`}
>
📋
</button>
<button
onClick={() => setViewMode('map')}
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
className={`px-3 py-1.5 text-label-2 font-semibold rounded-sm font-korean transition-colors ${
viewMode === 'map'
? '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'
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]'
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
}`}
>
🗺
@ -170,7 +169,7 @@ function AssetManagement() {
{viewMode === 'list' ? (
/* ── LIST VIEW ── */
<div className="flex-1 bg-bg-card border border-stroke rounded-md overflow-hidden flex flex-col">
<div className="flex-1 border border-stroke rounded-md overflow-hidden flex flex-col">
<div className="flex-1">
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
<colgroup>
@ -212,7 +211,7 @@ function AssetManagement() {
return (
<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'}`}
className={`px-2.5 py-2.5 text-caption 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-mix(in_srgb,var(--color-accent)_5%,transparent)]' : 'text-fg-sub'}`}
>
{h}
</th>
@ -224,59 +223,53 @@ function AssetManagement() {
{paged.map((org, idx) => (
<tr
key={org.id}
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)]' : ''
}`}
className={`border-b border-stroke hover:bg-white/[0.02] cursor-pointer transition-colors`}
onClick={() => {
handleSelectOrg(org);
setViewMode('map');
}}
>
<td className="px-2.5 py-2 text-center font-mono text-[10px]">
<td className="px-2.5 py-2 text-center font-mono text-caption">
{(safePage - 1) * pageSize + idx + 1}
</td>
<td className="px-2.5 py-2">
<span
className={`text-[9px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
>
{org.type}
</span>
<span className="text-caption text-color-accent font-korean">{org.type}</span>
</td>
<td className="px-2.5 py-2 text-[10px] font-semibold font-korean">
<td className="px-2.5 py-2 text-caption font-korean">
{regionShort(org.jurisdiction)}
</td>
<td className="px-2.5 py-2 text-[10px] font-semibold text-color-accent font-korean cursor-pointer truncate">
<td className="px-2.5 py-2 text-caption text-fg-sub font-korean cursor-pointer truncate">
{org.name}
</td>
<td className="px-2.5 py-2 text-[10px] text-fg-disabled font-korean truncate">
<td className="px-2.5 py-2 text-caption 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' : ''}`}
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'vessel' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
>
{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' : ''}`}
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'skimmer' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
>
{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' : ''}`}
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'pump' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
>
{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' : ''}`}
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'vehicle' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
>
{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' : ''}`}
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'sprayer' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
>
{org.sprayer}
</td>
<td className="px-2.5 py-2 text-center font-bold text-color-accent font-mono text-[10px]">
<td className="px-2.5 py-2 text-center text-fg-sub font-mono text-caption">
{org.totalAssets}
</td>
</tr>
@ -287,7 +280,7 @@ function AssetManagement() {
{/* Totals Summary */}
<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">
<span className="text-caption text-fg-disabled font-korean font-semibold mr-auto">
({filtered.length} )
</span>
{[
@ -332,15 +325,15 @@ function AssetManagement() {
return (
<div
key={t.key}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${equipFilter === t.key ? 'bg-color-accent/10' : ''}`}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${equipFilter === t.key ? 'bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)]' : ''}`}
>
<span
className={`text-[9px] font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}
className={`text-caption 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'}`}
className={`text-caption font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}
>
{t.value.toLocaleString()}
{t.unit}
@ -352,7 +345,7 @@ function AssetManagement() {
{/* Pagination */}
<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="text-caption 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)}
@ -362,14 +355,14 @@ function AssetManagement() {
<button
onClick={() => setCurrentPage(1)}
disabled={safePage <= 1}
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"
className="px-1.5 py-1 text-caption 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-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
className="px-1.5 py-1 text-caption 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>
@ -377,9 +370,9 @@ function AssetManagement() {
<button
key={p}
onClick={() => setCurrentPage(p)}
className={`w-6 h-6 text-[10px] font-bold rounded transition-colors cursor-pointer ${
className={`w-6 h-6 text-caption font-bold rounded transition-colors cursor-pointer ${
p === safePage
? 'bg-color-accent/20 text-color-accent border border-color-accent/40'
? 'bg-[color-mix(in_srgb,var(--color-accent)_20%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_40%,transparent)]'
: 'border border-stroke bg-bg-card text-fg-disabled hover:bg-bg-surface-hover'
}`}
>
@ -389,14 +382,14 @@ function AssetManagement() {
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
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"
className="px-1.5 py-1 text-caption 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-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
className="px-1.5 py-1 text-caption 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>
@ -423,10 +416,10 @@ function AssetManagement() {
{/* Header */}
<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-fg-sub font-semibold font-korean mb-1">
<div className="text-label-2 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">
<div className="text-label-2 text-fg-disabled font-korean">
{selectedOrg.address}
</div>
</div>
@ -437,7 +430,7 @@ function AssetManagement() {
<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 ${
className={`flex-1 py-2.5 text-center text-label-2 font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
detailTab === t
? 'text-color-accent border-color-accent'
: 'text-fg-disabled border-transparent hover:text-fg-sub'
@ -449,7 +442,10 @@ function AssetManagement() {
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3.5 scrollbar-thin">
<div
className="flex-1 overflow-y-auto p-3.5 scrollbar-thin"
style={{ scrollbarGutter: 'stable' }}
>
{/* Summary */}
<div className="grid grid-cols-3 gap-1.5 mb-3">
{[
@ -462,7 +458,7 @@ function AssetManagement() {
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">
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
{s.label}
</div>
</div>
@ -498,10 +494,10 @@ function AssetManagement() {
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">
<span className="text-label-2 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-label-2 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>
@ -528,7 +524,7 @@ function AssetManagement() {
].map(([k, v], i) => (
<div
key={i}
className="flex justify-between px-2.5 py-2 bg-bg-base rounded text-[11px]"
className="flex justify-between px-2.5 py-2 bg-bg-base rounded text-label-2"
>
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono font-semibold text-fg">{v}</span>
@ -541,7 +537,7 @@ function AssetManagement() {
<div className="flex flex-col gap-2">
{/* 기관 기본 정보 */}
<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 className="text-caption font-bold text-fg-disabled mb-2 font-korean">
</div>
{[
@ -553,7 +549,7 @@ function AssetManagement() {
].map(([k, v], j) => (
<div
key={j}
className="flex justify-between py-1.5 text-[11px] border-b border-stroke/30 last:border-b-0"
className="flex justify-between py-1.5 text-label-2 border-b border-stroke last:border-b-0"
>
<span className="text-fg-disabled font-korean shrink-0 mr-2">{k}</span>
<span
@ -568,7 +564,7 @@ function AssetManagement() {
{/* 담당자 목록 */}
{selectedOrg.contacts.length > 0 && (
<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 className="text-caption font-bold text-fg-disabled mb-2 font-korean">
</div>
{selectedOrg.contacts.map((c, i) => (
@ -582,7 +578,7 @@ function AssetManagement() {
.map(([k, v], j) => (
<div
key={j}
className="flex justify-between py-1.5 text-[11px] border-b border-stroke/30 last:border-b-0"
className="flex justify-between py-1.5 text-label-2 border-b border-stroke last:border-b-0"
>
<span className="text-fg-disabled font-korean">{k}</span>
<span
@ -604,19 +600,14 @@ function AssetManagement() {
</div>
{/* Bottom Actions */}
<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))',
}}
>
{/* <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 bg-color-accent border-none cursor-pointer hover:opacity-90 transition-opacity">
📥
</button>
<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>
</div> */}
</aside>
)}
</div>

파일 보기

@ -1,45 +1,45 @@
import { useMemo, useCallback, useEffect, useRef } from 'react'
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer } from '@deck.gl/layers'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
import { useMapStore } from '@common/store/mapStore'
import type { AssetOrgCompat } from '../services/assetsApi'
import { typeColor } from './assetTypes'
import { hexToRgba } from '@common/components/map/mapUtils'
import { useMemo, useCallback, useEffect, useRef } from 'react';
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { ScatterplotLayer } from '@deck.gl/layers';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import type { AssetOrgCompat } from '../services/assetsApi';
import { typeColor } from './assetTypes';
import { hexToRgba } from '@common/components/map/mapUtils';
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// ── FlyTo Controller ────────────────────────────────────
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
const { current: map } = useMap();
const prevIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!map) return
if (!map) return;
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 })
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 });
}
prevIdRef.current = selectedOrg.id
}, [map, selectedOrg])
prevIdRef.current = selectedOrg.id;
}, [map, selectedOrg]);
return null
return null;
}
interface AssetMapProps {
organizations: AssetOrgCompat[]
selectedOrg: AssetOrgCompat
onSelectOrg: (o: AssetOrgCompat) => void
regionFilter: string
onRegionFilterChange: (v: string) => void
organizations: AssetOrgCompat[];
selectedOrg: AssetOrgCompat;
onSelectOrg: (o: AssetOrgCompat) => void;
regionFilter: string;
onRegionFilterChange: (v: string) => void;
}
function AssetMap({
@ -49,15 +49,15 @@ function AssetMap({
regionFilter,
onRegionFilterChange,
}: AssetMapProps) {
const currentMapStyle = useBaseMapStyle()
const mapToggles = useMapStore((s) => s.mapToggles)
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const handleClick = useCallback(
(org: AssetOrgCompat) => {
onSelectOrg(org)
onSelectOrg(org);
},
[onSelectOrg],
)
);
const markerLayer = useMemo(() => {
return new ScatterplotLayer({
@ -65,19 +65,19 @@ function AssetMap({
data: orgs,
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
getRadius: (d: AssetOrgCompat) => {
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7
const isSelected = selectedOrg.id === d.id
return isSelected ? baseRadius + 4 : baseRadius
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
const isSelected = selectedOrg.id === d.id;
return isSelected ? baseRadius + 4 : baseRadius;
},
getFillColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type)
const isSelected = selectedOrg.id === d.id
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178)
const tc = typeColor(d.type);
const isSelected = selectedOrg.id === d.id;
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
},
getLineColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type)
const isSelected = selectedOrg.id === d.id
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200)
const tc = typeColor(d.type);
const isSelected = selectedOrg.id === d.id;
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
},
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
stroked: true,
@ -86,7 +86,7 @@ function AssetMap({
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: AssetOrgCompat }) => {
if (info.object) handleClick(info.object)
if (info.object) handleClick(info.object);
},
updateTriggers: {
getRadius: [selectedOrg.id],
@ -94,8 +94,8 @@ function AssetMap({
getLineColor: [selectedOrg.id],
getLineWidth: [selectedOrg.id],
},
})
}, [orgs, selectedOrg, handleClick])
});
}, [orgs, selectedOrg, handleClick]);
return (
<div className="w-full h-full relative">
@ -119,13 +119,13 @@ function AssetMap({
{ value: '중부', label: '중부청' },
{ value: '동해', label: '동해청' },
{ value: '제주', label: '제주청' },
].map(r => (
].map((r) => (
<button
key={r.value}
onClick={() => onRegionFilterChange(r.value)}
className={`px-2.5 py-1.5 text-[10px] font-bold rounded font-korean transition-colors ${
className={`px-2.5 py-1.5 text-caption font-bold rounded font-korean transition-colors ${
regionFilter === r.value
? 'bg-color-accent/20 text-color-accent border border-color-accent/40'
? 'bg-[color-mix(in_srgb,var(--color-accent)_20%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_40%,transparent)]'
: 'bg-bg-base/80 text-fg-sub border border-stroke hover:bg-bg-surface-hover/80'
}`}
>
@ -136,28 +136,31 @@ function AssetMap({
{/* Legend overlay */}
<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>
<div className="text-caption text-fg-disabled font-bold mb-1.5 font-korean"></div>
{[
{ color: '#06b6d4', label: '해경관할' },
{ color: '#3b82f6', label: '해경경찰서' },
{ color: '#22c55e', label: '파출소' },
{ color: '#a855f7', label: '관련기관' },
{ color: '#14b8a6', label: '해양환경공단' },
{ color: '#f59e0b', label: '업체' },
{ color: '#ec4899', label: '지자체' },
{ color: '#8b5cf6', label: '기름저장시설' },
{ color: '#0d9488', label: '정유사' },
{ color: '#64748b', label: '해군' },
{ color: '#6b7280', label: '기타' },
].map((item, i) => (
'해경관할',
'해경경찰서',
'파출소',
'관련기관',
'해양환경공단',
'업체',
'지자체',
'기름저장시설',
'정유사',
'해군',
'기타',
].map((label, 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-fg-sub font-korean">{item.label}</span>
<span
className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0"
style={{ background: typeColor(label).border }}
/>
<span className="text-caption text-fg-sub font-korean">{label}</span>
</div>
))}
</div>
</div>
)
);
}
export default AssetMap
export default AssetMap;

파일 보기

@ -2,15 +2,11 @@ interface TheoryItem {
title: string;
source: string;
desc: string;
tags?: { label: string; color: string }[];
highlight?: boolean;
tags?: string[];
}
interface TheorySection {
icon: string;
title: string;
color: string;
bgTint: string;
items: TheoryItem[];
dividerAfter?: number;
dividerLabel?: string;
@ -18,10 +14,7 @@ interface TheorySection {
const THEORY_SECTIONS: TheorySection[] = [
{
icon: '🚢',
title: '방제선 성능 기준',
color: 'var(--color-info)',
bgTint: 'rgba(59,130,246,.08)',
items: [
{
title: '해양경찰청 방제선 성능기준 고시',
@ -43,10 +36,7 @@ const THEORY_SECTIONS: TheorySection[] = [
],
},
{
icon: '🪢',
title: '오일펜스·흡착재 규격',
color: 'var(--boom, #f59e0b)',
bgTint: 'rgba(245,158,11,.08)',
items: [
{
title: 'ASTM F625 — Standard Guide for Selecting Mechanical Oil Spill Equipment',
@ -61,12 +51,9 @@ const THEORY_SECTIONS: TheorySection[] = [
],
},
{
icon: '⚙️',
title: '방제자원 배치·동원 이론',
color: 'var(--color-tertiary)',
bgTint: 'rgba(168,85,247,.08)',
dividerAfter: 2,
dividerLabel: '📐 최적화 수리모델 참고문헌',
dividerLabel: '최적화 수리모델 참고문헌',
items: [
{
title:
@ -74,7 +61,6 @@ const THEORY_SECTIONS: TheorySection[] = [
source:
'Xu, Y. et al. | Ningbo Univ. | Systems 2025, 13, 716 · DOI: 10.3390/systems13080716',
desc: 'IMOGWO 다목적 최적화 · 스케줄링 시간+경제·생태손실 동시 최소화 · 동적 오일필름 기반 방제정 라우팅',
highlight: true,
},
{
title: 'Dynamic Resource Allocation to Support Oil Spill Response Planning',
@ -92,54 +78,32 @@ const THEORY_SECTIONS: TheorySection[] = [
source:
'Das, T., Goerlandt, F. & Pelot, R. | Multimodal Transportation Vol.3 No.1, 100110, 2023',
desc: '혼합정수계획법으로 응급 방제자원 거점 위치 선택 + 자원 할당 동시 최적화. 비용·응답시간 트레이드오프 파레토 분석.',
highlight: true,
tags: [
{ label: 'MIP 수리모델', color: 'var(--color-tertiary)' },
{ label: '자원 위치 선택', color: 'var(--color-info)' },
{ label: '북극해 적용', color: 'var(--color-accent)' },
],
tags: ['MIP 수리모델', '자원 위치 선택', '북극해 적용'],
},
{
title: '유전알고리즘을 이용하여 최적화된 방제자원 배치안의 분포도 분석',
source: '김혜진, 김용혁 | 한국융합학회논문지 Vol.11 No.4, pp.1116, 2020',
desc: 'GA(유전알고리즘)로 방제자원 배치 최적화 및 시뮬레이션 분포도 분석. 국내 해역 실정에 맞는 자원 배치 패턴 도출.',
highlight: true,
tags: [
{ label: 'GA 메타휴리스틱', color: 'var(--color-tertiary)' },
{ label: '국내 연구', color: 'var(--green, #22c55e)' },
{ label: '배치 분포도 분석', color: 'var(--boom, #f59e0b)' },
],
tags: ['GA 메타휴리스틱', '국내 연구', '배치 분포도 분석'],
},
{
title:
'A Two-Stage Stochastic Optimization Framework for Environmentally Sensitive Oil Spill Response Resource Allocation',
source: 'Rahman, M.A., Kuhel, M.T. & Novoa, C. | arXiv preprint arXiv:2511.22218, 2025',
desc: '확률적 MILP 2단계 프레임워크로 불확실성 포함 최적 자원 배치. 환경민감구역 가중치 반영.',
highlight: true,
tags: [
{ label: '확률적 MILP', color: 'var(--color-tertiary)' },
{ label: '2단계 최적화', color: 'var(--color-info)' },
{ label: '환경민감구역', color: 'var(--green, #22c55e)' },
],
tags: ['확률적 MILP', '2단계 최적화', '환경민감구역'],
},
{
title:
'Mixed-Integer Dynamic Optimization for Oil-Spill Response Planning with Integration of Dynamic Oil Weathering Model',
source: 'You, F. & Leyffer, S. | Argonne National Laboratory Technical Note, 2008',
desc: '동적 최적화(MINLP/MILP) 프레임워크로 오일스필 대응 스케줄링 + 오일 풍화·거동 물리모델 통합.',
highlight: true,
tags: [
{ label: 'MINLP 동적 최적화', color: 'var(--color-tertiary)' },
{ label: '오일 풍화 모델 통합', color: 'var(--boom, #f59e0b)' },
],
tags: ['MINLP 동적 최적화', '오일 풍화 모델 통합'],
},
],
},
{
icon: '🗄',
title: '자산 현행화·데이터 관리',
color: 'var(--green, #22c55e)',
bgTint: 'rgba(34,197,94,.08)',
items: [
{
title: '해양오염방제자원 현황관리 지침',
@ -155,105 +119,33 @@ const THEORY_SECTIONS: TheorySection[] = [
},
];
const TAG_COLORS: Record<string, { bg: string; bd: string; fg: string }> = {
'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',
},
};
function TheoryCard({ section }: { section: TheorySection }) {
const badgeBg = section.bgTint.replace(/[\d.]+\)$/, '0.15)');
return (
<div className="bg-bg-card border border-stroke rounded-md overflow-hidden">
{/* Section Header */}
<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}
</span>
<div className="px-4 py-3 border-b border-stroke">
<span className="text-label-1 font-semibold text-fg font-korean">{section.title}</span>
</div>
{/* Items */}
<div className="px-4 py-3.5 flex flex-col gap-2 text-[9px]">
<div className="px-4 py-3.5 flex flex-col gap-2 text-caption">
{section.items.map((item, i) => (
<div key={i}>
{/* Divider */}
{section.dividerAfter !== undefined && i === section.dividerAfter + 1 && (
<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 }}
>
<div className="mt-1 mb-3 pt-2 border-t border-dashed border-stroke">
<div className="text-caption font-semibold text-fg mb-1.5">
{section.dividerLabel}
</div>
</div>
)}
<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,
}}
>
{/* Number badge */}
<div
className="w-5 h-5 rounded flex items-center justify-center text-[9px] shrink-0"
style={{
background: badgeBg,
fontWeight: item.highlight ? 700 : 400,
color: item.highlight ? section.color : undefined,
}}
>
{['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'][i]}
</div>
<div>
<div className="font-bold mb-0.5">{item.title}</div>
<div className="px-2.5 py-2 bg-bg-base rounded-md">
<div className="font-semibold mb-0.5">{item.title}</div>
<div className="text-fg-disabled leading-[1.6]">{item.source}</div>
{/* Tags */}
{item.tags && (
<div className="mt-0.5 flex flex-wrap gap-0.5">
{item.tags.map((tag, ti) => {
const tc = TAG_COLORS[tag.color] || {
bg: 'rgba(107,114,128,0.08)',
bd: 'rgba(107,114,128,0.2)',
fg: '#6b7280',
};
return (
<span
key={ti}
className="px-1 py-px rounded text-[8px]"
style={{
color: tc.fg,
background: tc.bg,
border: `1px solid ${tc.bd}`,
}}
>
{tag.label}
</span>
);
})}
</div>
)}
{item.tags && <div className="mt-0.5 text-fg-disabled">{item.tags.join(' | ')}</div>}
<div className="mt-0.5 text-fg-sub">{item.desc}</div>
</div>
</div>
</div>
))}
</div>
</div>
@ -263,8 +155,8 @@ function TheoryCard({ section }: { section: TheorySection }) {
function AssetTheory() {
return (
<div className="flex flex-col gap-0">
<div className="text-[18px] font-bold mb-1">📚 </div>
<div className="text-xs text-fg-disabled mb-6">
<div className="text-title-1 font-bold mb-1">📚 </div>
<div className="text-caption text-fg-disabled mb-6">
· ·
</div>

파일 보기

@ -22,19 +22,19 @@ function AssetUpload() {
<div className="flex gap-8 h-full overflow-auto">
{/* Left - Upload */}
<div className="flex-1 max-w-[580px]">
<div className="text-[13px] font-bold mb-3.5 font-korean">📤 </div>
<div className="text-title-4 font-bold mb-3.5 font-korean">📤 </div>
{/* Drop Zone */}
<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="border-2 border-dashed border-stroke-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-[rgba(6,182,212,0.4)] 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-fg-disabled mb-4 font-korean">
<div className="text-label-2 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"
className="px-7 py-2.5 text-title-4 font-semibold rounded-sm text-white border-none cursor-pointer font-korean"
style={{ background: 'linear-gradient(135deg, var(--color-info), #2563eb)' }}
>
@ -120,7 +120,7 @@ function AssetUpload() {
{/* Right - Permission & History */}
<div className="flex-1 max-w-[480px]">
{/* Permission System */}
<div className="text-[13px] font-bold mb-3.5 font-korean">🔐 </div>
<div className="text-title-4 font-bold mb-3.5 font-korean">🔐 </div>
<div className="flex flex-col gap-2 mb-7">
{[
{
@ -164,14 +164,14 @@ function AssetUpload() {
</div>
<div>
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
<div className="text-[10px] text-fg-disabled font-korean">{p.desc}</div>
<div className="text-caption text-fg-disabled font-korean">{p.desc}</div>
</div>
</div>
))}
</div>
{/* Upload History */}
<div className="text-[13px] font-bold mb-3.5 font-korean">📋 </div>
<div className="text-title-4 font-bold mb-3.5 font-korean">📋 </div>
<div className="flex flex-col gap-2">
{uploadHistory.map((h) => (
<div
@ -180,11 +180,11 @@ function AssetUpload() {
>
<div>
<div className="text-xs font-semibold font-korean">{h.fileNm}</div>
<div className="text-[10px] text-fg-disabled mt-0.5 font-korean">
<div className="text-caption 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-color-success">
<span className="px-2 py-0.5 rounded-full text-caption font-semibold bg-[rgba(34,197,94,0.15)] text-color-success">
</span>
</div>

파일 보기

@ -19,10 +19,10 @@ export function AssetsView() {
<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: '자산 관리' },
// { id: 'upload' as const, icon: '📤', label: '자산 현행화 (업로드)' },
{ id: 'theory' as const, icon: '📚', label: '방제자원 이론' },
{ id: 'insurance' as const, icon: '🛡', label: '선박 보험정보' },
{ id: 'management' as const, label: '자산 관리' },
// { id: 'upload' as const, label: '자산 현행화 (업로드)' },
{ id: 'theory' as const, label: '방제자원 이론' },
{ id: 'insurance' as const, label: '선박 보험정보' },
].map((tab) => (
<button
key={tab.id}
@ -33,16 +33,16 @@ export function AssetsView() {
: 'text-fg-disabled border-transparent hover:text-fg-sub'
}`}
>
{tab.icon} {tab.label}
{tab.label}
</button>
))}
</div>
<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
className="flex items-center gap-1.5 px-3.5 py-1.5 border rounded-full text-label-2 text-color-info font-korean mr-4"
style={{ borderColor: 'color-mix(in srgb, var(--color-info) 30%, transparent)' }}
>
👤 _방제과 ( )
</div>
</div> */}
</div>
{/* Content */}

파일 보기

@ -60,10 +60,12 @@ function ShipInsurance() {
const isY = yn === 'Y';
return (
<span
className="px-1.5 py-0.5 rounded text-[9px] font-bold"
className="px-1.5 py-0.5 rounded text-caption font-bold"
style={{
background: isY ? 'rgba(34,197,94,.15)' : 'rgba(100,116,139,.1)',
color: isY ? 'var(--color-success)' : 'var(--text-3)',
background: isY
? 'color-mix(in srgb, var(--color-success) 15%, transparent)'
: 'color-mix(in srgb, var(--stroke-default) 60%, transparent)',
color: isY ? 'var(--color-success)' : 'var(--text-fg-disabled)',
}}
>
{isY ? 'Y' : 'N'}
@ -131,17 +133,31 @@ function ShipInsurance() {
return (
<div className="flex flex-col flex-1 overflow-auto">
{/* 푸터 */}
<div className="mt-auto px-4 py-3 border border-stroke rounded-sm mb-6">
<div className="text-caption text-fg-disabled leading-[1.7]">
<span className="text-fg-sub"> :</span> ·
<br />
<span className="text-fg-sub">:</span> , , ,
, , , ,
</div>
</div>
{/* 헤더 */}
<div className="flex items-start justify-between mb-5">
<div>
<div className="flex items-center gap-2.5 mb-1">
<div className="text-[18px] font-bold"> </div>
<div className="text-title-1 font-bold"> </div>
<div
className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-bold"
className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-caption font-bold"
style={{
background: total > 0 ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
background:
total > 0
? 'color-mix(in srgb, var(--color-success) 12%, transparent)'
: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
color: total > 0 ? 'var(--color-success)' : 'var(--color-danger)',
border: `1px solid ${total > 0 ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
border: `1px solid ${total > 0 ? 'color-mix(in srgb, var(--color-success) 25%, transparent)' : 'color-mix(in srgb, var(--color-danger) 25%, transparent)'}`,
}}
>
<span
@ -158,23 +174,13 @@ function ShipInsurance() {
<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(--color-info)',
border: '1px solid rgba(59,130,246,.3)',
}}
className="px-4 py-2 text-label-2 font-bold cursor-pointer rounded-sm bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
>
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(--color-tertiary)',
border: '1px solid rgba(168,85,247,.3)',
}}
className="px-4 py-2 text-label-2 font-bold cursor-pointer rounded-sm bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
>
PortMIS
</button>
@ -182,10 +188,10 @@ function ShipInsurance() {
</div>
{/* 필터 */}
<div className="bg-bg-card border border-stroke rounded-md px-5 py-4 mb-4">
<div className="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-fg-disabled mb-1">
<label className="block text-caption font-semibold text-fg-disabled mb-1">
(//IMO/)
</label>
<input
@ -198,7 +204,7 @@ function ShipInsurance() {
/>
</div>
<div>
<label className="block text-[10px] font-semibold text-fg-disabled mb-1">
<label className="block text-caption font-semibold text-fg-disabled mb-1">
</label>
<select
@ -212,7 +218,7 @@ function ShipInsurance() {
</select>
</div>
<div>
<label className="block text-[10px] font-semibold text-fg-disabled mb-1">
<label className="block text-caption font-semibold text-fg-disabled mb-1">
</label>
<select
@ -237,28 +243,20 @@ function ShipInsurance() {
</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(--color-accent), var(--color-info))',
}}
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-xs cursor-pointer"
>
</button>
<button
onClick={handleReset}
className="px-4 py-2 bg-bg-base text-fg-sub border border-stroke rounded-sm text-xs cursor-pointer"
className="px-4 py-2 bg-bg-base text-fg 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)',
}}
className="px-4 py-2 text-xs cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default bg-bg-base border border-stroke"
>
</button>
@ -276,7 +274,7 @@ function ShipInsurance() {
animation: 'spin 0.8s linear infinite',
}}
/>
<div className="text-[13px] text-fg-sub"> ...</div>
<div className="text-title-4 text-fg-sub"> ...</div>
</div>
)}
@ -291,7 +289,7 @@ function ShipInsurance() {
{/* 테이블 */}
{!isLoading && !error && (
<>
<div className="bg-bg-card border border-stroke rounded-md overflow-hidden mb-3">
<div className="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-color-accent">{total.toLocaleString()}</span>
@ -303,7 +301,7 @@ function ShipInsurance() {
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[11px] border-collapse whitespace-nowrap">
<table className="w-full text-label-2 border-collapse whitespace-nowrap">
<thead>
<tr className="bg-bg-base">
{[
@ -342,18 +340,22 @@ function ShipInsurance() {
<tr
key={r.insSn}
className="border-b border-stroke"
style={{ background: isExp ? 'rgba(239,68,68,.03)' : undefined }}
style={{
background: isExp
? 'color-mix(in srgb, var(--color-danger) 3%, transparent)'
: 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">{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>
<span className="text-caption">{r.shipTp}</span>
{r.shipTpDetail && (
<span className="text-fg-disabled text-[9px] ml-1">
<span className="text-fg-disabled text-caption ml-1">
({r.shipTpDetail})
</span>
)}
@ -368,28 +370,27 @@ function ShipInsurance() {
<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]"
className="px-3 py-2 text-center font-mono text-caption"
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 text-caption">{r.issueOrg}</td>
<td className="px-3 py-2 text-center">
<span
className="px-2 py-0.5 rounded-full text-[9px] font-semibold"
className="px-2 py-0.5 rounded-full text-caption font-semibold"
style={{
background: isExp
? 'rgba(239,68,68,.15)'
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
: isSoon
? 'rgba(234,179,8,.15)'
: 'rgba(34,197,94,.15)',
? 'color-mix(in srgb, var(--color-caution) 15%, transparent)'
: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
color: isExp
? 'var(--color-danger)'
: isSoon
@ -414,7 +415,7 @@ function ShipInsurance() {
<button
onClick={() => loadData(page - 1)}
disabled={page <= 1}
className="px-3 py-1.5 text-[11px] rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
className="px-3 py-1.5 text-label-2 rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
>
</button>
@ -426,13 +427,7 @@ function ShipInsurance() {
<button
key={p}
onClick={() => loadData(p)}
className="w-8 h-8 text-[11px] rounded-sm border cursor-pointer font-mono"
style={{
background: p === page ? 'var(--color-accent)' : 'var(--bg-0)',
color: p === page ? '#fff' : 'var(--text-2)',
borderColor: p === page ? 'var(--color-accent)' : 'var(--stroke-default)',
fontWeight: p === page ? 700 : 400,
}}
className={`w-8 h-8 text-label-2 rounded-sm border cursor-pointer font-mono ${p === page ? 'bg-color-accent text-white border-color-accent font-bold' : 'bg-bg-base text-fg-sub border-stroke'}`}
>
{p}
</button>
@ -441,7 +436,7 @@ function ShipInsurance() {
<button
onClick={() => loadData(page + 1)}
disabled={page >= totalPages}
className="px-3 py-1.5 text-[11px] rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
className="px-3 py-1.5 text-label-2 rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
>
</button>
@ -449,17 +444,6 @@ function ShipInsurance() {
)}
</>
)}
{/* 푸터 */}
<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,18 +34,19 @@ export interface InsuranceRow {
}
export const typeTagCls = (type: string) => {
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]';
if (type === '해군') return 'bg-[rgba(100,116,139,0.1)] text-[#64748b]';
if (type === '기타') return 'bg-[rgba(107,114,128,0.1)] text-[#6b7280]';
return 'bg-[rgba(156,163,175,0.1)] text-[#9ca3af]';
if (type === '해경관할')
return 'bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] text-color-danger';
if (type === '해경경찰서')
return 'bg-[color-mix(in_srgb,var(--color-info)_10%,transparent)] text-color-info';
if (type === '파출소')
return 'bg-[color-mix(in_srgb,var(--color-success)_10%,transparent)] text-color-success';
if (type === '관련기관')
return 'bg-[color-mix(in_srgb,var(--color-tertiary)_10%,transparent)] text-color-tertiary';
if (type === '해양환경공단')
return 'bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] text-color-accent';
if (type === '업체')
return 'bg-[color-mix(in_srgb,var(--color-warning)_10%,transparent)] text-color-warning';
return 'bg-[color-mix(in_srgb,var(--color-info)_10%,transparent)] text-color-info';
};
export const typeColor = (type: string) => {

파일 보기

@ -10,12 +10,12 @@ const CATEGORY_LABELS: Record<string, string> = {
MANUAL: '해경매뉴얼',
};
// 카테고리별 배지 색상
// 카테고리별 배지 색상 (NOTICE는 danger, 나머지는 중립)
const CATEGORY_COLORS: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-green-500/20 text-green-400',
QNA: 'bg-purple-500/20 text-purple-400',
MANUAL: 'bg-blue-500/20 text-blue-400',
NOTICE: 'bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] text-color-danger',
DATA: 'bg-bg-elevated text-fg-sub',
QNA: 'bg-bg-elevated text-fg-sub',
MANUAL: 'bg-bg-elevated text-fg-sub',
};
interface BoardDetailViewProps {
@ -55,7 +55,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
if (isLoading || !post) {
return (
<div className="flex flex-col h-full bg-bg-base items-center justify-center">
<p className="text-fg-disabled text-sm"> ...</p>
<p className="text-fg-disabled text-label-1"> ...</p>
</div>
);
}
@ -69,7 +69,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
<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-fg-sub hover:text-fg transition-colors"
className="flex items-center gap-2 text-label-1 font-semibold text-fg-sub hover:text-fg transition-colors"
>
<span></span>
<span></span>
@ -78,13 +78,13 @@ 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-elevated text-fg border border-stroke hover:bg-bg-card transition-colors"
className="px-4 py-2 text-label-1 font-semibold rounded text-fg bg-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] cursor-pointer"
>
</button>
<button
onClick={onDelete}
className="px-4 py-2 text-sm font-semibold rounded bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
className="px-4 py-2 text-label-1 font-semibold rounded text-fg bg-[color-mix(in_srgb,var(--color-danger)_30%,transparent)] border border-[color-mix(in_srgb,var(--color-danger)_30%,transparent)] cursor-pointer"
>
</button>
@ -99,20 +99,20 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
<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'}`}
className={`inline-flex items-center px-2.5 py-0.5 rounded text-caption font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-bg-elevated text-fg-sub'}`}
>
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
</span>
{post.pinnedYn === 'Y' && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-yellow-500/20 text-yellow-400">
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-caption font-semibold bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent">
📌
</span>
)}
</div>
<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">
<h1 className="text-title-1 font-bold text-fg mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-label-1 text-fg-disabled">
<span>
: <span className="text-fg-sub font-semibold">{post.authorName}</span>
: <span className="text-fg-sub">{post.authorName}</span>
</span>
<span>|</span>
<span>: {new Date(post.regDtm).toLocaleDateString('ko-KR')}</span>
@ -130,7 +130,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
{/* 본문 */}
<div className="py-8">
<div className="prose prose-invert max-w-none">
<div className="text-fg text-[15px] leading-relaxed whitespace-pre-wrap">
<div className="text-fg text-subtitle leading-relaxed whitespace-pre-wrap">
{post.content || '(내용 없음)'}
</div>
</div>
@ -139,7 +139,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
{/* 댓글 섹션 (향후 구현 예정) */}
<div className="py-6 border-t border-stroke">
<div className="text-center py-8">
<p className="text-fg-disabled text-sm"> .</p>
<p className="text-fg-disabled text-label-1"> .</p>
</div>
</div>
</div>

파일 보기

@ -18,10 +18,10 @@ const CATEGORY_FILTER: { label: string; code: string | null }[] = [
];
const CATEGORY_STYLE: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-blue-500/20 text-blue-400',
QNA: 'bg-green-500/20 text-green-400',
MANUAL: 'bg-yellow-500/20 text-yellow-400',
NOTICE: 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent',
DATA: 'bg-bg-elevated text-fg-sub',
QNA: 'bg-bg-elevated text-fg-sub',
MANUAL: 'bg-bg-elevated text-fg-sub',
};
const PAGE_SIZE = 20;
@ -104,9 +104,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button
key={cat.label}
onClick={() => handleCategoryChange(cat.code)}
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
className={`px-4 py-2 text-label-1 font-semibold rounded transition-all ${
selectedCategory === cat.code
? 'bg-color-accent text-bg-0'
? 'bg-color-accent text-white'
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
}`}
>
@ -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-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none w-64"
className="px-4 py-2 text-label-1 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-color-accent text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] flex items-center gap-2"
>
<span>+</span>
<span></span>
@ -142,27 +142,29 @@ 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-fg-disabled text-sm"> ...</p>
<p className="text-fg-disabled text-label-1"> ...</p>
</div>
) : (
<>
<table className="w-full border-collapse">
<thead>
<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 className="px-4 py-3 text-left text-label-1 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 className="px-4 py-3 text-left text-label-1 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 className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub">
</th>
<th className="px-4 py-3 text-left text-label-1 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 className="px-4 py-3 text-left text-label-1 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 className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub w-24">
</th>
</tr>
@ -174,9 +176,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
onClick={() => onPostClick(post.sn)}
className="border-b border-stroke hover:bg-bg-elevated cursor-pointer transition-colors"
>
<td className="px-4 py-4 text-sm text-fg">
<td className="px-4 py-4 text-label-1 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">
<span className="inline-flex items-center px-2 py-0.5 rounded text-caption font-semibold bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent">
</span>
) : (
@ -185,27 +187,23 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
</td>
<td className="px-4 py-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
className={`inline-flex items-center px-2.5 py-0.5 rounded text-caption font-semibold ${
CATEGORY_STYLE[post.categoryCd] || 'bg-bg-elevated text-fg-sub'
}`}
>
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
</span>
</td>
<td className="px-4 py-4">
<span
className={`text-sm ${
post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'
} hover:text-color-accent transition-colors`}
>
<span className="text-label-1 text-fg hover:text-color-accent transition-colors">
{post.title}
</span>
</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">
<td className="px-4 py-4 text-label-1 text-fg-sub">{post.authorName}</td>
<td className="px-4 py-4 text-label-1 text-fg-disabled">
{formatDate(post.regDtm)}
</td>
<td className="px-4 py-4 text-sm text-fg-disabled">{post.viewCnt}</td>
<td className="px-4 py-4 text-label-1 text-fg-disabled">{post.viewCnt}</td>
</tr>
))}
</tbody>
@ -213,7 +211,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
{posts.length === 0 && (
<div className="text-center py-20">
<p className="text-fg-disabled text-sm"> .</p>
<p className="text-fg-disabled text-label-1"> .</p>
</div>
)}
</>
@ -226,7 +224,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
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"
className="px-3 py-1.5 text-label-1 rounded bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
>
</button>
@ -234,9 +232,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button
key={p}
onClick={() => setPage(p)}
className={`px-3 py-1.5 text-sm rounded ${
className={`px-3 py-1.5 text-label-1 rounded ${
page === p
? 'bg-color-accent text-bg-0 font-semibold'
? 'bg-color-accent text-white font-semibold'
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors'
}`}
>
@ -246,7 +244,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-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
className="px-3 py-1.5 text-label-1 rounded bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
>
</button>

파일 보기

@ -33,12 +33,12 @@ const CATEGORY_LABELS: Record<string, string> = {
MANUAL: '해경매뉴얼',
};
// 카테고리별 배지 색상
// 카테고리별 배지 색상 (NOTICE는 danger, 나머지 중립)
const CATEGORY_COLORS: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-green-500/20 text-green-400',
QNA: 'bg-purple-500/20 text-purple-400',
MANUAL: 'bg-blue-500/20 text-blue-400',
NOTICE: 'bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] text-color-danger',
DATA: 'bg-bg-elevated text-fg-sub',
QNA: 'bg-bg-elevated text-fg-sub',
MANUAL: 'bg-bg-elevated text-fg-sub',
};
const PAGE_SIZE = 20;
@ -91,6 +91,13 @@ export function BoardView() {
setPage(1);
}, [activeSubTab]);
// 서브탭 변경 시 목록 화면으로 복귀
useEffect(() => {
setViewMode('list');
setSelectedPostSn(null);
setEditingPostSn(null);
}, [activeSubTab]);
// 상세 보기
const handlePostClick = (sn: number) => {
setSelectedPostSn(sn);
@ -196,19 +203,18 @@ export function BoardView() {
const filteredManuals = manualList;
// 카테고리별 색상 (방제매뉴얼만 accent, 나머지 중립)
const catColor = (cat: string) => {
switch (cat) {
case '방제매뉴얼':
return { bg: 'rgba(6,182,212,.15)', text: '#22d3ee' };
case '대응매뉴얼':
return { bg: 'rgba(249,115,22,.15)', text: '#f97316' };
case '교육자료':
return { bg: 'rgba(34,197,94,.15)', text: '#22c55e' };
case '법령·규정':
return { bg: 'rgba(168,85,247,.15)', text: '#a855f7' };
default:
return { bg: 'rgba(100,100,100,.15)', text: '#999' };
if (cat === '방제매뉴얼') {
return {
bg: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
text: 'var(--color-accent)',
};
}
return {
bg: 'var(--bg-elevated)',
text: 'var(--fg-sub)',
};
};
if (activeSubTab === 'manual') {
@ -217,38 +223,31 @@ export function BoardView() {
<div className="flex-1 relative overflow-hidden">
<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(--stroke-default)', background: 'var(--bg-surface)' }}
>
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-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-fg-disabled">
<span className="text-subtitle font-bold"></span>
<span className="text-caption ml-1 text-fg-disabled">
{filteredManuals.length}
</span>
</div>
<div className="flex gap-1 ml-4">
{manualCategories.map((cat) => (
{manualCategories.map((cat) => {
const isActive = manualCategory === cat;
return (
<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(--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)',
}}
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all border ${
isActive
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] text-color-accent'
: 'bg-bg-card border-stroke text-fg-disabled'
}`}
>
{cat}
</button>
))}
);
})}
</div>
</div>
<div className="flex items-center gap-3">
@ -257,24 +256,13 @@ export function BoardView() {
placeholder="매뉴얼 검색..."
value={manualSearch}
onChange={(e) => setManualSearch(e.target.value)}
className="px-4 py-2 text-sm rounded w-64"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
outline: 'none',
}}
className="px-4 py-2 text-label-1 rounded w-64 bg-bg-elevated border border-stroke 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',
}}
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
>
📤
</button>
</div>
</div>
@ -283,7 +271,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-fg-disabled"> ...</p>
<p className="text-label-1 text-fg-disabled"> ...</p>
</div>
) : (
<div
@ -295,52 +283,29 @@ export function BoardView() {
return (
<div
key={file.manualSn}
className="rounded-xl p-4 transition-all"
style={{
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(--stroke-default)';
}}
className="rounded-xl p-4 transition-all bg-bg-card border border-stroke cursor-pointer hover:border-[color-mix(in_srgb,var(--color-accent)_40%,transparent)]"
>
<div className="flex items-center justify-between mb-3">
<span
className="px-2 py-0.5 rounded text-[10px] font-semibold"
className="px-2 py-0.5 rounded text-caption font-semibold"
style={{ background: cc.bg, color: cc.text }}
>
{file.catgNm}
</span>
<span
className="text-[10px] font-semibold px-2 py-0.5 rounded"
style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}
>
<span className="text-caption font-semibold px-2 py-0.5 rounded bg-bg-elevated text-fg-sub">
{file.version}
</span>
</div>
<div className="text-[12px] font-bold mb-3 leading-[1.5]">{file.title}</div>
<div className="text-label-1 font-bold mb-3 leading-[1.5]">
{file.title}
</div>
<div className="flex items-center gap-2 mb-3">
<div
className="flex items-center gap-1.5 px-2 py-1 rounded"
style={{ background: 'rgba(239,68,68,.08)' }}
>
<span style={{ fontSize: 12 }}>📄</span>
<span
className="text-[10px] font-semibold"
style={{ color: '#ef4444' }}
>
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-bg-elevated">
<span className="text-caption font-semibold text-fg-sub">
{file.fileTp || 'PDF'}
</span>
</div>
<span
className="text-[10px]"
style={{ color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}
>
<span className="text-caption text-fg-disabled font-mono">
{file.fileSz}
</span>
</div>
@ -358,16 +323,10 @@ export function BoardView() {
});
setShowUploadModal(true);
}}
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
style={{
background: 'rgba(59,130,246,.1)',
border: '1px solid rgba(59,130,246,.2)',
color: '#3b82f6',
cursor: 'pointer',
}}
className="px-2 py-0.5 rounded text-caption font-semibold transition-all bg-bg-elevated border border-stroke text-fg-sub cursor-pointer hover:bg-bg-card"
title="수정"
>
</button>
<button
onClick={async (e) => {
@ -384,34 +343,19 @@ export function BoardView() {
}
}
}}
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
style={{
background: 'rgba(239,68,68,.1)',
border: '1px solid rgba(239,68,68,.2)',
color: '#ef4444',
cursor: 'pointer',
}}
className="px-2 py-0.5 rounded text-caption font-semibold transition-all bg-bg-elevated border border-stroke text-fg-sub cursor-pointer hover:bg-bg-card"
title="삭제"
>
🗑
</button>
</div>
<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">
<div className="flex items-center justify-between pt-3 border-t border-stroke">
<div className="flex items-center gap-3 text-caption 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(--fg-disabled)',
fontFamily: 'var(--font-mono)',
}}
>
<span className="text-caption text-fg-disabled font-mono">
{file.dwnldCnt}
</span>
<button
@ -457,15 +401,9 @@ export function BoardView() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
className="px-3 py-1 rounded text-[10px] font-semibold transition-all"
style={{
background: 'rgba(6,182,212,.1)',
border: '1px solid rgba(6,182,212,.25)',
color: '#22d3ee',
cursor: 'pointer',
}}
className="px-3 py-1 rounded text-caption font-semibold transition-all cursor-pointer bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_25%,transparent)] text-color-accent"
>
📥
</button>
</div>
</div>
@ -477,8 +415,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-fg-disabled"> .</p>
<p className="text-label-1 text-fg-disabled"> .</p>
</div>
)}
</div>
@ -487,8 +424,7 @@ export function BoardView() {
{/* 업로드 모달 */}
{showUploadModal && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,.55)' }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[rgba(0,0,0,.55)]"
onClick={() => {
setShowUploadModal(false);
setEditingManualId(null);
@ -500,8 +436,7 @@ export function BoardView() {
>
<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">
<span className="text-label-1 font-bold">
{editingManualId ? '매뉴얼 수정' : '매뉴얼 업로드'}
</span>
</div>
@ -510,32 +445,28 @@ export function BoardView() {
setShowUploadModal(false);
setEditingManualId(null);
}}
className="cursor-pointer text-fg-disabled text-base leading-none"
className="cursor-pointer text-fg-disabled text-label-1 leading-none"
>
</span>
</div>
<div className="p-5 flex flex-col gap-4">
<div>
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub mb-1.5">
</label>
<div className="flex gap-1.5">
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map((cat) => {
const cc = catColor(cat);
const isActive = uploadForm.category === cat;
return (
<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(--bg-card)',
border: isActive
? `1px solid ${cc.text}40`
: '1px solid var(--stroke-default)',
color: isActive ? cc.text : 'var(--fg-disabled)',
}}
className={`flex-1 py-2 px-1 rounded-md text-label-2 font-semibold cursor-pointer border ${
isActive
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] text-color-accent'
: 'bg-bg-card border-stroke text-fg-disabled'
}`}
>
{cat}
</button>
@ -544,7 +475,7 @@ export function BoardView() {
</div>
</div>
<div>
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub mb-1.5">
</label>
<input
@ -554,12 +485,11 @@ export function BoardView() {
onChange={(e) =>
setUploadForm((prev) => ({ ...prev, title: e.target.value }))
}
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-elevated border border-stroke outline-none"
style={{ boxSizing: 'border-box' }}
className="w-full px-3 py-2.5 rounded-md text-label-1 bg-bg-elevated border border-stroke outline-none box-border"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub mb-1.5">
</label>
<input
@ -569,12 +499,11 @@ export function BoardView() {
onChange={(e) =>
setUploadForm((prev) => ({ ...prev, version: e.target.value }))
}
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-elevated border border-stroke outline-none"
style={{ boxSizing: 'border-box' }}
className="w-full px-3 py-2.5 rounded-md text-label-1 bg-bg-elevated border border-stroke outline-none box-border"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">
<label className="block text-label-2 font-semibold text-fg-sub mb-1.5">
</label>
<div
@ -599,10 +528,9 @@ export function BoardView() {
>
{uploadForm.fileName ? (
<div className="flex items-center justify-center gap-2">
<span className="text-xl">📄</span>
<div className="text-left">
<div className="text-xs font-semibold">{uploadForm.fileName}</div>
<div className="text-[10px] text-fg-disabled font-mono">
<div className="text-label-1 font-semibold">{uploadForm.fileName}</div>
<div className="text-caption text-fg-disabled font-mono">
{uploadForm.fileSize}
</div>
</div>
@ -611,18 +539,17 @@ export function BoardView() {
e.stopPropagation();
setUploadForm((prev) => ({ ...prev, fileName: '', fileSize: '' }));
}}
className="text-xs text-fg-disabled cursor-pointer ml-2"
className="text-label-1 text-fg-disabled cursor-pointer ml-2"
>
</span>
</div>
) : (
<>
<div className="text-[28px] opacity-30 mb-1.5">📁</div>
<div className="text-[11px] text-fg-disabled">
<div className="text-label-2 text-fg-disabled">
</div>
<div className="text-[9px] text-fg-disabled font-mono mt-1">
<div className="text-caption text-fg-disabled font-mono mt-1">
PDF, DOC, HWP, XLSX ( 100MB)
</div>
</>
@ -636,7 +563,7 @@ export function BoardView() {
setShowUploadModal(false);
setEditingManualId(null);
}}
className="px-5 py-2 rounded-md text-xs font-semibold bg-bg-card border border-stroke text-fg-disabled cursor-pointer"
className="px-5 py-2 rounded-md text-label-1 font-semibold bg-bg-card border border-stroke text-fg-disabled cursor-pointer"
>
</button>
@ -685,14 +612,9 @@ export function BoardView() {
alert((err as { message?: string })?.message || '저장에 실패했습니다.');
}
}}
className="px-6 py-2 rounded-md text-xs font-semibold cursor-pointer"
style={{
background: 'rgba(6,182,212,.2)',
border: '1px solid rgba(6,182,212,.35)',
color: '#22d3ee',
}}
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
>
{editingManualId ? '✏️ 수정' : '📤 업로드'}
{editingManualId ? '수정' : '업로드'}
</button>
</div>
</div>
@ -748,7 +670,7 @@ export function BoardView() {
<div className="flex flex-col h-full bg-bg-base">
{/* 헤더 */}
<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">
<div className="text-label-1 text-fg-disabled">
<span className="text-fg font-semibold">{totalCount}</span>
</div>
<div className="flex items-center gap-3">
@ -758,14 +680,14 @@ export function BoardView() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleSearchKeyDown}
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"
className="px-4 py-2 text-label-1 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-color-accent text-bg-0 hover:opacity-90 transition-opacity"
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-4 py-2 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
>
</button>
)}
</div>
@ -775,29 +697,29 @@ export function BoardView() {
<div className="flex-1 overflow-auto px-8 py-6">
{isLoading ? (
<div className="text-center py-20">
<p className="text-fg-disabled text-sm"> ...</p>
<p className="text-fg-disabled text-label-1"> ...</p>
</div>
) : (
<>
<table className="w-full border-collapse">
<thead>
<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 className="px-4 py-3 text-center text-label-1 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 className="px-4 py-3 text-center text-label-1 font-semibold text-fg-sub w-24">
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub">
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub">
</th>
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-24">
<th className="px-4 py-3 text-center text-label-1 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 className="px-4 py-3 text-center text-label-1 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 className="px-4 py-3 text-center text-label-1 font-semibold text-fg-sub w-16">
</th>
</tr>
@ -808,10 +730,10 @@ export function BoardView() {
key={post.sn}
className="border-b border-stroke hover:bg-bg-elevated transition-colors"
>
<td className="px-4 py-4 text-sm text-fg text-center">{post.sn}</td>
<td className="px-4 py-4 text-label-1 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'}`}
className={`inline-flex items-center px-2 py-0.5 rounded text-caption font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-bg-elevated text-fg-sub'}`}
>
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
</span>
@ -820,20 +742,18 @@ export function BoardView() {
className="px-4 py-4 cursor-pointer"
onClick={() => handlePostClick(post.sn)}
>
<span
className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'} hover:text-color-accent transition-colors`}
>
<span className="text-label-1 text-fg hover:text-color-accent transition-colors">
{post.pinnedYn === 'Y' && '📌 '}
{post.title}
</span>
</td>
<td className="px-4 py-4 text-sm text-fg-sub text-center">
<td className="px-4 py-4 text-label-1 text-fg-sub text-center">
{post.authorName}
</td>
<td className="px-4 py-4 text-sm text-fg-disabled text-center">
<td className="px-4 py-4 text-label-1 text-fg-disabled text-center">
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-4 text-sm text-fg-disabled text-center">
<td className="px-4 py-4 text-label-1 text-fg-disabled text-center">
{post.viewCnt}
</td>
</tr>
@ -843,7 +763,7 @@ export function BoardView() {
{posts.length === 0 && (
<div className="text-center py-20">
<p className="text-fg-disabled text-sm"> .</p>
<p className="text-fg-disabled text-label-1"> .</p>
</div>
)}
</>
@ -857,9 +777,9 @@ export function BoardView() {
<button
key={p}
onClick={() => setPage(p)}
className={`px-3 py-1.5 text-sm rounded transition-colors ${
className={`px-3 py-1.5 text-label-1 rounded transition-colors ${
p === page
? 'bg-color-accent/20 text-color-accent font-semibold'
? 'bg-[color-mix(in_srgb,var(--color-accent)_20%,transparent)] text-color-accent font-semibold'
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
}`}
>

파일 보기

@ -126,34 +126,50 @@ export function BoardWriteForm({
if (isFetching) {
return (
<div className="flex flex-col h-full bg-bg-base items-center justify-center">
<p className="text-fg-disabled text-sm"> ...</p>
<p className="text-fg-disabled text-label-1"> ...</p>
</div>
);
}
return (
<div className="flex flex-col h-full bg-bg-base">
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-bg-base">
{/* 헤더 */}
<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">
<h2 className="text-title-3 font-bold text-fg">
{isEditMode ? '게시글 수정' : '게시글 작성'}
</h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-sm text-label-1 font-semibold cursor-pointer text-fg-sub px-4 py-2 bg-bg-elevated border border-stroke"
>
</button>
<button
type="submit"
disabled={isLoading}
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-4 py-2 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] disabled:opacity-50"
>
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
</button>
</div>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto px-8 py-6">
<div className="max-w-4xl mx-auto space-y-6">
{/* 분류 선택 */}
<div>
<label className="block text-sm font-semibold text-fg-sub mb-2">
<span className="text-red-500">*</span>
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
<span className="text-color-danger">*</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-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none disabled:opacity-50"
className="w-full px-4 py-2.5 text-label-1 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}>
@ -165,8 +181,8 @@ export function BoardWriteForm({
{/* 제목 */}
<div>
<label className="block text-sm font-semibold text-fg-sub mb-2">
<span className="text-red-500">*</span>
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
<span className="text-color-danger">*</span>
</label>
<input
type="text"
@ -174,14 +190,14 @@ export function BoardWriteForm({
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
placeholder="제목을 입력하세요"
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"
className="w-full px-4 py-2.5 text-label-1 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-fg-sub mb-2">
<span className="text-red-500">*</span>
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
<span className="text-color-danger">*</span>
</label>
<textarea
value={content}
@ -189,13 +205,13 @@ export function BoardWriteForm({
maxLength={10000}
placeholder="내용을 입력하세요"
rows={15}
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"
className="w-full px-4 py-3 text-label-1 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-fg-sub mb-2"></label>
<label className="block text-label-1 font-semibold text-fg-sub mb-2"></label>
<div className="flex items-center gap-3">
<input
type="file"
@ -207,34 +223,16 @@ export function BoardWriteForm({
/>
<label
htmlFor="file-upload"
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"
className="px-4 py-2 text-label-1 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-fg-disabled"> </span>
<span className="text-label-1 text-fg-disabled"> </span>
</div>
</div>
</div>
</div>
{/* 하단 버튼 */}
<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-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-color-accent text-bg-0 hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
</button>
</div>
</form>
</div>
);
}

파일 보기

@ -548,7 +548,7 @@ ${styles}
SEBC
</span>
<span
className="text-[8px] font-semibold text-color-accent py-[2px] px-2 rounded-md"
className="text-caption font-semibold text-color-accent py-[2px] px-2 rounded-md"
style={{ background: 'rgba(6,182,212,.1)' }}
>
Standard European Behaviour Classification
@ -585,11 +585,11 @@ ${styles}
</div>
<div className="text-title-4 font-mono font-extrabold text-color-accent">G</div>
<div className="text-label-2 font-bold my-1">Gas</div>
<div className="text-[8px] text-fg-sub leading-normal">
<div className="text-caption text-fg-sub leading-normal">
.
</div>
<div
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
>
@ -616,11 +616,11 @@ ${styles}
</div>
<div className="text-title-4 font-mono font-extrabold text-color-accent">E</div>
<div className="text-label-2 font-bold my-1">Evaporator</div>
<div className="text-[8px] text-fg-sub leading-normal">
<div className="text-caption text-fg-sub leading-normal">
.
</div>
<div
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
>
+
@ -647,11 +647,11 @@ ${styles}
</div>
<div className="text-title-4 font-mono font-extrabold text-color-accent">F</div>
<div className="text-label-2 font-bold my-1">Floater</div>
<div className="text-[8px] text-fg-sub leading-normal">
<div className="text-caption text-fg-sub leading-normal">
{'해수면 위에 부유. 비중 < 1.0, 불용성 물질'}
</div>
<div
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
>
@ -678,11 +678,11 @@ ${styles}
</div>
<div className="text-title-4 font-mono font-extrabold text-color-accent">D</div>
<div className="text-label-2 font-bold my-1">Dissolver</div>
<div className="text-[8px] text-fg-sub leading-normal">
<div className="text-caption text-fg-sub leading-normal">
.
</div>
<div
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
>
@ -709,11 +709,11 @@ ${styles}
</div>
<div className="text-title-4 font-mono font-extrabold text-color-accent">S</div>
<div className="text-label-2 font-bold my-1">Sinker</div>
<div className="text-[8px] text-fg-sub leading-normal">
<div className="text-caption text-fg-sub leading-normal">
{'해저로 침강. 비중 > 1.0, 저층 오염 축적'}
</div>
<div
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
>
3D
@ -724,7 +724,7 @@ ${styles}
<div className="rounded-md p-3 border border-stroke bg-bg-card">
<div className="text-label-2 font-bold mb-2">🔀 </div>
<div
className="grid text-center text-[8px]"
className="grid text-center text-caption"
style={{ gridTemplateColumns: 'repeat(5,1fr)', gap: 6 }}
>
<div className="rounded p-1.5 bg-bg-base">
@ -799,20 +799,20 @@ ${styles}
</div>
<div className="flex gap-1">
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
G/GD
</span>
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
<div className="rounded bg-bg-base px-1.5 py-1">
<span className="text-fg-disabled">CAS:</span>{' '}
<span className="font-mono">7664-41-7</span>
@ -839,7 +839,7 @@ ${styles}
</div>
</div>
<div
className="grid text-center text-[7px]"
className="grid text-center text-caption"
style={{ gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}
>
<div
@ -879,7 +879,7 @@ ${styles}
<b>300 ppm</b>
</div>
</div>
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
. .
.
</div>
@ -898,20 +898,20 @@ ${styles}
</div>
<div className="flex gap-1">
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
ED
</span>
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
<div className="rounded bg-bg-base px-1.5 py-1">
<span className="text-fg-disabled">CAS:</span>{' '}
<span className="font-mono">67-56-1</span>
@ -938,7 +938,7 @@ ${styles}
</div>
</div>
<div
className="grid text-center text-[7px]"
className="grid text-center text-caption"
style={{ gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}
>
<div
@ -978,7 +978,7 @@ ${styles}
<b>6,000 ppm</b>
</div>
</div>
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
. .
. 2007 FODDANGER호 95L .
</div>
@ -995,20 +995,20 @@ ${styles}
</div>
<div className="flex gap-1">
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
G
</span>
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
<div className="rounded bg-bg-base px-1.5 py-1">
<span className="text-fg-disabled">CAS:</span>{' '}
<span className="font-mono">1333-74-0</span>
@ -1034,7 +1034,7 @@ ${styles}
<span className="font-mono text-color-accent">75.0%</span>
</div>
</div>
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
(4~75%). · . BLEVE
. .
</div>
@ -1055,20 +1055,20 @@ ${styles}
</div>
<div className="flex gap-1">
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
G
</span>
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
/
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
<div className="rounded bg-bg-base px-1.5 py-1">
<span className="text-fg-disabled">CAS:</span>{' '}
<span className="font-mono">74-82-8</span>
@ -1094,7 +1094,7 @@ ${styles}
<span className="font-mono text-color-accent">15.0%</span>
</div>
</div>
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
(-162°C) RPT(), Pool Fire . Flash
. · LNG .
</div>
@ -1113,20 +1113,20 @@ ${styles}
</div>
<div className="flex gap-1">
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
S/SD
</span>
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
<div className="rounded bg-bg-base px-1.5 py-1">
<span className="text-fg-disabled">CAS:</span>{' '}
<span className="font-mono">108-95-2</span>
@ -1152,7 +1152,7 @@ ${styles}
<span className="font-mono text-color-accent">84 g/L</span>
</div>
</div>
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
1.07 <b className="text-color-accent">Sinker </b> . ROMS
3.5. HNS (31.8kg/).
</div>
@ -1171,20 +1171,20 @@ ${styles}
</div>
<div className="flex gap-1">
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
FE
</span>
<span
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
>
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
<div className="rounded bg-bg-base px-1.5 py-1">
<span className="text-fg-disabled">CAS:</span>{' '}
<span className="font-mono">108-88-3</span>
@ -1210,7 +1210,7 @@ ${styles}
<span className="font-mono">0.52 g/L</span>
</div>
</div>
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
. (4°C) .
HNS. .
</div>
@ -1293,7 +1293,7 @@ ${styles}
</div>
<div className="flex flex-col gap-1">
<div
className="text-[8px] rounded px-2 py-1.5"
className="text-caption rounded px-2 py-1.5"
style={{
background: 'rgba(6,182,212,.03)',
border: '1px solid rgba(6,182,212,.1)',
@ -1302,7 +1302,7 @@ ${styles}
<b className="text-color-accent">ERPG-1</b> ,
</div>
<div
className="text-[8px] rounded px-2 py-1.5"
className="text-caption rounded px-2 py-1.5"
style={{
background: 'rgba(6,182,212,.03)',
border: '1px solid rgba(6,182,212,.1)',
@ -1311,7 +1311,7 @@ ${styles}
<b className="text-color-accent">ERPG-2</b> ,
</div>
<div
className="text-[8px] rounded px-2 py-1.5"
className="text-caption rounded px-2 py-1.5"
style={{
background: 'rgba(6,182,212,.03)',
border: '1px solid rgba(6,182,212,.1)',
@ -1465,7 +1465,7 @@ ${styles}
</tr>
</tbody>
</table>
<div className="mt-2 text-[7px] text-fg-disabled">
<div className="mt-2 text-caption text-fg-disabled">
AEGL: 60분 / ERPG: 1시간 / IDLH: 30분 / LFL: 폭발하한
</div>
</div>
@ -1556,7 +1556,7 @@ ${styles}
🔎
</button>
</div>
<div className="text-[8px] text-fg-disabled leading-[1.6]">
<div className="text-caption text-fg-disabled leading-[1.6]">
· <b className="text-color-accent"> </b>{' '}
&nbsp;|&nbsp; / {' '}
<b className="text-color-accent">, </b> &nbsp;|&nbsp; {' '}
@ -2080,11 +2080,11 @@ function HmsDetailPanel({
{nfpa.reactivity}
</text>
</svg>
<div className="text-center text-[7px] font-semibold text-fg-disabled mt-0.5">
<div className="text-center text-caption font-semibold text-fg-disabled mt-0.5">
NFPA 704
</div>
</div>
<div className="flex-1 flex flex-col gap-1 text-[8px]">
<div className="flex-1 flex flex-col gap-1 text-caption">
<div
style={{
padding: '4px 8px',
@ -2295,7 +2295,7 @@ function HmsDetailPanel({
>
<div className="text-base mb-[3px]">🧑🚒</div>
<div className="font-bold text-color-accent"></div>
<div className="text-[8px] text-fg-disabled mt-0.5">{s.ppeClose}</div>
<div className="text-caption text-fg-disabled mt-0.5">{s.ppeClose}</div>
</div>
<div
className="text-center rounded"
@ -2307,7 +2307,7 @@ function HmsDetailPanel({
>
<div className="text-base mb-[3px]">🦺</div>
<div className="font-bold text-color-accent"></div>
<div className="text-[8px] text-fg-disabled mt-0.5">{s.ppeFar}</div>
<div className="text-caption text-fg-disabled mt-0.5">{s.ppeFar}</div>
</div>
</div>
</div>
@ -2328,7 +2328,7 @@ function HmsDetailPanel({
📄 MSDS
</div>
<button
className="text-[8px] font-semibold cursor-pointer rounded"
className="text-caption font-semibold cursor-pointer rounded"
style={{
padding: '3px 10px',
background: 'rgba(6,182,212,.1)',
@ -2339,7 +2339,7 @@ function HmsDetailPanel({
📥
</button>
</div>
<div className="text-[8px] text-fg-sub leading-[1.7] p-2.5">
<div className="text-caption text-fg-sub leading-[1.7] p-2.5">
<b>§2 ·:</b> {s.msds.hazard}
<br />
<b>§4 :</b> {s.msds.firstAid}
@ -2607,7 +2607,7 @@ function HmsDetailPanel({
<div className="text-label-1 font-bold text-color-accent">
📋
</div>
<div className="text-[8px] text-fg-disabled"> </div>
<div className="text-caption text-fg-disabled"> </div>
</div>
<div className="p-3">
<table className="w-full border-collapse text-caption">
@ -2714,7 +2714,9 @@ function HmsDetailPanel({
}}
>
<div className="text-label-1 font-bold text-color-accent">🏗 </div>
<div className="text-[8px] text-fg-disabled">Port-MIS </div>
<div className="text-caption text-fg-disabled">
Port-MIS
</div>
</div>
<div className="p-3">
<table className="w-full border-collapse text-caption">

파일 보기

@ -3849,15 +3849,16 @@ function RealtimeComparePanel() {
<button
className="px-4 py-1.5 rounded-md text-caption font-bold cursor-pointer text-white"
style={{
background: 'var(--color-accent)',
border: 'none',
// background: 'var(--color-accent)',
// border: '1px solid var(--color-accent)',
color: 'var(--color-accent)',
}}
>
</button>
<button
className="px-3 py-1.5 rounded-md text-caption font-semibold cursor-pointer text-fg-sub bg-bg-card"
style={{ border: '1px solid var(--stroke-default)' }}
style={{ color: 'var(--color-accent)' }}
>
</button>

파일 보기

@ -171,7 +171,7 @@ function HNSManualViewer() {
<div className="text-label-2 font-bold" style={{ color: `${s.color},1)` }}>
{s.label}
</div>
<div className="text-fg-disabled whitespace-pre-line text-[8px] mt-[3px] leading-[1.3]">
<div className="text-fg-disabled whitespace-pre-line text-caption mt-[3px] leading-[1.3]">
{s.desc}
</div>
</div>
@ -180,7 +180,7 @@ function HNSManualViewer() {
</div>
{/* 출처 */}
<div className="text-fg-disabled rounded-sm bg-bg-card p-[10px] text-[8px] leading-[1.5]">
<div className="text-fg-disabled rounded-sm bg-bg-card p-[10px] text-caption leading-[1.5]">
<b>:</b> Marine HNS Response Manual Bonn Agreement / HELCOM / REMPEC (WestMOPoCo
Project, 2024 )
<br />

파일 보기

@ -113,7 +113,7 @@ function StatusBadge({ status }: { status: Status }) {
if (status === 'forbidden')
return (
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
className="text-caption font-bold px-1.5 py-0.5 rounded"
style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}
>
@ -122,7 +122,7 @@ function StatusBadge({ status }: { status: Status }) {
if (status === 'allowed')
return (
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
className="text-caption font-bold px-1.5 py-0.5 rounded"
style={{ background: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' }}
>
@ -130,7 +130,7 @@ function StatusBadge({ status }: { status: Status }) {
);
return (
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
className="text-caption font-bold px-1.5 py-0.5 rounded"
style={{ background: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' }}
>
@ -173,10 +173,10 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
}}
>
<div>
<div className="text-[11px] font-bold text-fg font-korean">🚢 </div>
<div className="text-[8px] text-fg-sub font-korean"> 22</div>
<div className="text-label-2 font-bold text-fg font-korean">🚢 </div>
<div className="text-caption text-fg-sub font-korean"> 22</div>
</div>
<span onClick={onClose} className="text-[14px] cursor-pointer text-fg-sub hover:text-fg">
<span onClick={onClose} className="text-title-3 cursor-pointer text-fg-sub hover:text-fg">
</span>
</div>
@ -187,14 +187,17 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
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-fg-sub font-korean"> </span>
<span className="text-[9px] text-fg font-mono">
<span className="text-caption text-fg-sub font-korean"> </span>
<span className="text-caption 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-fg-sub font-korean"> ()</span>
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
<span className="text-caption text-fg-sub font-korean"> ()</span>
<span
className="text-label-2 font-bold font-mono"
style={{ color: ZONE_COLORS[zoneIdx] }}
>
{distanceNm.toFixed(1)} NM
</span>
</div>
@ -242,13 +245,13 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
<div
style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }}
/>
<span className="text-[10px] font-bold text-fg font-korean">{cat}</span>
<span className="text-caption 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 }}>
<span className="text-caption font-semibold" style={{ color: summaryColor }}>
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
</span>
<span className="text-[9px] text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
<span className="text-caption text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
</div>
</div>
@ -265,7 +268,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
borderRadius: 4,
}}
>
<span className="text-[9px] text-fg font-korean">{rule.item}</span>
<span className="text-caption text-fg font-korean">{rule.item}</span>
<StatusBadge status={rule.zones[zoneIdx]} />
</div>
))}
@ -276,7 +279,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
.map((r, i) => (
<div
key={i}
className="text-[7px] text-fg-sub font-korean leading-relaxed"
className="text-caption text-fg-sub font-korean leading-relaxed"
>
💡 {r.item}: {r.condition}
</div>
@ -295,7 +298,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
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 className="text-caption text-fg-sub font-korean leading-relaxed">
. .
</div>
</div>

파일 보기

@ -233,10 +233,10 @@ export function IncidentsLeftPanel({
setSelectedPeriod('');
resetPage();
}}
className="bg-bg-base border border-stroke font-mono text-[11px] outline-none flex-1"
className="bg-bg-base border border-stroke font-mono text-label-2 outline-none flex-1"
style={{ padding: '5px 8px', borderRadius: 'var(--radius-sm)' }}
/>
<span className="text-fg-disabled text-[11px]">~</span>
<span className="text-fg-disabled text-label-2">~</span>
<input
type="date"
value={dateTo}
@ -245,12 +245,12 @@ export function IncidentsLeftPanel({
setSelectedPeriod('');
resetPage();
}}
className="bg-bg-base border border-stroke font-mono text-[11px] outline-none flex-1"
className="bg-bg-base border border-stroke font-mono text-label-2 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"
className="text-label-2 font-semibold cursor-pointer whitespace-nowrap text-white border-none"
style={{
padding: '5px 12px',
background: 'linear-gradient(135deg,var(--color-accent),var(--color-info))',
@ -267,7 +267,7 @@ export function IncidentsLeftPanel({
<button
key={p}
onClick={() => handlePeriodClick(p)}
className="text-[10px] font-semibold cursor-pointer"
className="text-caption font-semibold cursor-pointer"
style={{
padding: '3px 8px',
borderRadius: '14px',
@ -290,7 +290,7 @@ export function IncidentsLeftPanel({
style={{ background: 'rgba(6,182,212,0.03)' }}
>
<div
className="text-[10px] font-bold text-fg-disabled mb-2"
className="text-caption font-bold text-fg-disabled mb-2"
style={{ letterSpacing: '0.8px' }}
>
📅 ({todayLabel})
@ -306,7 +306,7 @@ export function IncidentsLeftPanel({
setSelectedRegion(r);
resetPage();
}}
className="text-[11px] cursor-pointer"
className="text-label-2 cursor-pointer"
style={{
padding: '4px 10px',
borderRadius: 'var(--radius-sm)',
@ -349,7 +349,7 @@ export function IncidentsLeftPanel({
setSelectedStatus(s.id);
resetPage();
}}
className="flex items-center gap-1 text-[10px] font-semibold cursor-pointer"
className="flex items-center gap-1 text-caption font-semibold cursor-pointer"
style={{
padding: '4px 10px',
borderRadius: '12px',
@ -372,7 +372,7 @@ export function IncidentsLeftPanel({
</div>
{/* Count */}
<div className="px-4 py-1.5 text-[11px] text-fg-disabled shrink-0 border-b border-stroke">
<div className="px-4 py-1.5 text-label-2 text-fg-disabled shrink-0 border-b border-stroke">
{filteredIncidents.length}
</div>
@ -385,7 +385,7 @@ export function IncidentsLeftPanel({
}}
>
{pagedIncidents.length === 0 ? (
<div className="px-4 py-10 text-center text-fg-disabled text-[11px]">
<div className="px-4 py-10 text-center text-fg-disabled text-label-2">
.
</div>
) : (
@ -449,7 +449,7 @@ export function IncidentsLeftPanel({
{inc.name}
</div>
<span
className="shrink-0 text-[10px] font-semibold"
className="shrink-0 text-caption font-semibold"
style={{
padding: '2px 10px',
borderRadius: '10px',
@ -461,7 +461,7 @@ export function IncidentsLeftPanel({
</span>
</div>
{/* Row 2: meta */}
<div className="flex items-center gap-2 text-[10px] text-fg-disabled mb-[5px]">
<div className="flex items-center gap-2 text-caption text-fg-disabled mb-[5px]">
<span>
📅 {inc.date} {inc.time}
</span>
@ -472,7 +472,7 @@ export function IncidentsLeftPanel({
<div className="flex flex-wrap gap-1">
{inc.causeType && (
<span
className="text-[10px] font-medium text-fg-sub"
className="text-caption font-medium text-fg-sub"
style={{
padding: '2px 8px',
borderRadius: '3px',
@ -485,7 +485,7 @@ export function IncidentsLeftPanel({
)}
{inc.oilType && (
<span
className="text-[10px] font-medium text-color-warning"
className="text-caption font-medium text-color-warning"
style={{
padding: '2px 8px',
borderRadius: '3px',
@ -498,7 +498,7 @@ export function IncidentsLeftPanel({
)}
{inc.prediction && (
<span
className="text-[10px] font-medium text-color-success"
className="text-caption font-medium text-color-success"
style={{
padding: '2px 8px',
borderRadius: '3px',
@ -512,7 +512,7 @@ export function IncidentsLeftPanel({
</div>
<div className="flex gap-1">
<button
className="inc-wx-btn cursor-pointer text-[11px]"
className="inc-wx-btn cursor-pointer text-label-2"
onClick={(e) => handleWeatherClick(e, inc.id)}
title="사고 위치 기상정보"
style={{
@ -537,7 +537,7 @@ export function IncidentsLeftPanel({
setMediaModalIncident(inc);
}}
title="현장정보 조회"
className="cursor-pointer text-[11px]"
className="cursor-pointer text-label-2"
style={{
padding: '3px 7px',
borderRadius: '4px',
@ -548,7 +548,7 @@ export function IncidentsLeftPanel({
transition: '0.15s',
}}
>
📹 <span className="text-[8px]">{inc.mediaCount}</span>
📹 <span className="text-caption">{inc.mediaCount}</span>
</button>
)}
</div>
@ -576,7 +576,7 @@ export function IncidentsLeftPanel({
{/* Pagination */}
<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">
<div className="text-caption text-fg-disabled">
<b>{filteredIncidents.length}</b> {(safePage - 1) * pageSize + 1}-
{Math.min(safePage * pageSize, filteredIncidents.length)}
</div>
@ -612,7 +612,7 @@ export function IncidentsLeftPanel({
onChange={(e) => {
/* page size change placeholder */ void e;
}}
className="bg-bg-base border border-stroke text-fg-sub text-[9px] outline-none rounded px-1.5 py-[3px]"
className="bg-bg-base border border-stroke text-fg-sub text-caption outline-none rounded px-1.5 py-[3px]"
>
<option>6</option>
<option>10</option>
@ -638,7 +638,7 @@ function PgBtn({
<button
onClick={onClick}
disabled={disabled}
className="flex items-center justify-center font-mono text-[9px]"
className="flex items-center justify-center font-mono text-caption"
style={{
minWidth: '24px',
height: '24px',
@ -694,8 +694,8 @@ const WeatherPopup = forwardRef<
<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-fg-disabled font-mono text-[8px]">{data?.obsDtm || '-'}</div>
<div className="text-label-2 font-bold">{data?.locNm || '기상정보 없음'}</div>
<div className="text-fg-disabled font-mono text-caption">{data?.obsDtm || '-'}</div>
</div>
</div>
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-sm p-0.5">
@ -710,12 +710,12 @@ const WeatherPopup = forwardRef<
<div className="text-[28px]">{data?.icon || '❓'}</div>
<div>
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</div>
<div className="text-fg-disabled text-[9px]">{data?.weatherDc || '-'}</div>
<div className="text-fg-disabled text-caption">{data?.weatherDc || '-'}</div>
</div>
</div>
{/* Detail grid */}
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
<div className="grid grid-cols-2 gap-1.5 text-caption">
<WxCell icon="💨" label="풍향/풍속" value={data?.wind} />
<WxCell icon="🌊" label="파고" value={data?.wave} />
<WxCell icon="💧" label="습도" value={data?.humid} />
@ -735,8 +735,8 @@ const WeatherPopup = forwardRef<
>
<span className="text-xs"></span>
<div>
<div className="text-fg-disabled text-[7px]"> ()</div>
<div className="font-bold font-mono text-[10px] text-color-info">
<div className="text-fg-disabled text-caption"> ()</div>
<div className="font-bold font-mono text-caption text-color-info">
{data?.highTide || '-'}
</div>
</div>
@ -747,8 +747,8 @@ const WeatherPopup = forwardRef<
>
<span className="text-xs"></span>
<div>
<div className="text-fg-disabled text-[7px]"> ()</div>
<div className="text-color-accent font-bold font-mono text-[10px]">
<div className="text-fg-disabled text-caption"> ()</div>
<div className="text-color-accent font-bold font-mono text-caption">
{data?.lowTide || '-'}
</div>
</div>
@ -757,9 +757,9 @@ const WeatherPopup = forwardRef<
{/* 24h Forecast */}
<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>
<div className="font-bold text-fg-disabled text-caption mb-1.5">24h </div>
{forecast.length > 0 ? (
<div className="flex justify-between font-mono text-fg-sub text-[8px]">
<div className="flex justify-between font-mono text-fg-sub text-caption">
{forecast.map((f, i) => (
<div key={i} className="text-center">
<div>{f.hour}</div>
@ -769,7 +769,7 @@ const WeatherPopup = forwardRef<
))}
</div>
) : (
<div className="text-fg-disabled text-center text-[8px] py-1"> </div>
<div className="text-fg-disabled text-center text-caption py-1"> </div>
)}
</div>
@ -782,8 +782,8 @@ const WeatherPopup = forwardRef<
border: '1px solid rgba(249,115,22,0.12)',
}}
>
<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 className="font-bold text-color-warning text-caption mb-[3px]"> </div>
<div className="text-fg-sub text-caption leading-[1.5]">{data?.impactDc || '-'}</div>
</div>
</div>
</div>
@ -794,9 +794,9 @@ WeatherPopup.displayName = 'WeatherPopup';
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
return (
<div className="flex items-center bg-bg-base rounded gap-[6px] py-1.5 px-2">
<span className="text-[12px]">{icon}</span>
<span className="text-label-1">{icon}</span>
<div>
<div className="text-fg-disabled text-[7px]">{label}</div>
<div className="text-fg-disabled text-caption">{label}</div>
<div className="font-semibold font-mono">{value || '-'}</div>
</div>
</div>

파일 보기

@ -311,7 +311,7 @@ export function IncidentsRightPanel({
if (!incident) {
return (
<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-center text-fg-disabled text-label-2">
<div className="text-[32px] mb-2 opacity-30">📊</div>
<br />
@ -326,7 +326,7 @@ export function IncidentsRightPanel({
{/* Header */}
<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-fg-disabled">
<div className="text-caption text-fg-disabled">
: <b className="text-color-accent">{incident.name}</b>
</div>
</div>
@ -350,7 +350,7 @@ export function IncidentsRightPanel({
</span>
</div>
<button
className="text-[10px] font-semibold cursor-pointer"
className="text-caption font-semibold cursor-pointer"
style={{
padding: '3px 10px',
borderRadius: '4px',
@ -364,7 +364,7 @@ export function IncidentsRightPanel({
</div>
<div className="flex flex-col gap-1">
{sec.items.length === 0 ? (
<div className="text-[9px] text-fg-disabled text-center py-1.5">
<div className="text-caption text-fg-disabled text-center py-1.5">
</div>
) : (
@ -387,15 +387,15 @@ export function IncidentsRightPanel({
style={{ accentColor: sec.color }}
/>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
<div className="text-caption font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
{item.name}
</div>
<div className="text-fg-disabled font-mono text-[8px]">{item.sub}</div>
<div className="text-fg-disabled font-mono text-caption">{item.sub}</div>
</div>
<span
onClick={() => removePredItem(item.id)}
title="제거"
className="text-[10px] cursor-pointer text-fg-disabled shrink-0"
className="text-caption cursor-pointer text-fg-disabled shrink-0"
>
</span>
@ -403,7 +403,7 @@ export function IncidentsRightPanel({
))
)}
</div>
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-fg-disabled">
<div className="flex items-center gap-1.5 mt-1.5 text-caption text-fg-disabled">
: <b style={{ color: sec.color }}>{checkedCount}</b> · {sec.totalLabel}
</div>
</div>
@ -421,7 +421,7 @@ export function IncidentsRightPanel({
</span>
</div>
<button
className="text-[10px] font-semibold cursor-pointer"
className="text-caption font-semibold cursor-pointer"
style={{
padding: '3px 10px',
borderRadius: '4px',
@ -433,8 +433,8 @@ export function IncidentsRightPanel({
📋
</button>
</div>
<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">
<div className="text-caption text-fg-disabled text-center py-1.5"> </div>
<div className="flex items-center gap-1.5 mt-1.5 text-caption text-fg-disabled">
: <b style={{ color: sec.color }}>0</b> · 0
</div>
</div>
@ -448,7 +448,7 @@ export function IncidentsRightPanel({
</div>
<div className="flex flex-col gap-[3px]">
{sensCategories.length === 0 ? (
<div className="text-[9px] text-fg-disabled text-center py-1.5">
<div className="text-caption text-fg-disabled text-center py-1.5">
</div>
) : (
@ -463,7 +463,7 @@ export function IncidentsRightPanel({
return (
<label
key={cat.category}
className="flex items-center cursor-pointer text-[9px] gap-[5px] rounded-[3px]"
className="flex items-center cursor-pointer text-caption gap-[5px] rounded-[3px]"
style={{ padding: '4px 6px', background: `rgba(${r},${g},${b},0.06)` }}
>
<input
@ -499,23 +499,23 @@ export function IncidentsRightPanel({
<span className="text-sm">🛡</span>
<span className="text-xs font-bold text-color-boom"> </span>
{nearbyOrgs.length > 0 && (
<span className="ml-auto text-[9px] font-mono text-color-boom">
<span className="ml-auto text-caption font-mono text-color-boom">
{nearbyOrgs.length}
</span>
)}
</div>
{!selectedVessel ? (
<div className="py-2.5 text-center text-fg-disabled text-[10px] leading-[1.7]">
<div className="py-2.5 text-center text-fg-disabled text-caption leading-[1.7]">
<div className="text-xl mb-1 opacity-40">🚢</div>
<br />
</div>
) : nearbyLoading ? (
<div className="py-2.5 text-center text-fg-disabled text-[10px]"> ...</div>
<div className="py-2.5 text-center text-fg-disabled text-caption"> ...</div>
) : nearbyOrgs.length === 0 ? (
<div className="py-2.5 text-center text-fg-disabled text-[10px]">
<div className="py-2.5 text-center text-fg-disabled text-caption">
</div>
) : (
@ -532,19 +532,19 @@ export function IncidentsRightPanel({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 mb-[2px]">
<span
className="text-[8px] px-[4px] py-[1px] rounded-[2px] font-bold shrink-0"
className="text-caption px-[4px] py-[1px] rounded-[2px] font-bold shrink-0"
style={{ background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }}
>
{org.orgTp}
</span>
<span className="text-[10px] font-bold text-fg truncate">{org.orgNm}</span>
<span className="text-caption font-bold text-fg truncate">{org.orgNm}</span>
</div>
<div className="text-[9px] text-fg-disabled">
<div className="text-caption text-fg-disabled">
{org.areaNm}
{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}` : ''}
</div>
</div>
<span className="text-[9px] font-mono text-color-boom shrink-0">
<span className="text-caption font-mono text-color-boom shrink-0">
{org.distanceNm.toFixed(1)} nm
</span>
</div>
@ -555,8 +555,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-fg-disabled"> </span>
<span className="text-[10px] font-bold font-mono text-color-boom">
<span className="text-caption text-fg-disabled"> </span>
<span className="text-caption font-bold font-mono text-color-boom">
{nearbyRadius} nm
</span>
</div>
@ -593,7 +593,7 @@ export function IncidentsRightPanel({
<button
key={v.mode}
onClick={() => onViewModeChange(v.mode)}
className="flex-1 text-[10px] cursor-pointer"
className="flex-1 text-caption cursor-pointer"
style={{
padding: '6px',
borderRadius: 'var(--radius-sm)',
@ -624,7 +624,7 @@ export function IncidentsRightPanel({
const sensChecked = checkedSensCategories.size;
onRunAnalysis(checkedSections, sensChecked);
}}
className="w-full text-[11px] font-bold cursor-pointer"
className="w-full text-label-2 font-bold cursor-pointer"
style={{
padding: '8px',
background: analysisActive

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -60,7 +60,7 @@ 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-fg-disabled"
className="text-center text-label-1 text-fg-disabled"
style={{
width: 300,
padding: 40,
@ -114,8 +114,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<div className="flex items-center gap-[10px]">
<span className="text-lg">📋</span>
<div>
<div className="text-[14px] font-[800] text-fg"> {incident.name}</div>
<div className="text-[10px] text-fg-disabled font-mono">
<div className="text-title-3 font-[800] text-fg"> {incident.name}</div>
<div className="text-caption text-fg-disabled font-mono">
{incident.name} · {incident.date} · {media.photoCnt} / {media.videoCnt} /
{media.satCnt} / CCTV {media.cctvCnt}
</div>
@ -161,7 +161,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
{/* Close */}
<span
onClick={onClose}
className="text-[18px] cursor-pointer text-fg-disabled rounded"
className="text-title-1 cursor-pointer text-fg-disabled rounded"
style={{ padding: '2px 6px' }}
>
@ -174,7 +174,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
className="shrink-0 flex items-center gap-[10px]"
style={{ padding: '6px 20px', borderBottom: '1px solid var(--stroke-light)' }}
>
<span className="text-[9px] text-fg-disabled whitespace-nowrap">TIMELINE</span>
<span className="text-caption text-fg-disabled whitespace-nowrap">TIMELINE</span>
<div className="flex-1 relative" style={{ height: 16 }}>
<div
style={{
@ -204,7 +204,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
/>
))}
</div>
<div className="flex gap-2 text-[8px] font-mono text-fg-disabled whitespace-nowrap">
<div className="flex gap-2 text-caption font-mono text-fg-disabled whitespace-nowrap">
<span style={{ color: '#ef4444' }}> </span>
<span style={{ color: '#f59e0b' }}> </span>
<span className="text-fg-disabled"> </span>
@ -231,8 +231,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
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-fg">
<span className="text-label-1">📷</span>
<span className="text-label-1 font-bold text-fg">
{str(media.photoMeta, 'title', '현장 사진')}
</span>
</div>
@ -245,10 +245,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
📷
</div>
<div className="text-[12px] text-fg-sub font-semibold">
<div className="text-label-1 text-fg-sub font-semibold">
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')}
</div>
<div className="text-[9px] text-fg-disabled font-mono">
<div className="text-caption text-fg-disabled font-mono">
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
</div>
</div>
@ -262,7 +262,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
(_, i) => (
<div
key={i}
className="flex items-center justify-center text-[14px] cursor-pointer"
className="flex items-center justify-center text-title-3 cursor-pointer"
style={{
width: 40,
height: 36,
@ -281,10 +281,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
)}
</div>
<div className="flex justify-between items-center">
<span className="text-[8px] text-fg-disabled">
<span className="text-caption text-fg-disabled">
📷 {num(media.photoMeta, 'thumbCount')} · {str(media.photoMeta, 'stage')}
</span>
<span className="text-[8px] text-color-tertiary cursor-pointer">🔗 R&D </span>
<span className="text-caption text-color-tertiary cursor-pointer">
🔗 R&D
</span>
</div>
</div>
</div>
@ -298,13 +300,13 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
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-fg">
<span className="text-label-1">🎬</span>
<span className="text-label-1 font-bold text-fg">
{str(media.droneMeta, 'title', '드론 영상')}
</span>
</div>
<span
className="text-[9px] font-bold text-color-danger rounded"
className="text-caption font-bold text-color-danger rounded"
style={{
padding: '2px 8px',
background: 'rgba(239,68,68,0.15)',
@ -317,8 +319,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<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-fg-disabled font-mono">
<div className="text-label-1 text-fg-sub font-semibold"> </div>
<div className="text-caption text-fg-disabled font-mono">
{str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')}
</div>
</div>
@ -328,9 +330,9 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
style={{ padding: '10px 16px', borderTop: '1px solid var(--stroke-light)' }}
>
<div className="flex items-center justify-center gap-3">
<span className="text-[12px] text-fg-disabled cursor-pointer"></span>
<span className="text-label-1 text-fg-disabled cursor-pointer"></span>
<div
className="flex items-center justify-center text-[12px] text-color-tertiary cursor-pointer"
className="flex items-center justify-center text-label-1 text-color-tertiary cursor-pointer"
style={{
width: 28,
height: 28,
@ -341,18 +343,18 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
>
</div>
<span className="text-[12px] text-fg-disabled cursor-pointer"></span>
<span className="text-[10px] text-fg-disabled font-mono">
<span className="text-label-1 text-fg-disabled cursor-pointer"></span>
<span className="text-caption 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-fg-disabled">
<span className="text-caption text-fg-disabled">
🎬 {num(media.droneMeta, 'videoCount')} · {str(media.droneMeta, 'stage')}
</span>
<div className="flex gap-[8px]">
<span className="text-[8px] text-color-info cursor-pointer">📂 </span>
<span className="text-[8px] text-color-tertiary cursor-pointer">
<span className="text-caption text-color-info cursor-pointer">📂 </span>
<span className="text-caption text-color-tertiary cursor-pointer">
🔗 R&D
</span>
</div>
@ -369,8 +371,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
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-fg">
<span className="text-label-1">🛰</span>
<span className="text-label-1 font-bold text-fg">
{str(media.satMeta, 'title', '위성영상')}
</span>
</div>
@ -397,16 +399,16 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
}}
>
<div
className="absolute text-[9px] font-bold text-color-danger font-mono bg-bg-base"
className="absolute text-caption 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-fg-disabled">🛰</div>
<div className="text-[11px] text-fg-sub font-semibold">
<div className="text-label-2 text-fg-sub font-semibold">
{str(media.satMeta, 'title', '위성영상')}
</div>
<div className="text-[8px] text-fg-disabled font-mono">
<div className="text-caption text-fg-disabled font-mono">
{str(media.satMeta, 'date')} · {str(media.satMeta, 'resolution')}
</div>
</div>
@ -414,7 +416,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
{str(media.satMeta, 'detection') === '—' && (
<div className="text-center">
<div className="text-[40px] text-fg-disabled">🛰</div>
<div className="text-[11px] text-fg-disabled" style={{ marginTop: 8 }}>
<div className="text-label-2 text-fg-disabled" style={{ marginTop: 8 }}>
</div>
</div>
@ -429,7 +431,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
{Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
<div
key={i}
className="flex items-center justify-center text-[14px] text-fg-disabled cursor-pointer"
className="flex items-center justify-center text-title-3 text-fg-disabled cursor-pointer"
style={{
width: 40,
height: 36,
@ -444,10 +446,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
</div>
)}
<div className="flex justify-between items-center">
<span className="text-[8px] text-fg-disabled">
<span className="text-caption text-fg-disabled">
🛰 {num(media.satMeta, 'thumbCount')} · {str(media.satMeta, 'sensor')}
</span>
<span className="text-[8px] text-color-info cursor-pointer">🔍 / </span>
<span className="text-caption text-color-info cursor-pointer">
🔍 /
</span>
</div>
</div>
</div>
@ -461,15 +465,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
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-fg">
<span className="text-label-1">📹</span>
<span className="text-label-1 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-color-success rounded"
className="text-caption font-bold text-color-success rounded"
style={{
padding: '2px 8px',
background: 'rgba(34,197,94,0.15)',
@ -484,17 +488,17 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<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-color-danger font-mono"
className="absolute text-caption 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-fg-disabled">📹</div>
<div className="text-[12px] text-fg-sub font-semibold">
<div className="text-label-1 text-fg-sub font-semibold">
{str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
</div>
<div className="text-[9px] text-fg-disabled font-mono">
<div className="text-caption text-fg-disabled font-mono">
{str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} ·{' '}
{bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
</div>
@ -529,13 +533,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
))}
</div>
<div className="flex justify-between items-center">
<span className="text-[8px] text-fg-disabled">
<span className="text-caption text-fg-disabled">
📹 CCTV {num(media.cctvMeta, 'camCount')} ·{' '}
{str(media.cctvMeta, 'location')}
</span>
<div className="flex gap-[8px]">
<span className="text-[8px] text-color-danger cursor-pointer">🔴 </span>
<span className="text-[8px] text-color-info cursor-pointer">🎥 PTZ</span>
<span className="text-caption text-color-danger cursor-pointer">
🔴
</span>
<span className="text-caption text-color-info cursor-pointer">🎥 PTZ</span>
</div>
</div>
</div>
@ -552,7 +558,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
borderTop: '1px solid #30363d',
}}
>
<div className="flex gap-4 text-[10px] font-mono text-fg-disabled">
<div className="flex gap-4 text-caption font-mono text-fg-disabled">
<span>
📷 <b className="text-fg">{media.photoCnt}</b>
</span>
@ -601,7 +607,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-fg-disabled cursor-pointer rounded bg-bg-elevated"
className="flex items-center justify-center text-caption text-fg-disabled cursor-pointer rounded bg-bg-elevated"
style={{
width: 22,
height: 22,
@ -628,7 +634,7 @@ function BottomBtn({
}) {
return (
<button
className="flex items-center gap-1 text-[10px] font-bold cursor-pointer rounded-sm"
className="flex items-center gap-1 text-caption font-bold cursor-pointer rounded-sm"
style={{
padding: '6px 14px',
background: bg,

파일 보기

@ -116,7 +116,7 @@ export function BacktrackModal({
</div>
<div className="flex-1">
<h2 className="text-base font-bold m-0"> </h2>
<div className="text-[11px] text-fg-disabled mt-[2px]">
<div className="text-label-2 text-fg-disabled mt-[2px]">
AIS
</div>
</div>
@ -144,7 +144,7 @@ export function BacktrackModal({
>
{/* Analysis Conditions */}
<div>
<h3 className="text-[12px] font-bold text-fg-sub mb-[10px]"> </h3>
<h3 className="text-label-1 font-bold text-fg-sub mb-[10px]"> </h3>
<div
style={{
display: 'grid',
@ -161,7 +161,7 @@ export function BacktrackModal({
}}
className="border border-stroke"
>
<div className="text-[9px] text-fg-disabled mb-1"> </div>
<div className="text-caption text-fg-disabled mb-1"> </div>
<input
type="datetime-local"
value={inputTime}
@ -180,7 +180,7 @@ export function BacktrackModal({
}}
className="border border-stroke"
>
<div className="text-[9px] text-fg-disabled mb-1"> </div>
<div className="text-caption text-fg-disabled mb-1"> </div>
<select
value={inputRange}
onChange={(e) => setInputRange(e.target.value)}
@ -202,7 +202,7 @@ export function BacktrackModal({
}}
className="border border-stroke"
>
<div className="text-[9px] text-fg-disabled mb-1"> </div>
<div className="text-caption text-fg-disabled mb-1"> </div>
<div className="flex items-center gap-1">
<input
type="number"
@ -214,7 +214,7 @@ export function BacktrackModal({
step={0.5}
style={{ ...inputStyle, flex: 1 }}
/>
<span className="text-[10px] text-fg-disabled shrink-0">NM</span>
<span className="text-caption text-fg-disabled shrink-0">NM</span>
</div>
</div>
@ -227,8 +227,8 @@ export function BacktrackModal({
}}
className="border border-stroke"
>
<div className="text-[9px] text-fg-disabled mb-1"> </div>
<div className="text-[12px] font-semibold font-mono">
<div className="text-caption text-fg-disabled mb-1"> </div>
<div className="text-label-1 font-semibold font-mono">
{conditions.spillLocation.lat.toFixed(4)}°N,{' '}
{conditions.spillLocation.lon.toFixed(4)}°E
</div>
@ -244,10 +244,10 @@ export function BacktrackModal({
gridColumn: '1 / -1',
}}
>
<div className="text-[9px] text-fg-disabled mb-1"> </div>
<div className="text-caption text-fg-disabled mb-1"> </div>
<div className="text-sm font-bold text-color-tertiary font-mono">
{conditions.totalVessels}{' '}
<span className="text-[10px] font-medium text-fg-disabled">(AIS )</span>
<span className="text-caption font-medium text-fg-disabled">(AIS )</span>
</div>
</div>
</div>
@ -257,7 +257,7 @@ 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-fg-sub m-0"> </h3>
<h3 className="text-label-1 font-bold text-fg-sub m-0"> </h3>
<div
style={{
padding: '4px 10px',
@ -265,7 +265,7 @@ export function BacktrackModal({
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
}}
className="text-[10px] font-bold text-color-danger"
className="text-caption font-bold text-color-danger"
>
{conditions.totalVessels} {vessels.length}
</div>
@ -303,7 +303,7 @@ export function BacktrackModal({
border: 'none',
color: '#fff',
}}
className="flex-1 text-[13px] font-bold cursor-pointer"
className="flex-1 text-title-4 font-bold cursor-pointer"
>
🔍
</button>
@ -318,7 +318,7 @@ export function BacktrackModal({
color: 'var(--color-tertiary)',
cursor: 'wait',
}}
className="flex-1 text-[13px] font-bold border border-stroke"
className="flex-1 text-title-4 font-bold border border-stroke"
>
AIS ...
</button>
@ -333,7 +333,7 @@ export function BacktrackModal({
border: 'none',
color: '#fff',
}}
className="flex-1 text-[13px] font-bold cursor-pointer"
className="flex-1 text-title-4 font-bold cursor-pointer"
>
🗺
</button>
@ -380,8 +380,8 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
{vessel.rank}
</div>
<div className="flex-1">
<div className="text-[13px] font-bold font-mono">{vessel.name}</div>
<div className="text-[9px] text-fg-disabled font-mono mt-[2px]">
<div className="text-title-4 font-bold font-mono">{vessel.name}</div>
<div className="text-caption text-fg-disabled font-mono mt-[2px]">
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
</div>
</div>
@ -392,7 +392,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
>
{vessel.probability}%
</div>
<div className="text-[8px] text-fg-disabled"> </div>
<div className="text-caption text-fg-disabled"> </div>
</div>
</div>
@ -430,12 +430,12 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
: '1px solid var(--stroke-default)',
}}
>
<div className="text-[8px] text-fg-disabled mb-[2px]">{s.label}</div>
<div className="text-caption text-fg-disabled mb-[2px]">{s.label}</div>
<div
style={{
color: s.highlight ? 'var(--color-danger)' : 'var(--fg-default)',
}}
className="text-[10px] font-semibold font-mono"
className="text-caption font-semibold font-mono"
>
{s.value}
</div>
@ -453,7 +453,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
borderRadius: '6px',
lineHeight: '1.5',
}}
className="text-[9px] text-fg-sub"
className="text-caption text-fg-sub"
>
{vessel.description}
</div>

파일 보기

@ -67,7 +67,11 @@ ${styles}
return (
<div className="flex flex-col h-full overflow-hidden bg-bg-base">
<div className="flex-1 overflow-y-auto scrollbar-thin p-5" ref={contentRef}>
<div
className="flex-1 overflow-y-auto scrollbar-thin p-5"
ref={contentRef}
style={{ scrollbarGutter: 'stable' }}
>
{/* 헤더 */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">

파일 보기

@ -166,8 +166,8 @@ export function RecalcModal({
🔄
</div>
<div className="flex-1">
<h2 className="text-[15px] font-bold m-0"> </h2>
<div className="text-[10px] text-fg-disabled mt-[2px]">
<h2 className="text-subtitle font-bold m-0"> </h2>
<div className="text-caption text-fg-disabled mt-[2px]">
·
</div>
</div>
@ -202,10 +202,10 @@ export function RecalcModal({
borderRadius: '8px',
}}
>
<div className="text-[9px] font-bold text-color-accent mb-1.5"> </div>
<div className="text-caption font-bold text-color-accent mb-1.5"> </div>
<div
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}
className="text-[9px]"
className="text-caption"
>
<InfoItem label="사고명" value={incidentName} />
<InfoItem label="유종" value={initOilType} />
@ -281,7 +281,7 @@ export function RecalcModal({
<FieldGroup label="유출 위치 (좌표)">
<div className="flex gap-1.5">
<div className="flex-1">
<div className="text-[8px] text-fg-disabled mb-[3px]"> (N)</div>
<div className="text-caption text-fg-disabled mb-[3px]"> (N)</div>
<input
type="number"
className="prd-i font-mono"
@ -291,7 +291,7 @@ export function RecalcModal({
/>
</div>
<div className="flex-1">
<div className="text-[8px] text-fg-disabled mb-[3px]"> (E)</div>
<div className="text-caption text-fg-disabled mb-[3px]"> (E)</div>
<input
type="number"
className="prd-i font-mono"
@ -347,7 +347,7 @@ export function RecalcModal({
background: 'var(--bg-card)',
opacity: phase !== 'editing' ? 0.5 : 1,
}}
className="flex-1 text-[12px] font-semibold border border-stroke text-fg-sub cursor-pointer"
className="flex-1 text-label-1 font-semibold border border-stroke text-fg-sub cursor-pointer"
>
</button>
@ -378,7 +378,7 @@ export function RecalcModal({
: '#fff',
opacity: models.size === 0 && phase === 'editing' ? 0.5 : 1,
}}
className="flex-[2] text-[12px] font-bold"
className="flex-[2] text-label-1 font-bold"
>
{phase === 'done'
? '✅ 재계산 완료!'
@ -395,7 +395,7 @@ export function RecalcModal({
function FieldGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] font-bold text-fg-sub mb-1.5">{label}</div>
<div className="text-caption font-bold text-fg-sub mb-1.5">{label}</div>
{children}
</div>
);

파일 보기

@ -442,7 +442,7 @@ export function RightPanel({
}}
>
<div
className="w-[30px] h-[30px] rounded-md flex items-center justify-center text-[15px]"
className="w-[30px] h-[30px] rounded-md flex items-center justify-center text-subtitle"
style={{
background: 'rgba(6,182,212,0.1)',
border: '1px solid rgba(6,182,212,0.2)',

파일 보기

@ -410,8 +410,8 @@ const S = {
marginBottom: '24px',
borderRadius: '6px',
border: '1px solid var(--stroke-default)',
fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif",
fontSize: '12px',
fontFamily: 'var(--font-korean)',
fontSize: 'var(--font-size-label-1)',
lineHeight: '1.6',
position: 'relative' as const,
width: '100%',
@ -421,14 +421,14 @@ const S = {
background: 'rgba(6,182,212,0.12)',
color: 'var(--color-accent)',
padding: '8px 16px',
fontSize: '13px',
fontSize: 'var(--font-size-title-4)',
fontWeight: 700,
marginBottom: '12px',
borderRadius: '4px',
border: '1px solid rgba(6,182,212,0.2)',
},
subHeader: {
fontSize: '14px',
fontSize: 'var(--font-size-title-3)',
fontWeight: 700,
color: 'var(--color-accent)',
marginBottom: '12px',
@ -439,7 +439,7 @@ const S = {
width: '100%',
tableLayout: 'fixed' as const,
borderCollapse: 'collapse' as const,
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
marginBottom: '16px',
},
th: {
@ -449,20 +449,20 @@ const S = {
fontWeight: 600,
color: 'var(--fg-sub)',
textAlign: 'center' as const,
fontSize: '10px',
fontSize: 'var(--font-size-caption)',
},
td: {
border: '1px solid var(--stroke-default)',
padding: '5px 10px',
textAlign: 'center' as const,
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
color: 'var(--fg-sub)',
},
tdLeft: {
border: '1px solid var(--stroke-default)',
padding: '5px 10px',
textAlign: 'left' as const,
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
color: 'var(--fg-sub)',
},
thLabel: {
@ -472,7 +472,7 @@ const S = {
fontWeight: 600,
color: 'var(--fg-sub)',
textAlign: 'left' as const,
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
width: '120px',
},
mapPlaceholder: {
@ -485,7 +485,7 @@ const S = {
alignItems: 'center',
justifyContent: 'center',
color: 'var(--fg-disabled)',
fontSize: '13px',
fontSize: 'var(--font-size-title-4)',
fontWeight: 600,
marginBottom: '16px',
},
@ -498,7 +498,7 @@ const inputStyle: React.CSSProperties = {
border: '1px solid var(--stroke-light)',
borderRadius: '3px',
padding: '4px 8px',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
outline: 'none',
textAlign: 'center',
};
@ -546,7 +546,7 @@ function AddRowBtn({ onClick, label }: { onClick: () => void; label?: string })
return (
<button
onClick={onClick}
className="px-3 py-1 text-[10px] font-semibold text-color-accent bg-[rgba(6,182,212,0.08)] border border-dashed border-color-accent rounded-sm cursor-pointer mb-3"
className="px-3 py-1 text-label-2 font-semibold text-color-accent bg-[rgba(6,182,212,0.08)] border border-dashed border-color-accent rounded-sm cursor-pointer mb-3"
>
+ {label || '행 추가'}
</button>
@ -569,11 +569,11 @@ function Page1({
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
</div>
<div
className="text-color-accent text-[18px] font-bold mb-5 rounded px-5 py-3 text-center tracking-wide border"
className="text-color-accent text-title-1 font-bold mb-5 rounded px-5 py-3 text-center tracking-wide border"
style={{ background: 'rgba(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.2)' }}
>
@ -713,7 +713,7 @@ function Page2({
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}>2. </div>
@ -969,7 +969,7 @@ function Page3({
}) {
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}></div>
@ -985,7 +985,7 @@ function Page3({
border: '1px solid var(--stroke-light)',
borderRadius: '4px',
padding: '16px',
fontSize: '13px',
fontSize: 'var(--font-size-title-4)',
outline: 'none',
resize: 'vertical',
lineHeight: '1.8',
@ -1002,7 +1002,7 @@ function Page3({
padding: '16px',
color: data.analysis ? 'var(--fg-default)' : 'var(--fg-disabled)',
fontStyle: data.analysis ? 'normal' : 'italic',
fontSize: '13px',
fontSize: 'var(--font-size-title-4)',
whiteSpace: 'pre-wrap',
lineHeight: '1.8',
}}
@ -1684,7 +1684,7 @@ function Page4({
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}>4. </div>
@ -1820,7 +1820,9 @@ function Page4({
<tbody>
{data.esi.map((e, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 600, fontSize: '10px' }}>{e.code}</td>
<td style={{ ...S.td, fontWeight: 600, fontSize: 'var(--font-size-caption)' }}>
{e.code}
</td>
<td style={S.tdLeft}>{e.type}</td>
<ECell
value={e.length}
@ -2229,7 +2231,7 @@ function Page5({
};
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}> ( )</div>
@ -2283,7 +2285,7 @@ function Page6({
};
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}>5. ·</div>
@ -2414,7 +2416,7 @@ function Page6({
border: '1px solid var(--stroke-light)',
borderRadius: '4px',
padding: '12px',
fontSize: '12px',
fontSize: 'var(--font-size-label-1)',
outline: 'none',
resize: 'vertical',
}}
@ -2430,7 +2432,7 @@ function Page6({
padding: '12px',
color: data.etcEquipment ? 'var(--fg-default)' : 'var(--fg-disabled)',
fontStyle: data.etcEquipment ? 'normal' : 'italic',
fontSize: '12px',
fontSize: 'var(--font-size-label-1)',
}}
>
{data.etcEquipment || '-'}
@ -2458,7 +2460,7 @@ function Page7({
onChange({ ...data, result: { ...data.result, [k]: v } });
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}>/ </div>
@ -2591,25 +2593,25 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
{onBack && (
<button
onClick={onBack}
className="px-3 py-1.5 text-[12px] font-semibold text-fg-sub bg-transparent border-none cursor-pointer"
className="px-3 py-1.5 text-label-1 font-semibold text-fg-sub bg-transparent border-none cursor-pointer"
>
</button>
)}
<h2 className="text-[18px] font-bold">
<h2 className="text-title-1 font-bold">
{editing ? (
<input
value={data.title}
onChange={(e) => setData({ ...data, title: e.target.value })}
placeholder="보고서 제목 입력"
className="text-[18px] font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-full max-w-[600px]"
className="text-title-1 font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-full max-w-[600px]"
/>
) : (
data.title || '유류오염사고 대응지원 상황도'
)}
</h2>
<span
className="px-2.5 py-[3px] text-[10px] font-semibold rounded border"
className="px-2.5 py-[3px] text-label-2 font-semibold rounded border"
style={
editing
? {
@ -2630,7 +2632,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('all')}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
style={{
border:
viewMode === 'all'
@ -2644,7 +2646,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
</button>
<button
onClick={() => setViewMode('page')}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
style={{
border:
viewMode === 'page'
@ -2659,14 +2661,14 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
{editing && (
<button
onClick={handleSave}
className="px-4 py-1.5 text-[11px] font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-color-success"
className="px-4 py-1.5 text-label-2 font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-color-success"
>
</button>
)}
<button
onClick={() => window.print()}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[rgba(239,68,68,0.1)] text-color-danger"
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[rgba(239,68,68,0.1)] text-color-danger"
>
/ PDF
</button>
@ -2680,7 +2682,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
<button
key={i}
onClick={() => setCurrentPage(i)}
className="px-3 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
className="px-3 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
style={{
border:
currentPage === i
@ -2707,7 +2709,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
<button
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
className="px-5 py-2 text-[12px] font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
className="px-5 py-2 text-label-1 font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
style={{
color: currentPage === 0 ? 'var(--fg-disabled)' : 'var(--fg-default)',
opacity: currentPage === 0 ? 0.4 : 1,
@ -2715,13 +2717,13 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
>
</button>
<span className="px-4 py-2 text-[12px] text-fg-sub">
<span className="px-4 py-2 text-label-1 text-fg-sub">
{currentPage + 1} / {pages.length}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(pages.length - 1, p + 1))}
disabled={currentPage === pages.length - 1}
className="px-5 py-2 text-[12px] font-semibold rounded cursor-pointer"
className="px-5 py-2 text-label-1 font-semibold rounded cursor-pointer"
style={{
border: '1px solid var(--color-accent)',
background: 'rgba(6,182,212,0.1)',

파일 보기

@ -38,15 +38,22 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
{/* 라벨 */}
<div className="flex items-center gap-1.5 mb-1.5">
<span
className="text-[11px] font-bold font-korean px-2 py-0.5 rounded"
style={{ background: 'rgba(6,182,212,0.12)', color: '#06b6d4', border: '1px solid rgba(6,182,212,0.25)' }}
className="text-label-2 font-bold font-korean px-2 py-0.5 rounded"
style={{
background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
color: 'var(--color-accent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 25%, transparent)',
}}
>
{label}
</span>
</div>
{/* 지도 + 캡처 오버레이 */}
<div className="relative rounded-lg border border-stroke overflow-hidden" style={{ aspectRatio: '16/9' }}>
<div
className="relative rounded-lg border border-stroke overflow-hidden"
style={{ aspectRatio: '16/9' }}
>
<MapView
center={mapData.center}
zoom={mapData.zoom}
@ -66,19 +73,28 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
<div className="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
<div
className="rounded-lg overflow-hidden"
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
style={{
border: '1px solid color-mix(in srgb, var(--color-accent) 50%, transparent)',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
}}
>
<img src={captured} alt={`${label} 캡처`} className="w-full block" />
<div
className="flex items-center justify-between px-2 py-1"
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
style={{
background: 'rgba(15,23,42,0.85)',
borderTop: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
}}
>
<span
className="text-caption font-korean font-semibold"
style={{ color: 'var(--color-accent)' }}
>
<span className="text-[9px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
📷
</span>
<button
onClick={onReset}
className="text-[9px] font-korean hover:text-fg transition-colors"
className="text-caption font-korean hover:text-fg transition-colors"
style={{ color: 'rgba(148,163,184,0.8)' }}
>
@ -91,17 +107,21 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
{/* 캡처 버튼 */}
<div className="flex items-center justify-between mt-1.5">
<p className="text-[9px] text-fg-disabled font-korean">
<p className="text-caption text-fg-disabled font-korean">
{captured ? 'PDF 출력 시 포함됩니다.' : '원하는 범위를 선택 후 캡처하세요.'}
</p>
<button
onClick={handleCapture}
disabled={isCapturing || !!captured}
className="px-2.5 py-1 text-[10px] font-semibold rounded transition-all font-korean flex items-center gap-1"
className="px-2.5 py-1 text-label-2 font-semibold rounded transition-all font-korean flex items-center gap-1"
style={{
background: captured ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)',
border: '1px solid rgba(6,182,212,0.4)',
color: captured ? 'rgba(6,182,212,0.5)' : '#06b6d4',
background: captured
? 'color-mix(in srgb, var(--color-accent) 6%, transparent)'
: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 40%, transparent)',
color: captured
? 'color-mix(in srgb, var(--color-accent) 50%, transparent)'
: 'var(--color-accent)',
opacity: isCapturing ? 0.6 : 1,
cursor: captured ? 'default' : 'pointer',
}}
@ -124,7 +144,7 @@ const OilSpreadMapPanel = ({
}: OilSpreadMapPanelProps) => {
if (!mapData) {
return (
<div className="w-full h-[200px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-[12px] font-korean mb-4">
<div className="w-full h-[200px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-label-1 font-korean mb-4">
. .
</div>
);

파일 보기

@ -305,8 +305,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<div className="flex flex-col h-full w-full">
{/* Header */}
<div className="px-6 py-4 border-b border-stroke bg-bg-surface">
<h2 className="text-[16px] font-bold text-fg font-korean"> </h2>
<p className="text-[11px] text-fg-disabled font-korean mt-1">
<h2 className="text-title-2 font-bold text-fg font-korean"> </h2>
<p className="text-label-2 text-fg-disabled font-korean mt-1">
.
</p>
@ -329,12 +329,12 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
>
<div className="text-[22px] mb-1">{c.icon}</div>
<div
className="text-[12px] font-bold"
className="text-label-1 font-bold"
style={{ color: isActive ? c.color : 'var(--fg-disabled)' }}
>
{c.label}
</div>
<div className="text-[9px] text-fg-disabled mt-0.5">{c.desc}</div>
<div className="text-caption text-fg-disabled mt-0.5">{c.desc}</div>
</button>
);
})}
@ -346,7 +346,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<div className="w-[250px] min-w-[250px] border-r border-stroke bg-bg-surface flex flex-col overflow-y-auto shrink-0">
{/* 템플릿 선택 */}
<div className="px-4 py-3 border-b border-stroke">
<h3 className="text-[11px] font-bold text-fg-sub font-korean flex items-center gap-2">
<h3 className="text-label-2 font-bold text-fg-sub font-korean flex items-center gap-2">
📄 릿
</h3>
</div>
@ -361,9 +361,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
background: selectedTemplate === i ? cat.bgActive : 'var(--bg-elevated)',
}}
>
<span className="text-[14px]">{tmpl.icon}</span>
<span className="text-title-3">{tmpl.icon}</span>
<span
className="text-[11px] font-semibold"
className="text-label-2 font-semibold"
style={{ color: selectedTemplate === i ? cat.color : 'var(--fg-sub)' }}
>
{tmpl.label}
@ -374,7 +374,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{/* 섹션 체크 */}
<div className="px-4 py-3 border-b border-stroke">
<h3 className="text-[11px] font-bold text-fg-sub font-korean flex items-center gap-2">
<h3 className="text-label-2 font-bold text-fg-sub font-korean flex items-center gap-2">
📋
</h3>
</div>
@ -391,7 +391,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
}}
>
<div
className="w-[18px] h-[18px] rounded shrink-0 mt-[1px] flex items-center justify-center text-[10px]"
className="w-[18px] h-[18px] rounded shrink-0 mt-[1px] flex items-center justify-center text-label-2"
style={{
background: sec.checked ? cat.color : 'var(--bg-card)',
color: sec.checked ? '#fff' : 'transparent',
@ -402,15 +402,15 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className="text-[12px]">{sec.icon}</span>
<span className="text-label-1">{sec.icon}</span>
<span
className="text-[11px] font-bold"
className="text-label-2 font-bold"
style={{ color: sec.checked ? 'var(--fg-default)' : 'var(--fg-disabled)' }}
>
{sec.title}
</span>
</div>
<p className="text-[9px] text-fg-disabled mt-0.5">{sec.desc}</p>
<p className="text-caption text-fg-disabled mt-0.5">{sec.desc}</p>
</div>
</button>
))}
@ -421,10 +421,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<div className="flex-1 flex flex-col overflow-hidden">
{/* Preview Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke bg-bg-surface">
<h3 className="text-[13px] font-bold text-fg font-korean flex items-center gap-2">
<h3 className="text-title-4 font-bold text-fg font-korean flex items-center gap-2">
📄
<span
className="text-[10px] font-semibold px-2 py-0.5 rounded"
className="text-label-2 font-semibold px-2 py-0.5 rounded"
style={{ background: cat.bgActive, color: cat.color }}
>
{cat.templates[selectedTemplate].label}
@ -433,7 +433,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<div className="flex items-center gap-2">
<button
onClick={handleDownload}
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
className="px-3 py-1.5 text-label-2 font-semibold rounded transition-all font-korean flex items-center gap-1.5"
style={{
background: cat.bgActive,
border: `1px solid ${cat.borderColor}`,
@ -444,7 +444,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</button>
<button
onClick={handleSave}
className="px-3 py-1.5 text-[11px] font-semibold rounded border border-stroke bg-bg-elevated text-fg hover:bg-bg-surface-hover transition-all font-korean flex items-center gap-1.5"
className="px-3 py-1.5 text-label-2 font-semibold rounded border border-stroke bg-bg-elevated text-fg hover:bg-bg-surface-hover transition-all font-korean flex items-center gap-1.5"
>
💾
</button>
@ -456,11 +456,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{/* Report Header */}
<div className="rounded-lg border border-stroke p-8 mb-6 bg-bg-elevated">
<div className="text-center">
<p className="text-[10px] text-fg-disabled font-korean mb-2">
<p className="text-label-2 text-fg-disabled font-korean mb-2">
</p>
<h2 className="text-[20px] font-bold text-fg font-korean mb-2">{cat.reportName}</h2>
<p className="text-[12px] font-korean" style={{ color: cat.color }}>
<p className="text-label-1 font-korean" style={{ color: cat.color }}>
{cat.templates[selectedTemplate].label}
</p>
</div>
@ -473,7 +473,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
className="rounded-lg border border-stroke mb-4 overflow-hidden bg-bg-elevated"
>
<div className="px-5 py-3 border-b border-stroke">
<h4 className="text-[13px] font-bold text-fg font-korean flex items-center gap-2">
<h4 className="text-title-4 font-bold text-fg font-korean flex items-center gap-2">
{sec.icon} {sec.title}
</h4>
</div>
@ -492,7 +492,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
/>
{oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0 && (
<div className="mb-4 overflow-x-auto">
<table className="w-full border-collapse text-[11px]">
<table className="w-full border-collapse text-label-2">
<thead>
<tr className="border-b border-stroke bg-bg-card">
<th className="px-3 py-2 text-center font-semibold text-fg-disabled font-korean">
@ -558,7 +558,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
key={i}
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
>
<p className="text-[10px] text-fg-disabled font-korean mb-1">
<p className="text-label-2 text-fg-disabled font-korean mb-1">
{m.label}
</p>
<p
@ -576,7 +576,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<>
{oilPayload && !oilPayload.hasSimulation && (
<div
className="mb-3 px-3 py-2 rounded text-[10px] font-korean"
className="mb-3 px-3 py-2 rounded text-label-2 font-korean"
style={{
background: 'rgba(249,115,22,0.08)',
border: '1px solid rgba(249,115,22,0.3)',
@ -615,16 +615,16 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
],
].map((row, i) => (
<tr key={i} className="border-b border-stroke">
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean bg-[rgba(255,255,255,0.02)]">
<td className="px-4 py-3 text-label-2 text-fg-disabled font-korean bg-[rgba(255,255,255,0.02)]">
{row[0]}
</td>
<td className="px-4 py-3 text-[12px] text-fg font-mono font-semibold text-right">
<td className="px-4 py-3 text-label-1 text-fg font-mono font-semibold text-right">
{row[1]}
</td>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean bg-[rgba(255,255,255,0.02)]">
<td className="px-4 py-3 text-label-2 text-fg-disabled font-korean bg-[rgba(255,255,255,0.02)]">
{row[2]}
</td>
<td className="px-4 py-3 text-[12px] text-fg font-mono font-semibold text-right">
<td className="px-4 py-3 text-label-1 text-fg font-mono font-semibold text-right">
{row[3]}
</td>
</tr>
@ -638,7 +638,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
const resources = oilPayload?.sensitiveResources;
if (!resources || resources.length === 0) {
return (
<p className="text-[12px] text-fg-disabled font-korean italic">
<p className="text-label-1 text-fg-disabled font-korean italic">
.
</p>
);
@ -652,13 +652,13 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</colgroup>
<thead>
<tr className="border-b border-stroke">
<th className="px-4 py-2 text-[11px] text-fg-disabled font-korean text-left bg-[rgba(255,255,255,0.02)]">
<th className="px-4 py-2 text-label-2 text-fg-disabled font-korean text-left bg-[rgba(255,255,255,0.02)]">
</th>
<th className="px-4 py-2 text-[11px] text-fg-disabled font-korean text-right bg-[rgba(255,255,255,0.02)]">
<th className="px-4 py-2 text-label-2 text-fg-disabled font-korean text-right bg-[rgba(255,255,255,0.02)]">
</th>
<th className="px-4 py-2 text-[11px] text-fg-disabled font-korean text-right bg-[rgba(255,255,255,0.02)]">
<th className="px-4 py-2 text-label-2 text-fg-disabled font-korean text-right bg-[rgba(255,255,255,0.02)]">
</th>
</tr>
@ -666,14 +666,14 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<tbody>
{resources.map((r, i) => (
<tr key={i} className="border-b border-stroke">
<td className="px-4 py-3 text-[12px] text-fg font-korean">
<td className="px-4 py-3 text-label-1 text-fg font-korean">
{r.category}
</td>
<td className="px-4 py-3 text-[12px] text-fg text-right">
<td className="px-4 py-3 text-label-1 text-fg text-right">
<span className="font-mono">{r.count}</span>
<span className="font-korean"></span>
</td>
<td className="px-4 py-3 text-[12px] text-fg font-mono text-right">
<td className="px-4 py-3 text-label-1 text-fg font-mono text-right">
{r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—'}
</td>
</tr>
@ -686,7 +686,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
(() => {
if (!oilPayload) {
return (
<p className="text-[12px] text-fg-disabled font-korean italic">
<p className="text-label-1 text-fg-disabled font-korean italic">
.
</p>
);
@ -696,14 +696,14 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
!coastLength || coastLength === '—' || coastLength.startsWith('0.00');
if (hasNoCoastal) {
return (
<p className="text-[12px] text-fg-sub font-korean">
<p className="text-label-1 text-fg-sub font-korean">
{' '}
<span className="font-semibold text-fg"> </span>.
</p>
);
}
return (
<p className="text-[12px] text-fg-sub font-korean">
<p className="text-label-1 text-fg-sub font-korean">
:{' '}
<span className="font-semibold text-fg">
{oilPayload.coastal?.firstTime ?? '—'}
@ -715,9 +715,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
);
})()}
{sec.id === 'oil-defense' && (
<div className="text-[12px] text-fg-disabled font-korean">
<div className="text-label-1 text-fg-disabled font-korean">
<p className="mb-2"> .</p>
<div className="w-full h-[100px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-[12px] font-korean">
<div className="w-full h-[100px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-label-1 font-korean">
[ ]
</div>
</div>
@ -727,7 +727,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
const wx = oilPayload?.weather;
if (!wx) {
return (
<p className="text-[12px] text-fg-disabled font-korean italic">
<p className="text-label-1 text-fg-disabled font-korean italic">
· .
</p>
);
@ -767,11 +767,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-3">
<span className="text-[11px] font-semibold text-accent-1 font-korean">
<span className="text-label-2 font-semibold text-accent-1 font-korean">
{stationLabel}
</span>
{capturedAt && (
<span className="text-[10px] text-fg-disabled font-korean">
<span className="text-label-2 text-fg-disabled font-korean">
: {capturedAt}
</span>
)}
@ -779,10 +779,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5">
{rows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="text-[10px] text-fg-disabled font-korean w-[64px] shrink-0">
<span className="text-label-2 text-fg-disabled font-korean w-[64px] shrink-0">
{row.label}
</span>
<span className="text-[12px] font-semibold text-fg font-mono">
<span className="text-label-1 font-semibold text-fg font-mono">
{row.value}
</span>
</div>
@ -803,7 +803,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
style={{ maxHeight: '300px', objectFit: 'contain' }}
/>
) : (
<div className="w-full h-[140px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-[12px] font-korean mb-4">
<div className="w-full h-[140px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-label-1 font-korean mb-4">
[ ]
</div>
)}
@ -832,7 +832,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
key={i}
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
>
<p className="text-[10px] text-fg-disabled font-korean mb-1">
<p className="text-label-2 text-fg-disabled font-korean mb-1">
{m.label}
</p>
<p
@ -841,7 +841,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
>
{m.value}
</p>
<p className="text-[8px] text-fg-disabled font-korean mt-1">{m.desc}</p>
<p className="text-caption text-fg-disabled font-korean mt-1">
{m.desc}
</p>
</div>
))}
</div>
@ -877,26 +879,29 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
>
<p
className="text-[9px] font-bold font-korean mb-1"
className="text-caption font-bold font-korean mb-1"
style={{ color: h.color }}
>
{h.label}
</p>
<p className="text-[18px] font-bold font-mono" style={{ color: h.color }}>
<p
className="text-title-1 font-bold font-mono"
style={{ color: h.color }}
>
{h.value}
</p>
{h.area && (
<p className="text-[10px] text-fg-disabled font-mono mt-0.5">
<p className="text-label-2 text-fg-disabled font-mono mt-0.5">
{h.area}
</p>
)}
<p className="text-[8px] text-fg-disabled font-korean mt-1">{h.desc}</p>
<p className="text-caption text-fg-disabled font-korean mt-1">{h.desc}</p>
</div>
))}
</div>
)}
{sec.id === 'hns-substance' && (
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="grid grid-cols-2 gap-2 text-label-2">
{[
{ k: '물질명', v: hnsPayload?.substance.name || '—' },
{ k: 'UN번호', v: hnsPayload?.substance.un || '—' },
@ -913,7 +918,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
))}
<div className="col-span-2 flex justify-between px-3 py-2 bg-bg-surface rounded border border-[rgba(239,68,68,0.3)]">
<span className="text-fg-disabled font-korean"></span>
<span className="text-[var(--color-danger)] font-semibold font-mono text-[10px]">
<span className="text-[var(--color-danger)] font-semibold font-mono text-label-2">
{hnsPayload?.substance.toxicity || '—'}
</span>
</div>
@ -921,7 +926,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)}
{sec.id === 'hns-ppe' && (
<div className="flex flex-wrap gap-2">
<span className="text-fg-disabled font-korean text-[11px]"></span>
<span className="text-fg-disabled font-korean text-label-2"></span>
</div>
)}
{sec.id === 'hns-facility' && (
@ -935,15 +940,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
key={i}
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
>
<div className="text-[18px] mb-1">{f.icon}</div>
<p className="text-[14px] font-bold text-fg font-mono">{f.value}</p>
<p className="text-[9px] text-fg-disabled font-korean mt-1">{f.label}</p>
<div className="text-title-1 mb-1">{f.icon}</div>
<p className="text-title-3 font-bold text-fg font-mono">{f.value}</p>
<p className="text-caption text-fg-disabled font-korean mt-1">
{f.label}
</p>
</div>
))}
</div>
)}
{sec.id === 'hns-3d' && (
<div className="w-full h-[160px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-[12px] font-korean">
<div className="w-full h-[160px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-label-1 font-korean">
[3D ]
</div>
)}
@ -967,9 +974,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
key={i}
className="bg-bg-surface border border-stroke rounded-lg p-3 text-center"
>
<div className="text-[16px] mb-0.5">{w.icon}</div>
<p className="text-[13px] font-bold text-fg font-mono">{w.value}</p>
<p className="text-[8px] text-fg-disabled font-korean mt-1">{w.label}</p>
<div className="text-title-2 mb-0.5">{w.icon}</div>
<p className="text-title-4 font-bold text-fg font-mono">{w.value}</p>
<p className="text-caption text-fg-disabled font-korean mt-1">
{w.label}
</p>
</div>
))}
</div>
@ -988,8 +997,13 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
key={i}
className="bg-bg-surface border border-stroke rounded-lg p-3 text-center"
>
<p className="text-[9px] text-fg-disabled font-korean mb-1">{s.label}</p>
<p className="text-[16px] font-bold font-mono" style={{ color: s.color }}>
<p className="text-caption text-fg-disabled font-korean mb-1">
{s.label}
</p>
<p
className="text-title-2 font-bold font-mono"
style={{ color: s.color }}
>
{s.value}
</p>
</div>
@ -999,7 +1013,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{sec.id === 'rescue-timeline' && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 px-3 py-2 bg-bg-surface rounded border border-stroke">
<span className="text-[11px] text-fg-disabled font-korean"></span>
<span className="text-label-2 text-fg-disabled font-korean"></span>
</div>
</div>
)}
@ -1015,17 +1029,19 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
key={i}
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
>
<p className="text-[9px] text-fg-disabled font-korean mb-1">{c.label}</p>
<p className="text-caption text-fg-disabled font-korean mb-1">
{c.label}
</p>
<p className="text-[24px] font-bold font-mono" style={{ color: c.color }}>
{c.value}
</p>
<p className="text-[8px] text-fg-disabled font-korean mt-0.5"></p>
<p className="text-caption text-fg-disabled font-korean mt-0.5"></p>
</div>
))}
</div>
)}
{sec.id === 'rescue-resource' && (
<table className="w-full text-[11px] border-collapse">
<table className="w-full text-label-2 border-collapse">
<thead>
<tr className="border-b border-stroke">
<th className="px-3 py-2 text-left text-fg-disabled font-korean"></th>
@ -1044,7 +1060,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<tr className="border-b border-stroke">
<td
colSpan={4}
className="px-3 py-3 text-center text-fg-disabled font-korean text-[11px]"
className="px-3 py-3 text-center text-fg-disabled font-korean text-label-2"
>
</td>
@ -1063,8 +1079,13 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
key={i}
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
>
<p className="text-[9px] text-fg-disabled font-korean mb-1">{g.label}</p>
<p className="text-[14px] font-bold font-mono" style={{ color: g.color }}>
<p className="text-caption text-fg-disabled font-korean mb-1">
{g.label}
</p>
<p
className="text-title-3 font-bold font-mono"
style={{ color: g.color }}
>
{g.value}
</p>
</div>
@ -1083,9 +1104,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
key={i}
className="bg-bg-surface border border-stroke rounded-lg p-3 text-center"
>
<div className="text-[16px] mb-0.5">{w.icon}</div>
<p className="text-[13px] font-bold text-fg font-mono">{w.value}</p>
<p className="text-[8px] text-fg-disabled font-korean mt-1">{w.label}</p>
<div className="text-title-2 mb-0.5">{w.icon}</div>
<p className="text-title-4 font-bold text-fg font-mono">{w.value}</p>
<p className="text-caption text-fg-disabled font-korean mt-1">
{w.label}
</p>
</div>
))}
</div>

파일 보기

@ -121,11 +121,11 @@ export function ReportsView() {
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke bg-bg-surface">
<div className="flex items-center gap-3">
<span className="text-[11px] text-fg-disabled font-korean"></span>
<span className="text-label-2 text-fg-disabled font-korean"></span>
<select
value={filterJurisdiction}
onChange={(e) => setFilterJurisdiction(e.target.value)}
className="px-3 py-1.5 text-[11px] bg-bg-elevated border border-stroke rounded text-fg font-korean outline-none focus:border-color-accent"
className="px-3 py-1.5 text-label-2 bg-bg-elevated border border-stroke rounded text-fg font-korean outline-none focus:border-color-accent"
>
<option value="전체"></option>
{jurisdictions.map((j) => (
@ -141,9 +141,15 @@ export function ReportsView() {
placeholder="보고서명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-52 px-3 py-1.5 text-[11px] bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled font-korean outline-none focus:border-color-accent"
className="w-52 px-3 py-1.5 text-label-2 bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled font-korean outline-none focus:border-color-accent"
/>
<button 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 hover:text-fg transition-all font-korean">
<button
className="rounded-sm text-label-2 font-semibold cursor-pointer text-fg-sub px-3.5 py-1.5 font-korean"
style={{
border: '1px solid var(--stroke-default)',
background: 'var(--bg-card)',
}}
>
</button>
<button
@ -151,7 +157,11 @@ export function ReportsView() {
setView({ screen: 'templates' });
setActiveSubTab('template');
}}
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] transition-all font-korean"
className="rounded-sm text-label-2 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 font-korean"
style={{
border: '1px solid rgba(6,182,212,.3)',
background: 'rgba(6,182,212,.08)',
}}
>
+
</button>
@ -167,7 +177,7 @@ export function ReportsView() {
setView({ screen: 'templates' });
setActiveSubTab('template');
}}
className="px-4 py-2 text-xs font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean mt-4"
className="px-4 py-2 text-xs font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] transition-all font-korean mt-4"
>
릿
</button>
@ -200,43 +210,43 @@ export function ReportsView() {
filteredReports.length > 0
}
onChange={toggleAll}
className="accent-[#06b6d4] w-3.5 h-3.5"
className="accent-[var(--color-accent)] w-3.5 h-3.5"
/>
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-disabled text-left font-korean">
<th className="px-4 py-3 text-label-2 font-semibold text-fg-disabled text-left font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
</th>
</tr>
@ -252,10 +262,10 @@ export function ReportsView() {
type="checkbox"
checked={selectedIds.has(report.id)}
onChange={() => toggleSelect(report.id)}
className="accent-[#06b6d4] w-3.5 h-3.5"
className="accent-[var(--color-accent)] w-3.5 h-3.5"
/>
</td>
<td className="px-3 py-3 text-[12px] text-fg-disabled text-center font-mono">
<td className="px-3 py-3 text-label-1 text-fg-disabled text-center font-mono">
{idx + 1}
</td>
<td className="px-4 py-3 truncate">
@ -268,18 +278,18 @@ export function ReportsView() {
setPreviewReport(report);
}
}}
className="text-[12px] font-semibold text-color-accent hover:underline text-left font-korean truncate max-w-full block"
className="text-label-1 font-semibold text-color-accent hover:underline text-left font-korean truncate max-w-full block"
>
{report.title || '제목 없음'}
</button>
</td>
<td className="px-3 py-3 text-center">
<span
className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean"
className="inline-block px-2.5 py-1 text-caption font-semibold rounded font-korean"
style={{
background:
typeColors[report.reportType]?.bg || 'rgba(138,150,168,0.15)',
color: typeColors[report.reportType]?.text || '#8a96a8',
color: typeColors[report.reportType]?.text || 'var(--fg-disabled)',
}}
>
{report.reportType}
@ -293,7 +303,7 @@ export function ReportsView() {
{style ? (
<div className="inline-flex items-center gap-1.5">
<div
className="flex items-center justify-center w-5 h-5 rounded-full text-[11px]"
className="flex items-center justify-center w-5 h-5 rounded-full text-label-2"
style={{
background: style.bg,
boxShadow: `0 0 6px ${style.text}25`,
@ -302,30 +312,30 @@ export function ReportsView() {
{style.icon}
</div>
<span
className="text-[9px] font-semibold font-korean"
className="text-caption font-semibold font-korean"
style={{ color: style.text }}
>
{cat}
</span>
</div>
) : (
<span className="text-[10px] text-fg-disabled font-korean">-</span>
<span className="text-label-2 text-fg-disabled font-korean">-</span>
)}
</td>
);
})()}
<td className="px-3 py-3 text-[11px] text-fg-sub text-center font-mono">
<td className="px-3 py-3 text-label-2 text-fg-sub text-center font-mono">
{formatDate(report.createdAt)}
</td>
<td className="px-3 py-3 text-[11px] text-fg-sub text-center font-korean">
<td className="px-3 py-3 text-label-2 text-fg-sub text-center font-korean">
{report.author || '-'}
</td>
<td className="px-3 py-3 text-[11px] text-fg-sub text-center font-korean">
<td className="px-3 py-3 text-label-2 text-fg-sub text-center font-korean">
{report.jurisdiction}
</td>
<td className="px-3 py-3 text-center">
<span
className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean"
className="inline-block px-2.5 py-1 text-caption font-semibold rounded font-korean"
style={{
background: statusColors[report.status]?.bg,
color: statusColors[report.status]?.text,
@ -344,29 +354,29 @@ export function ReportsView() {
setView({ screen: 'edit', data: { ...report } });
}
}}
className="text-[11px] text-color-accent hover:underline font-korean"
className="text-label-2 text-color-accent hover:underline font-korean"
>
</button>
</td>
<td className="px-3 py-3 text-center">
{report.hasMapCapture || report.capturedMapImage ? (
<span title="확산예측 지도 캡처 있음" className="text-[14px]">
<span title="확산예측 지도 캡처 있음" className="text-title-3">
📷
</span>
) : (
<span className="text-[11px] text-fg-disabled"></span>
<span className="text-label-2 text-fg-disabled"></span>
)}
</td>
<td className="px-3 py-3 text-center">
<button className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded bg-[rgba(239,68,68,0.12)] text-[#ef4444] border border-[rgba(239,68,68,0.25)] hover:bg-[rgba(239,68,68,0.2)] transition-all">
<button className="inline-flex items-center gap-1 px-2 py-1 text-caption font-semibold rounded bg-[color-mix(in_srgb,var(--color-danger)_12%,transparent)] text-color-danger border border-[color-mix(in_srgb,var(--color-danger)_25%,transparent)] hover:bg-[color-mix(in_srgb,var(--color-danger)_20%,transparent)] transition-all">
PDF
</button>
</td>
<td className="px-3 py-3 text-center">
<button
onClick={() => handleDelete(report.id)}
className="w-7 h-7 rounded flex items-center justify-center text-color-danger hover:bg-[rgba(239,68,68,0.1)] transition-all text-sm"
className="w-7 h-7 rounded flex items-center justify-center text-color-danger hover:bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] transition-all text-sm"
>
🗑
</button>
@ -473,18 +483,18 @@ export function ReportsView() {
)[previewReport.reportType] || '📄'}
</div>
<div
className="text-center font-korean text-[13px] font-bold leading-snug"
className="text-center font-korean text-title-4 font-bold leading-snug"
style={{ wordBreak: 'keep-all' }}
>
{previewReport.title || '제목 없음'}
</div>
<div className="text-center mt-2">
<span
className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean"
className="inline-block px-2.5 py-1 text-caption font-semibold rounded font-korean"
style={{
background:
typeColors[previewReport.reportType]?.bg || 'rgba(138,150,168,0.15)',
color: typeColors[previewReport.reportType]?.text || '#8a96a8',
color: typeColors[previewReport.reportType]?.text || 'var(--fg-disabled)',
}}
>
{previewReport.reportType}
@ -493,19 +503,21 @@ export function ReportsView() {
</div>
{/* 메타 정보 */}
<div className="flex flex-col gap-2.5 font-korean text-[11px] px-[18px] py-3.5 border-b border-stroke">
<div className="flex flex-col gap-2.5 font-korean text-label-2 px-[18px] py-3.5 border-b border-stroke">
<div className="flex flex-col gap-0.5">
<span className="text-fg-disabled text-[9px] uppercase tracking-wide">
<span className="text-fg-disabled text-caption uppercase tracking-wide">
</span>
<span className="font-semibold">{previewReport.author || '—'}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-fg-disabled text-[9px] uppercase tracking-wide"></span>
<span className="text-fg-disabled text-caption uppercase tracking-wide">
</span>
<span className="font-semibold">{previewReport.jurisdiction}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-fg-disabled text-[9px] uppercase tracking-wide">
<span className="text-fg-disabled text-caption uppercase tracking-wide">
</span>
<span className="font-mono font-semibold">
@ -513,7 +525,9 @@ export function ReportsView() {
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-fg-disabled text-[9px] uppercase tracking-wide"></span>
<span className="text-fg-disabled text-caption uppercase tracking-wide">
</span>
<b
style={{
color: statusColors[previewReport.status]?.text || 'var(--fg-default)',
@ -531,7 +545,7 @@ export function ReportsView() {
setPreviewReport(null);
setView({ screen: 'edit', data: { ...previewReport } });
}}
className="w-full font-korean text-[11px] font-semibold cursor-pointer rounded-md border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)] text-color-accent py-2"
className="w-full font-korean text-label-2 font-semibold cursor-pointer rounded-md border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] text-color-accent py-2"
>
</button>
@ -542,7 +556,7 @@ export function ReportsView() {
{/* 하단 다운로드 버튼 */}
<div className="flex flex-col gap-2 px-4 py-3.5 border-t border-stroke">
<div className="text-center font-korean mb-0.5 text-[9px] text-fg-disabled">
<div className="text-center font-korean mb-0.5 text-caption text-fg-disabled">
</div>
<button
@ -567,10 +581,10 @@ export function ReportsView() {
exportAsPDF(html, previewReport.title || tpl.label);
}
}}
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
className="w-full flex items-center justify-center gap-1.5 font-korean text-label-1 font-bold cursor-pointer rounded-md py-[11px]"
style={{
border: '1px solid rgba(239,68,68,0.4)',
background: 'rgba(239,68,68,0.1)',
border: '1px solid color-mix(in srgb, var(--color-danger) 40%, transparent)',
background: 'color-mix(in srgb, var(--color-danger) 10%, transparent)',
color: 'var(--color-danger)',
}}
>
@ -606,7 +620,7 @@ export function ReportsView() {
}
}
}}
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
className="w-full flex items-center justify-center gap-1.5 font-korean text-label-1 font-bold cursor-pointer rounded-md py-[11px]"
style={{
border: '1px solid rgba(59,130,246,0.4)',
background: 'rgba(59,130,246,0.1)',
@ -623,14 +637,14 @@ export function ReportsView() {
{/* 헤더 */}
<div className="flex items-center justify-between shrink-0 px-5 py-3.5 border-b border-stroke">
<div className="flex items-center gap-2">
<span className="font-korean text-[9px] px-2 py-[3px] rounded bg-[rgba(6,182,212,0.1)] text-color-accent font-semibold">
<span className="font-korean text-caption px-2 py-[3px] rounded bg-[rgba(6,182,212,0.1)] text-color-accent font-semibold">
</span>
<span className="font-korean text-[12px] text-fg-disabled"> </span>
<span className="font-korean text-label-1 text-fg-disabled"> </span>
</div>
<span
onClick={() => setPreviewReport(null)}
className="text-[18px] cursor-pointer text-fg-disabled leading-none hover:text-fg transition-colors"
className="text-title-1 cursor-pointer text-fg-disabled leading-none hover:text-fg transition-colors"
>
</span>
@ -642,12 +656,12 @@ export function ReportsView() {
{/* 1. 사고개요 */}
<div>
<div
className="font-korean text-[12px] font-bold text-color-accent border-b pb-1 mb-2"
className="font-korean text-label-1 font-bold text-color-accent border-b pb-1 mb-2"
style={{ borderColor: 'rgba(6,182,212,0.15)' }}
>
1.
</div>
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
<div className="font-korean text-label-1 leading-[1.7] whitespace-pre-wrap mt-2">
{[
previewReport.incident.name && `사고명: ${previewReport.incident.name}`,
previewReport.incident.occurTime &&
@ -667,12 +681,12 @@ export function ReportsView() {
{/* 2. 유출현황 */}
<div>
<div
className="font-korean text-[12px] font-bold text-color-accent border-b pb-1 mb-2"
className="font-korean text-label-1 font-bold text-color-accent border-b pb-1 mb-2"
style={{ borderColor: 'rgba(6,182,212,0.15)' }}
>
2.
</div>
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
<div className="font-korean text-label-1 leading-[1.7] whitespace-pre-wrap mt-2">
{[
previewReport.incident.pollutant &&
`유출유종: ${previewReport.incident.pollutant}`,
@ -698,7 +712,7 @@ export function ReportsView() {
<div className="grid grid-cols-2 gap-3">
{previewReport.step3MapImage && (
<div className="relative">
<span className="absolute top-1.5 left-1.5 z-10 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
<span className="absolute top-1.5 left-1.5 z-10 text-caption font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
3
</span>
<img
@ -711,7 +725,7 @@ export function ReportsView() {
)}
{previewReport.step6MapImage && (
<div className="relative">
<span className="absolute top-1.5 left-1.5 z-10 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
<span className="absolute top-1.5 left-1.5 z-10 text-caption font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
6
</span>
<img

파일 보기

@ -91,7 +91,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
position: 'absolute',
top: 10,
right: 16,
fontSize: '9px',
fontSize: 'var(--font-size-caption)',
color: 'var(--fg-disabled)',
fontWeight: 600,
}}
@ -102,14 +102,14 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
{/* Section header */}
<div
style={{
background: 'rgba(6,182,212,0.12)',
background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
color: 'var(--color-accent)',
padding: '8px 16px',
fontSize: '13px',
fontSize: 'var(--font-size-title-4)',
fontWeight: 700,
marginBottom: '16px',
borderRadius: '4px',
border: '1px solid rgba(6,182,212,0.2)',
border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)',
}}
>
{section.title}
@ -121,7 +121,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
width: '100%',
tableLayout: 'fixed',
borderCollapse: 'collapse',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
}}
>
<colgroup>
@ -139,7 +139,11 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
style={{ border: '1px solid var(--stroke-default)', padding: '12px 14px' }}
>
<span
style={{ color: 'var(--fg-disabled)', fontStyle: 'italic', fontSize: '11px' }}
style={{
color: 'var(--fg-disabled)',
fontStyle: 'italic',
fontSize: 'var(--font-size-caption)',
}}
>
{section.title} ( )
</span>
@ -161,7 +165,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
fontWeight: 600,
color: 'var(--fg-sub)',
verticalAlign: 'top',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
}}
>
{field.label}
@ -182,11 +186,10 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
border: '1px solid var(--stroke-light)',
borderRadius: '3px',
padding: '8px 12px',
fontSize: '12px',
fontSize: 'var(--font-size-label-1)',
outline: 'none',
resize: 'vertical',
color: 'var(--fg-default)',
fontFamily: 'inherit',
}}
/>
</td>
@ -206,7 +209,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
fontWeight: 600,
color: 'var(--fg-sub)',
verticalAlign: 'middle',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
}}
>
{field.label}
@ -220,12 +223,12 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
<input type="checkbox" style={{ accentColor: '#06b6d4' }} />
<input type="checkbox" style={{ accentColor: 'var(--color-accent)' }} />
{opt}
</label>
))}
@ -246,7 +249,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
fontWeight: 600,
color: 'var(--fg-sub)',
verticalAlign: 'middle',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
}}
>
{field.label}
@ -260,7 +263,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
width: '100%',
background: 'transparent',
border: 'none',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
outline: 'none',
color: 'var(--fg-default)',
padding: '2px 0',
@ -353,7 +356,7 @@ export default function TemplateEditPage({
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="text-[12px] font-semibold text-fg-sub hover:text-fg transition-colors whitespace-nowrap"
className="text-label-1 font-semibold text-fg-sub hover:text-fg transition-colors whitespace-nowrap"
>
</button>
@ -361,14 +364,14 @@ export default function TemplateEditPage({
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="보고서 제목 입력"
className="text-[17px] font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-[380px] max-w-[480px]"
className="text-title-1 font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-[380px] max-w-[480px]"
/>
<span
className="px-2.5 py-[3px] text-[10px] font-semibold rounded border whitespace-nowrap"
className="px-2.5 py-[3px] text-label-2 font-semibold rounded border whitespace-nowrap"
style={{
background: 'rgba(251,191,36,0.15)',
color: '#f59e0b',
borderColor: 'rgba(251,191,36,0.3)',
background: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: 'var(--color-warning)',
borderColor: 'color-mix(in srgb, var(--color-warning) 30%, transparent)',
}}
>
@ -377,13 +380,16 @@ export default function TemplateEditPage({
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('all')}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
style={{
border:
viewMode === 'all'
? '1px solid var(--color-accent)'
: '1px solid var(--stroke-default)',
background: viewMode === 'all' ? 'rgba(6,182,212,0.1)' : 'var(--bg-elevated)',
background:
viewMode === 'all'
? 'color-mix(in srgb, var(--color-accent) 10%, transparent)'
: 'var(--bg-elevated)',
color: viewMode === 'all' ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
@ -391,13 +397,16 @@ export default function TemplateEditPage({
</button>
<button
onClick={() => setViewMode('page')}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
style={{
border:
viewMode === 'page'
? '1px solid var(--color-accent)'
: '1px solid var(--stroke-default)',
background: viewMode === 'page' ? 'rgba(6,182,212,0.1)' : 'var(--bg-elevated)',
background:
viewMode === 'page'
? 'color-mix(in srgb, var(--color-accent) 10%, transparent)'
: 'var(--bg-elevated)',
color: viewMode === 'page' ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
@ -405,13 +414,13 @@ export default function TemplateEditPage({
</button>
<button
onClick={handleSave}
className="px-4 py-1.5 text-[11px] font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-color-success"
className="px-4 py-1.5 text-label-2 font-bold rounded cursor-pointer border border-color-success bg-[color-mix(in_srgb,var(--color-success)_15%,transparent)] text-color-success"
>
</button>
<button
onClick={() => window.print()}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[rgba(239,68,68,0.1)] text-color-danger"
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] text-color-danger"
>
/ PDF
</button>
@ -425,13 +434,16 @@ export default function TemplateEditPage({
<button
key={i}
onClick={() => setCurrentPage(i)}
className="px-3 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
className="px-3 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
style={{
border:
currentPage === i
? '1px solid var(--color-accent)'
: '1px solid var(--stroke-default)',
background: currentPage === i ? 'rgba(6,182,212,0.15)' : 'transparent',
background:
currentPage === i
? 'color-mix(in srgb, var(--color-accent) 15%, transparent)'
: 'transparent',
color: currentPage === i ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
@ -457,7 +469,7 @@ export default function TemplateEditPage({
<button
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
className="px-5 py-2 text-[12px] font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
className="px-5 py-2 text-label-1 font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
style={{
color: currentPage === 0 ? 'var(--fg-disabled)' : 'var(--fg-default)',
opacity: currentPage === 0 ? 0.4 : 1,
@ -465,16 +477,16 @@ export default function TemplateEditPage({
>
</button>
<span className="px-4 py-2 text-[12px] text-fg-sub">
<span className="px-4 py-2 text-label-1 text-fg-sub">
{currentPage + 1} / {sections.length}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(sections.length - 1, p + 1))}
disabled={currentPage === sections.length - 1}
className="px-5 py-2 text-[12px] font-semibold rounded cursor-pointer"
className="px-5 py-2 text-label-1 font-semibold rounded cursor-pointer"
style={{
border: '1px solid var(--color-accent)',
background: 'rgba(6,182,212,0.1)',
background: 'color-mix(in srgb, var(--color-accent) 10%, transparent)',
color:
currentPage === sections.length - 1
? 'var(--fg-disabled)'

파일 보기

@ -108,8 +108,8 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
{/* Left Sidebar - Template Selection */}
<div className="w-60 min-w-[240px] border-r border-stroke bg-bg-surface flex flex-col py-4 px-3 gap-2 overflow-y-auto shrink-0">
<div className="px-1 mb-2">
<h3 className="text-[13px] font-bold text-fg font-korean"> 릿 </h3>
<p className="text-[9px] text-fg-disabled font-korean mt-1">
<h3 className="text-title-4 font-bold text-fg font-korean"> 릿 </h3>
<p className="text-caption text-fg-disabled font-korean mt-1">
릿 .
</p>
</div>
@ -125,11 +125,11 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
>
<span className="text-lg mb-1">{t.icon}</span>
<span
className={`text-[12px] font-bold font-korean ${selectedType === t.id ? 'text-color-accent' : 'text-fg'}`}
className={`text-label-1 font-bold font-korean ${selectedType === t.id ? 'text-color-accent' : 'text-fg'}`}
>
{t.label}
</span>
<span className="text-[9px] text-fg-disabled font-korean mt-0.5">{t.desc}</span>
<span className="text-caption text-fg-disabled font-korean mt-0.5">{t.desc}</span>
</button>
))}
</div>
@ -140,16 +140,19 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke bg-bg-surface">
<div className="flex items-center gap-3">
<span className="text-lg">{template.icon}</span>
<span className="text-[15px] font-bold text-fg font-korean">{template.label}</span>
<span className="text-subtitle font-bold text-fg font-korean">{template.label}</span>
<span
className="px-2 py-0.5 text-[9px] font-semibold rounded font-korean"
style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4' }}
className="px-2 py-0.5 text-caption font-semibold rounded font-korean"
style={{
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
color: 'var(--color-accent)',
}}
>
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-fg-disabled font-korean">:</span>
<span className="text-label-2 text-fg-disabled font-korean">:</span>
<button
onClick={() => setAutoSave(!autoSave)}
className={`relative w-9 h-[18px] rounded-full transition-all ${autoSave ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}
@ -159,8 +162,8 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
/>
</button>
<span
className="text-[10px] font-semibold font-korean"
style={{ color: autoSave ? '#06b6d4' : 'var(--fg-disabled)' }}
className="text-label-2 font-semibold font-korean"
style={{ color: autoSave ? 'var(--color-accent)' : 'var(--fg-disabled)' }}
>
{autoSave ? 'ON' : 'OFF'}
</span>
@ -171,7 +174,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<div className="flex-1 overflow-y-auto px-6 py-5">
{template.sections.map((section, sIdx) => (
<div key={sIdx} className="mb-6 w-full">
<h4 className="text-[13px] font-bold font-korean mb-3 text-cyan-500">
<h4 className="text-title-4 font-bold font-korean mb-3 text-color-accent">
{section.title}
</h4>
<table className="w-full table-fixed border-collapse">
@ -184,7 +187,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<tr key={fIdx} className="border-b border-stroke">
{field.label ? (
<>
<td className="px-4 py-3 text-[11px] font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] align-middle">
<td className="px-4 py-3 text-label-2 font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] align-middle">
{field.label}
</td>
<td className="px-4 py-2 align-middle">
@ -193,7 +196,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
value={getVal(field.key)}
onChange={(e) => setVal(field.key, e.target.value)}
placeholder={`${field.label} 입력`}
className="w-full bg-transparent text-[12px] text-fg font-korean outline-none placeholder-fg-disabled"
className="w-full bg-transparent text-label-1 text-fg font-korean outline-none placeholder-fg-disabled"
/>
)}
{field.type === 'checkbox-group' && field.options && (
@ -201,11 +204,11 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
{field.options.map((opt) => (
<label
key={opt}
className="flex items-center gap-1.5 text-[11px] text-fg-sub font-korean cursor-pointer"
className="flex items-center gap-1.5 text-label-2 text-fg-sub font-korean cursor-pointer"
>
<input
type="checkbox"
className="accent-[#06b6d4] w-3.5 h-3.5"
className="accent-[var(--color-accent)] w-3.5 h-3.5"
/>
{opt}
</label>
@ -220,7 +223,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
value={getVal(field.key)}
onChange={(e) => setVal(field.key, e.target.value)}
placeholder="내용을 입력하세요..."
className="w-full min-h-[120px] bg-bg-elevated border border-stroke rounded-md px-3 py-2 text-[12px] text-fg font-korean outline-none placeholder-fg-disabled resize-y focus:border-color-accent"
className="w-full min-h-[120px] bg-bg-elevated border border-stroke rounded-md px-3 py-2 text-label-1 text-fg font-korean outline-none placeholder-fg-disabled resize-y focus:border-color-accent"
/>
</td>
)}
@ -237,13 +240,13 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<div className="flex items-center gap-2">
<button
onClick={() => doExport('pdf')}
className="px-3 py-2 text-[11px] font-semibold rounded bg-color-danger text-white hover:opacity-90 transition-all"
className="px-3 py-2 text-label-2 font-semibold rounded bg-color-danger text-white hover:opacity-90 transition-all"
>
PDF
</button>
<button
onClick={() => doExport('hwp')}
className="px-3 py-2 text-[11px] font-semibold rounded bg-[#2563eb] text-white hover:opacity-90 transition-all"
className="px-3 py-2 text-label-2 font-semibold rounded bg-color-info text-white hover:opacity-90 transition-all"
>
HWPX
</button>
@ -251,19 +254,19 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="px-4 py-2 text-[11px] font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
className="px-4 py-2 text-label-2 font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
>
</button>
<button
onClick={() => setShowPreview(true)}
className="px-4 py-2 text-[11px] font-semibold rounded border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
className="px-4 py-2 text-label-2 font-semibold rounded border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
>
</button>
<button
onClick={handleSave}
className="px-5 py-2 text-[11px] font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean flex items-center gap-1"
className="px-5 py-2 text-label-2 font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean flex items-center gap-1"
>
</button>
@ -285,10 +288,15 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div className="flex items-center gap-3">
<span className="text-lg">{template.icon}</span>
<span className="text-[15px] font-bold text-fg font-korean">{template.label}</span>
<span className="text-subtitle font-bold text-fg font-korean">
{template.label}
</span>
<span
className="px-2 py-0.5 text-[9px] font-semibold rounded font-korean"
style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4' }}
className="px-2 py-0.5 text-caption font-semibold rounded font-korean"
style={{
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
color: 'var(--color-accent)',
}}
>
</span>
@ -306,13 +314,13 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<div className="w-full">
{/* Report Title */}
<div className="text-center mb-8">
<h2 className="text-[18px] font-bold text-fg font-korean mb-1">
<h2 className="text-title-1 font-bold text-fg font-korean mb-1">
</h2>
<h3 className="text-[15px] font-semibold font-korean text-cyan-500">
<h3 className="text-subtitle font-semibold font-korean text-color-accent">
{formData['incident.name'] || template.label}
</h3>
<p className="text-[11px] text-fg-disabled font-korean mt-2">
<p className="text-label-2 text-fg-disabled font-korean mt-2">
: {reportMeta.writeTime} | : {reportMeta.author || '-'} | :{' '}
{reportMeta.jurisdiction}
</p>
@ -322,8 +330,11 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
{template.sections.map((section, sIdx) => (
<div key={sIdx} className="mb-5">
<h4
className="text-[13px] font-bold font-korean mb-2 px-2 py-1.5 rounded"
style={{ color: '#06b6d4', background: 'rgba(6,182,212,0.06)' }}
className="text-title-4 font-bold font-korean mb-2 px-2 py-1.5 rounded"
style={{
color: 'var(--color-accent)',
background: 'color-mix(in srgb, var(--color-accent) 6%, transparent)',
}}
>
{section.title}
</h4>
@ -337,10 +348,10 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
const val = getVal(field.key);
return field.label ? (
<tr key={fIdx} className="border-b border-stroke">
<td className="px-4 py-2.5 text-[11px] font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] border-r border-stroke align-middle">
<td className="px-4 py-2.5 text-label-2 font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] border-r border-stroke align-middle">
{field.label}
</td>
<td className="px-4 py-2.5 text-[12px] text-fg font-korean align-middle">
<td className="px-4 py-2.5 text-label-1 text-fg font-korean align-middle">
{val || <span className="text-fg-disabled">-</span>}
</td>
</tr>
@ -348,7 +359,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<tr key={fIdx} className="border-b border-stroke">
<td
colSpan={2}
className="px-4 py-3 text-[12px] text-fg font-korean whitespace-pre-wrap"
className="px-4 py-3 text-label-1 text-fg font-korean whitespace-pre-wrap"
>
{val || <span className="text-fg-disabled"> </span>}
</td>
@ -366,7 +377,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
<div className="flex items-center justify-end gap-3 px-6 py-3 border-t border-stroke">
<button
onClick={() => setShowPreview(false)}
className="px-4 py-2 text-[11px] font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
className="px-4 py-2 text-label-2 font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
>
</button>
@ -375,7 +386,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
setShowPreview(false);
handleSave();
}}
className="px-5 py-2 text-[11px] font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
className="px-5 py-2 text-label-2 font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
>
</button>

파일 보기

@ -390,9 +390,9 @@ export const CATEGORIES: CategoryDef[] = [
icon: '🧪',
label: 'HNS 대기확산',
desc: 'ALOHA · WRF-Chem',
color: 'var(--color-warning)',
borderColor: 'rgba(249,115,22,0.4)',
bgActive: 'rgba(249,115,22,0.08)',
color: 'var(--color-accent)',
borderColor: 'rgba(6,182,212,0.4)',
bgActive: 'rgba(6,182,212,0.08)',
reportName: 'HNS 대기확산 예측보고서',
templates: [
{ icon: '🧪', label: 'HNS 예측보고서' },
@ -456,9 +456,9 @@ export const CATEGORIES: CategoryDef[] = [
icon: '🚨',
label: '긴급구난',
desc: '복원성 · 좌초위험 분석',
color: 'var(--color-danger)',
borderColor: 'rgba(239,68,68,0.4)',
bgActive: 'rgba(239,68,68,0.08)',
color: 'var(--color-accent)',
borderColor: 'rgba(6,182,212,0.4)',
bgActive: 'rgba(6,182,212,0.08)',
reportName: '긴급구난 상황보고서',
templates: [
{ icon: '🚨', label: '긴급구난 상황보고' },

파일 보기

@ -25,17 +25,17 @@ interface RescueScenario {
}
const SEV_STYLE: Record<Severity, { bg: string; color: string; label: string }> = {
CRITICAL: { bg: 'rgba(239,68,68,0.2)', color: '#f87171', label: 'CRITICAL' },
HIGH: { bg: 'rgba(249,115,22,0.15)', color: '#fb923c', label: 'HIGH' },
MEDIUM: { bg: 'rgba(251,191,36,0.15)', color: '#fbbf24', label: 'MEDIUM' },
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e', label: 'RESOLVED' },
CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)', label: 'CRITICAL' },
HIGH: { bg: 'rgba(249,115,22,0.15)', color: 'var(--color-warning)', label: 'HIGH' },
MEDIUM: { bg: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)', label: 'MEDIUM' },
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)', label: 'RESOLVED' },
};
const SEV_COLOR: Record<Severity, string> = {
CRITICAL: '#f87171',
HIGH: '#fb923c',
MEDIUM: '#fbbf24',
RESOLVED: '#22c55e',
CRITICAL: 'var(--color-danger)',
HIGH: 'var(--color-warning)',
MEDIUM: 'var(--color-caution)',
RESOLVED: 'var(--color-success)',
};
/* ─── Color helpers ─── */
@ -198,18 +198,11 @@ export function RescueScenarioView() {
<div className="flex flex-col flex-1 w-full h-full overflow-hidden bg-bg-base">
{/* ── Header ── */}
<div className="px-5 py-3.5 border-b border-stroke flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-[10px] flex items-center justify-center text-lg border border-[rgba(6,182,212,.3)]"
style={{
background: 'linear-gradient(135deg,rgba(6,182,212,.2),rgba(59,130,246,.15))',
}}
>
📊
</div>
<div className="flex items-center gap-2.5">
<span className="text-base">📊</span>
<div>
<div className="text-[15px] font-bold"> </div>
<div className="text-[10px] text-fg-disabled mt-0.5">
<div className="text-title-4 font-bold"> </div>
<div className="text-label-2 text-fg-disabled">
· (SFR-009)
</div>
</div>
@ -218,7 +211,7 @@ export function RescueScenarioView() {
<select
value={selectedIncident}
onChange={(e) => setSelectedIncident(Number(e.target.value))}
className="px-3 py-1.5 rounded-md border border-stroke bg-bg-card text-[10px] outline-none"
className="prd-i w-[280px] text-label-2"
>
{ops.map((op, i) => (
<option key={op.rescueOpsSn} value={i}>
@ -228,8 +221,11 @@ export function RescueScenarioView() {
</select>
<button
onClick={() => setNewScnModalOpen(true)}
className="px-3.5 py-1.5 rounded-md border-none text-white text-[10px] font-bold cursor-pointer"
style={{ background: 'linear-gradient(135deg,var(--color-accent),#3b82f6)' }}
className="cursor-pointer whitespace-nowrap font-semibold text-color-accent text-label-2 px-[14px] py-1.5 rounded-sm"
style={{
border: '1px solid rgba(6,182,212,.3)',
background: 'rgba(6,182,212,.08)',
}}
>
+
</button>
@ -239,26 +235,25 @@ export function RescueScenarioView() {
{/* ── Content: Left List + Right Detail ── */}
<div className="flex flex-1 overflow-hidden">
{/* ═══ LEFT: 시나리오 목록 ═══ */}
<div className="w-[360px] min-w-[360px] bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
<div
className="flex flex-col overflow-hidden shrink-0 border-r border-stroke bg-bg-surface"
style={{ width: '370px', minWidth: '370px' }}
>
{/* Sort bar */}
<div className="px-3.5 py-2.5 border-b border-stroke flex items-center justify-between">
<div className="text-[11px] font-bold">
📋 {' '}
<span className="font-normal text-fg-disabled text-[9px]">
({scenarios.length})
<div className="flex items-center justify-between border-b border-stroke px-[14px] py-2.5">
<span className="text-label-2 font-bold text-fg-disabled">
({scenarios.length})
</span>
</div>
<div className="flex gap-1">
{(['time', 'risk'] as const).map((s) => (
<button
key={s}
onClick={() => setSortBy(s)}
className="px-2 py-px rounded text-[9px] font-semibold cursor-pointer"
style={{
border: `1px solid ${sortBy === s ? 'rgba(6,182,212,.4)' : 'var(--stroke-default)'}`,
background: sortBy === s ? 'rgba(6,182,212,.08)' : 'var(--bg-card)',
color: sortBy === s ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
className={`cursor-pointer px-2 py-[3px] text-caption font-semibold rounded-sm border border-stroke ${
sortBy === s
? 'bg-[rgba(6,182,212,0.08)] text-color-accent'
: 'bg-bg-card text-fg-disabled'
}`}
>
{s === 'time' ? '시간순' : '위험도순'}
</button>
@ -267,9 +262,12 @@ export function RescueScenarioView() {
</div>
{/* Card list */}
<div className="flex-1 overflow-y-auto px-3 py-2.5 flex flex-col gap-2 scrollbar-thin">
<div
className="flex-1 overflow-y-auto flex flex-col gap-1.5 p-2"
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
>
{loading && scenarios.length === 0 && (
<div className="text-center py-10 text-[11px] text-fg-disabled">
<div className="text-center py-10 text-label-2 text-fg-disabled">
...
</div>
)}
@ -280,14 +278,11 @@ export function RescueScenarioView() {
<div
key={sc.id}
onClick={() => setSelectedId(sc.id)}
className={`hns-scn-card${isSel ? ' sel' : ''} p-3 rounded-md cursor-pointer transition-all`}
style={{
border: `1px solid ${isSel ? 'rgba(6,182,212,.35)' : 'var(--stroke-default)'}`,
background: isSel ? 'rgba(6,182,212,.04)' : 'var(--bg-card)',
}}
className={`hns-scn-card${isSel ? ' sel' : ''}`}
>
{/* Top: checkbox + ID + severity */}
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={checked.has(sc.id)}
@ -295,83 +290,71 @@ export function RescueScenarioView() {
e.stopPropagation();
toggleCheck(sc.id);
}}
className="accent-[var(--color-accent)]"
style={{ accentColor: 'var(--color-accent)' }}
/>
<span
className="text-xs font-extrabold font-mono"
style={{ color: isSel ? 'var(--color-accent)' : 'var(--fg-default)' }}
>
{sc.id}
<span className="text-label-1 font-bold">
{sc.id} {sc.name}
</span>
</div>
<span
className="px-1.5 py-px rounded text-[8px] font-bold font-mono"
className="font-bold px-2 py-[2px] rounded-lg text-caption"
style={{ background: sev.bg, color: sev.color }}
>
{sev.label}
</span>
<span className="ml-auto text-[9px] text-fg-disabled font-mono">
</div>
{/* Time row */}
<div className="flex items-center gap-1.5 mb-1.5">
<span
className="font-bold font-mono text-color-accent text-caption px-1.5 py-[2px] rounded-[3px]"
style={{ background: 'rgba(6,182,212,0.1)' }}
>
{sc.timeStep}
</span>
<span className="text-caption text-fg-disabled font-mono">{sc.datetime}</span>
</div>
{/* Name + time */}
<div className="text-[11px] font-bold mb-1">{sc.name}</div>
<div className="text-[9px] text-fg-disabled font-mono mb-2">{sc.datetime}</div>
{/* KPI grid */}
<div className="grid grid-cols-4 gap-1 mb-2 text-[8px] text-center">
<div className="py-1 bg-bg-base rounded">
<div className="text-fg-disabled">GM</div>
<div
className="font-bold font-mono"
style={{ color: gmColor(parseFloat(sc.gm)) }}
>
{sc.gm}m
</div>
</div>
<div className="py-1 bg-bg-base rounded">
<div className="text-fg-disabled"></div>
<div
className="font-bold font-mono"
style={{ color: listColor(parseFloat(sc.list)) }}
>
{sc.list}°
</div>
</div>
<div className="py-1 bg-bg-base rounded">
<div className="text-fg-disabled"></div>
<div
className="font-bold font-mono"
style={{ color: buoyColor(sc.buoyancy) }}
>
{sc.buoyancy}%
</div>
</div>
<div className="py-1 bg-bg-base rounded">
<div className="text-fg-disabled"></div>
<div
className="font-bold font-mono"
style={{ color: oilColor(parseFloat(sc.oilRate)) }}
>
{sc.oilRate.split(' ')[0]}
<div className="grid grid-cols-4 gap-1 font-mono text-caption">
{[
{ label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) },
{
label: '횡경사',
value: `${sc.list}°`,
color: listColor(parseFloat(sc.list)),
},
{ label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) },
{
label: '유출',
value: sc.oilRate.split(' ')[0],
color: oilColor(parseFloat(sc.oilRate)),
},
].map((m, i) => (
<div key={i} className="text-center p-[3px] bg-bg-base rounded-[3px]">
<div className="text-fg-disabled text-caption">{m.label}</div>
<div className="font-bold" style={{ color: m.color }}>
{m.value}
</div>
</div>
))}
</div>
{/* Description */}
<div className="text-[9px] text-fg-sub leading-[1.5]">{sc.description}</div>
<div className="text-fg-sub mt-1.5 text-caption leading-[1.4]">
{sc.description}
</div>
</div>
);
})}
</div>
{/* Bottom actions */}
<div className="px-3.5 py-2.5 border-t border-stroke flex gap-1.5">
<div className="flex gap-2 border-t border-stroke px-[14px] py-2.5">
<button
onClick={() => setDetailView(1)}
className="flex-1 py-2 rounded-md border-none text-white text-[10px] font-bold cursor-pointer"
style={{ background: 'linear-gradient(135deg,var(--color-accent),#3b82f6)' }}
className="flex-1 cursor-pointer font-bold text-static-white text-label-2 p-2 rounded-sm bg-color-navy hover:bg-color-navy-hover"
>
📊
</button>
<button className="px-3.5 py-2 rounded-md border border-stroke bg-bg-card text-fg-sub text-[10px] font-semibold cursor-pointer">
<button className="cursor-pointer font-semibold text-fg-sub text-label-2 px-[14px] py-2 rounded-sm bg-bg-card border border-stroke">
📄
</button>
</div>
@ -380,18 +363,12 @@ export function RescueScenarioView() {
{/* ═══ RIGHT: 상세/비교 ═══ */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
{/* Detail tabs */}
<div className="flex border-b border-stroke shrink-0">
<div className="flex border-b border-stroke shrink-0 px-4 bg-bg-surface">
{(['📋 시나리오 상세', '📊 비교 차트', '🗺 지도 오버레이'] as const).map((label, i) => (
<button
key={i}
onClick={() => setDetailView(i as DetailView)}
className="rsc-atab flex-1 py-2.5 px-1 border-none cursor-pointer transition-all text-[10px]"
style={{
background: detailView === i ? 'rgba(6,182,212,.04)' : 'transparent',
color: detailView === i ? 'var(--color-accent)' : 'var(--fg-disabled)',
fontWeight: detailView === i ? 700 : 600,
borderBottom: `2px solid ${detailView === i ? 'var(--color-accent)' : 'transparent'}`,
}}
className={`rsc-atab ${detailView === i ? 'on' : ''}`}
>
{label}
</button>
@ -403,20 +380,15 @@ export function RescueScenarioView() {
{/* ─── VIEW 0: 시나리오 상세 ─── */}
{detailView === 0 && selected && (
<div className="p-5">
{/* Gradient header */}
<div
className="px-5 py-4 rounded-[10px] border border-[rgba(6,182,212,.2)] mb-4"
style={{
background: 'linear-gradient(135deg,rgba(6,182,212,.06),rgba(239,68,68,.04))',
}}
>
{/* Header card */}
<div className="px-5 py-4 rounded-[10px] bg-bg-card border border-stroke mb-4">
<div className="flex items-center gap-2.5 mb-2.5">
<span className="text-base font-extrabold font-mono text-color-accent">
{selected.id}
</span>
<span className="text-sm font-bold">{selected.name}</span>
<span className="text-label-1 font-bold">{selected.name}</span>
<span
className="px-2 py-px rounded text-[9px] font-bold font-mono"
className="px-2 py-[2px] rounded-lg text-caption font-bold font-mono"
style={{
background: SEV_STYLE[selected.severity].bg,
color: SEV_STYLE[selected.severity].color,
@ -424,13 +396,13 @@ export function RescueScenarioView() {
>
{selected.severity}
</span>
<span className="ml-auto text-[10px] text-fg-disabled font-mono">
<span className="ml-auto text-label-2 text-fg-disabled font-mono">
{selected.datetime}
</span>
</div>
{/* 6 KPI cards */}
<div
className="grid gap-2 text-[8px] text-center"
className="grid gap-2 text-caption text-center"
style={{ gridTemplateColumns: 'repeat(6,1fr)' }}
>
{[
@ -493,9 +465,7 @@ export function RescueScenarioView() {
<div className="grid grid-cols-2 gap-4 mb-4">
{/* 침수 구획 */}
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
<div className="text-[11px] font-bold text-color-accent mb-2.5">
🚢
</div>
<div className="text-label-2 font-bold mb-2.5">🚢 </div>
<div className="flex flex-col gap-1.5">
{selected.compartments.map((c, i) => (
<div
@ -503,9 +473,9 @@ export function RescueScenarioView() {
className="flex items-center justify-between px-2.5 py-1.5 bg-bg-base rounded"
style={{ borderLeft: `3px solid ${c.color}` }}
>
<span className="text-[9px]">{c.name}</span>
<span className="text-caption">{c.name}</span>
<span
className="text-[9px] font-semibold font-mono"
className="text-caption font-semibold font-mono"
style={{ color: c.color }}
>
{c.status}
@ -516,9 +486,7 @@ export function RescueScenarioView() {
</div>
{/* 구난 판단 */}
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
<div className="text-[11px] font-bold text-color-danger mb-2.5">
</div>
<div className="text-label-2 font-bold mb-2.5"> </div>
<div className="flex flex-col gap-1.5">
{selected.assessment.map((a, i) => (
<div
@ -526,10 +494,10 @@ export function RescueScenarioView() {
className="px-2.5 py-2 bg-bg-base rounded"
style={{ borderLeft: `3px solid ${a.color}` }}
>
<div className="text-[9px] font-bold" style={{ color: a.color }}>
<div className="text-caption font-bold" style={{ color: a.color }}>
{a.label}
</div>
<div className="text-[9px] text-fg-sub mt-0.5">{a.value}</div>
<div className="text-caption text-fg-sub mt-0.5">{a.value}</div>
</div>
))}
</div>
@ -538,9 +506,7 @@ export function RescueScenarioView() {
{/* 대응 조치 이력 */}
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
<div className="text-[11px] font-bold text-color-warning mb-2.5">
📋
</div>
<div className="text-label-2 font-bold mb-2.5">📋 </div>
<div className="flex flex-col gap-1.5">
{selected.actions.map((a, i) => (
<div
@ -548,7 +514,7 @@ export function RescueScenarioView() {
className="flex items-center gap-2.5 px-2.5 py-1.5 bg-bg-base rounded"
>
<span
className="text-[10px] font-bold font-mono min-w-[40px]"
className="text-label-2 font-bold font-mono min-w-[40px]"
style={{ color: a.color }}
>
{a.time}
@ -557,7 +523,7 @@ export function RescueScenarioView() {
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: a.color }}
/>
<span className="text-[9px]">{a.text}</span>
<span className="text-caption">{a.text}</span>
</div>
))}
</div>
@ -573,15 +539,15 @@ export function RescueScenarioView() {
<div className="p-5">
<div className="bg-bg-card border border-stroke rounded-[10px] p-5 text-center">
<div className="text-[32px] opacity-30 mb-2.5">🗺</div>
<div className="text-[13px] font-bold mb-1.5">GIS </div>
<div className="text-[10px] text-fg-disabled leading-relaxed mb-4">
<div className="text-title-4 font-bold mb-1.5">GIS </div>
<div className="text-label-2 text-fg-disabled leading-relaxed mb-4">
.
</div>
<div className="flex gap-2 justify-center flex-wrap">
{scenarios.map((sc) => (
<div
key={sc.id}
className="px-3 py-1.5 rounded-md text-[9px]"
className="px-3 py-1.5 rounded-md text-caption"
style={{
border: `1px solid ${SEV_STYLE[sc.severity].color}40`,
background: SEV_STYLE[sc.severity].bg,
@ -595,7 +561,7 @@ export function RescueScenarioView() {
))}
</div>
<div className="mt-4 p-[30px] bg-bg-base rounded-md border border-dashed border-stroke">
<div className="text-[11px] text-fg-disabled">
<div className="text-label-2 text-fg-disabled">
</div>
</div>
@ -627,20 +593,19 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
};
/* ── shared className helpers ── */
const labelCls = 'text-[9px] text-fg-disabled block mb-1';
const labelCls = 'text-caption text-fg-disabled block mb-1';
const inputCls =
'w-full px-3 py-2 rounded-md border border-stroke bg-bg-base text-[11px] outline-none';
'w-full px-3 py-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none';
const numCls = `${inputCls} font-mono`;
const sectionIcon = (n: number) => (
<div
className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold text-color-accent"
className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-caption font-bold text-color-accent"
style={{ background: 'rgba(6,182,212,.12)' }}
>
{n}
</div>
);
const sectionTitleCls =
'text-[11px] font-bold text-color-accent mb-2.5 flex items-center gap-1.5';
const sectionTitleCls = 'text-label-2 font-bold mb-2.5 flex items-center gap-1.5';
return (
<div
@ -652,28 +617,17 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
style={{ background: 'rgba(0,0,0,.65)', backdropFilter: 'blur(6px)' }}
>
<div
className="bg-bg-surface border border-[rgba(6,182,212,.3)] rounded-[14px] w-[700px] max-h-[88vh] flex flex-col overflow-hidden"
className="bg-bg-surface border border-stroke rounded-[14px] w-[700px] max-h-[88vh] flex flex-col overflow-hidden"
style={{ boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}
>
{/* ── 헤더 ── */}
<div className="px-6 pt-5 pb-4 border-b border-stroke shrink-0 relative overflow-hidden">
<div
className="absolute top-0 left-0 right-0 h-0.5"
style={{ background: 'linear-gradient(90deg,#06b6d4,#3b82f6,#8b5cf6)' }}
/>
<div className="px-6 pt-5 pb-4 border-b border-stroke shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
className="w-9 h-9 rounded-[10px] flex items-center justify-center text-lg"
style={{
background: 'linear-gradient(135deg,rgba(6,182,212,.15),rgba(59,130,246,.08))',
}}
>
🚨
</div>
<span className="text-lg">🚨</span>
<div>
<div className="text-[15px] font-bold"> </div>
<div className="text-[10px] text-fg-disabled mt-0.5">
<div className="text-title-4 font-bold"> </div>
<div className="text-label-2 text-fg-disabled mt-0.5">
(SFR-009)
</div>
</div>
@ -857,17 +811,14 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
</div>
</div>
{/* 침수 상태 */}
<div
className="mt-2.5 px-3.5 py-2.5 rounded-md border border-[rgba(239,68,68,.12)]"
style={{ background: 'rgba(239,68,68,.04)' }}
>
<div className="text-[9px] font-bold text-[#f87171] mb-2">💧 </div>
<div className="mt-2.5 px-3.5 py-2.5 rounded-md border border-stroke bg-bg-card">
<div className="text-caption font-bold mb-2">💧 </div>
<div className="grid grid-cols-4 gap-2">
<div>
<label className="text-[8px] text-fg-disabled block mb-px"> </label>
<label className="text-caption text-fg-disabled block mb-px"> </label>
<select
defaultValue="2개"
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-[10px] outline-none"
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 outline-none"
>
{['1개', '2개', '3개', '4개 이상'].map((v) => (
<option key={v}>{v}</option>
@ -875,17 +826,17 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
</select>
</div>
<div>
<label className="text-[8px] text-fg-disabled block mb-px"> ()</label>
<label className="text-caption text-fg-disabled block mb-px"> ()</label>
<input
type="number"
defaultValue={850}
step={50}
min={0}
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-[10px] font-mono outline-none"
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
/>
</div>
<div>
<label className="text-[8px] text-fg-disabled block mb-px">
<label className="text-caption text-fg-disabled block mb-px">
(t/h)
</label>
<input
@ -893,17 +844,19 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
defaultValue={120}
step={10}
min={0}
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-[10px] font-mono outline-none"
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
/>
</div>
<div>
<label className="text-[8px] text-fg-disabled block mb-px"> (t/h)</label>
<label className="text-caption text-fg-disabled block mb-px">
(t/h)
</label>
<input
type="number"
defaultValue={80}
step={10}
min={0}
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-[10px] font-mono outline-none"
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
/>
</div>
</div>
@ -923,7 +876,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
<input type="text" defaultValue="128.4217" className={`${inputCls} font-mono`} />
</div>
<button
className="px-3.5 py-2 rounded-md border border-[rgba(6,182,212,.3)] text-color-accent text-[10px] font-semibold cursor-pointer whitespace-nowrap"
className="px-3.5 py-2 rounded-md border border-[rgba(6,182,212,.3)] text-color-accent text-label-2 font-semibold cursor-pointer whitespace-nowrap"
style={{ background: 'rgba(6,182,212,.08)' }}
>
📍
@ -932,12 +885,12 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
<div className="mt-2 grid grid-cols-4 gap-2.5">
<div>
<label className={labelCls}>
/ <span className="text-[#f87171]">*</span>
/ <span className="text-color-danger">*</span>
</label>
<div className="flex gap-1">
<select
defaultValue="SW"
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-[10px] outline-none"
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none"
>
{['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'].map((d) => (
<option key={d}>{d}</option>
@ -948,13 +901,13 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
defaultValue={12.5}
step={0.5}
min={0}
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-[10px] font-mono outline-none text-center"
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-label-2 font-mono outline-none text-center"
/>
</div>
</div>
<div>
<label className={labelCls}>
(m) <span className="text-[#f87171]">*</span>
(m) <span className="text-color-danger">*</span>
</label>
<input type="number" defaultValue={2.5} step={0.1} min={0} className={numCls} />
</div>
@ -963,7 +916,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
<div className="flex gap-1">
<select
defaultValue="NE"
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-[10px] outline-none"
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none"
>
{['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'].map((d) => (
<option key={d}>{d}</option>
@ -974,7 +927,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
defaultValue={1.2}
step={0.1}
min={0}
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-[10px] font-mono outline-none text-center"
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-label-2 font-mono outline-none text-center"
/>
</div>
</div>
@ -1017,7 +970,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
<div className="grid grid-cols-3 gap-2.5">
<div>
<label className={labelCls}>
<span className="text-[#f87171]">*</span>
<span className="text-color-danger">*</span>
</label>
<select defaultValue="R&D 긴급구난 종합분석" className={inputCls}>
{[
@ -1044,7 +997,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
{['복원성', '예인력', '인양력', '유출 위험'].map((item) => (
<label
key={item}
className="flex items-center gap-px text-[8px] text-fg-sub cursor-pointer"
className="flex items-center gap-px text-caption text-fg-sub cursor-pointer"
>
<input
type="checkbox"
@ -1058,22 +1011,19 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
</div>
</div>
{/* R&D 연계 분석 */}
<div
className="mt-2.5 px-3.5 py-2.5 flex flex-col gap-1.5 rounded-md border border-[rgba(249,115,22,.12)]"
style={{ background: 'rgba(249,115,22,.04)' }}
>
<div className="text-[9px] font-bold text-color-warning">🔗 R&D </div>
<div className="mt-2.5 px-3.5 py-2.5 flex flex-col gap-1.5 rounded-md border border-stroke bg-bg-card">
<div className="text-caption font-bold">🔗 R&D </div>
<div className="flex gap-3">
<label className="flex items-center gap-1 text-[9px] text-fg-sub cursor-pointer">
<input type="checkbox" style={{ accentColor: 'var(--color-warning)' }} />
<label className="flex items-center gap-1 text-caption text-fg-sub cursor-pointer">
<input type="checkbox" style={{ accentColor: 'var(--color-accent)' }} />
</label>
<label className="flex items-center gap-1 text-[9px] text-fg-sub cursor-pointer">
<input type="checkbox" style={{ accentColor: 'var(--color-warning)' }} /> HNS
<label className="flex items-center gap-1 text-caption text-fg-sub cursor-pointer">
<input type="checkbox" style={{ accentColor: 'var(--color-accent)' }} /> HNS
</label>
</div>
<div className="text-[8px] text-fg-disabled leading-[1.5]">
<div className="text-caption text-fg-disabled leading-[1.5]">
,
</div>
@ -1085,16 +1035,16 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
<div className={sectionTitleCls}>{sectionIcon(6)} </div>
<textarea
placeholder="시나리오 설명, 가정 조건, 현장 상황 특이사항 등을 기록합니다..."
className="w-full h-[60px] px-3 py-2.5 rounded-md border border-stroke bg-bg-base text-[10px] outline-none resize-y leading-relaxed scrollbar-thin"
className="w-full h-[60px] px-3 py-2.5 rounded-md border border-stroke bg-bg-base text-label-2 outline-none resize-y leading-relaxed scrollbar-thin"
/>
</div>
</div>
{/* ── 하단 버튼 ── */}
<div className="px-6 py-4 border-t border-stroke shrink-0 flex gap-2 items-center">
<div className="flex-1 text-[9px] text-fg-disabled leading-[1.5]">
<span className="text-[#f87171]">*</span> · / API
<div className="flex-1 text-caption text-fg-disabled leading-[1.5]">
<span className="text-color-danger">*</span> · /
API
</div>
<button
onClick={onClose}
@ -1105,11 +1055,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
{done ? (
<button
onClick={onClose}
className="px-7 py-2.5 rounded-md border-none text-white text-xs font-bold cursor-pointer"
style={{
background: 'linear-gradient(135deg,#22c55e,#10b981)',
boxShadow: '0 4px 16px rgba(34,197,94,.3)',
}}
className="px-7 py-2.5 rounded-md border-none text-static-white text-xs font-bold cursor-pointer bg-color-success"
>
</button>
@ -1117,15 +1063,11 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
<button
onClick={handleSubmit}
disabled={submitting}
className="px-7 py-2.5 rounded-md border-none text-xs font-bold"
style={{
background: submitting
? 'var(--bg-card)'
: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
color: submitting ? 'var(--fg-disabled)' : '#fff',
cursor: submitting ? 'wait' : 'pointer',
boxShadow: submitting ? 'none' : '0 4px 16px rgba(6,182,212,.3)',
}}
className={`px-7 py-2.5 rounded-md border-none text-xs font-bold ${
submitting
? 'bg-bg-card text-fg-disabled cursor-wait'
: 'bg-color-navy text-static-white cursor-pointer hover:bg-color-navy-hover'
}`}
>
{submitting ? '⏳ 분석 중...' : '🚨 시나리오 생성 · 분석 실행'}
</button>
@ -1148,7 +1090,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
if (chartData.length === 0) {
return (
<div className="p-10 text-center text-[11px] text-fg-disabled">
<div className="p-10 text-center text-label-2 text-fg-disabled">
.
</div>
);
@ -1158,9 +1100,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
<div className="p-5">
{/* Chart 1: GM 추이 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-4 mb-4">
<div className="text-[11px] font-bold text-color-accent mb-2.5">
📈 GM () (m)
</div>
<div className="text-label-2 font-bold mb-2.5">📈 GM () (m)</div>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 180 }}>
{/* Grid */}
{[0, 0.5, 1.0, 1.5, 2.0].map((v) => {
@ -1252,9 +1192,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
<div className="grid grid-cols-2 gap-4 mb-4">
{/* Chart 2: 횡경사 변화 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
<div className="text-[11px] font-bold text-color-warning mb-2.5">
📉 (List) (°)
</div>
<div className="text-label-2 font-bold mb-2.5">📉 (List) (°)</div>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 160 }}>
{[0, 5, 10, 15, 20, 25].map((v) => {
const y = PY + ph - (v / 25) * ph;
@ -1317,9 +1255,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
{/* Chart 3: 유출률 변화 (bar) */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
<div className="text-[11px] font-bold text-color-danger mb-2.5">
📊 (L/min)
</div>
<div className="text-label-2 font-bold mb-2.5">📊 (L/min)</div>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 160 }}>
{[0, 50, 100, 150, 200].map((v) => {
const y = PY + ph - (v / 200) * ph;
@ -1383,11 +1319,11 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
{/* Chart 4: 비교 테이블 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
<div className="text-[11px] font-bold mb-2.5">📋 </div>
<table className="w-full border-collapse text-[9px]">
<div className="text-label-2 font-bold mb-2.5">📋 </div>
<table className="w-full border-collapse text-caption">
<thead>
<tr style={{ background: 'rgba(6,182,212,.06)' }}>
<th className="py-[7px] px-2 text-left border-b-2 border-[var(--stroke-light)] text-color-accent">
<tr className="bg-bg-base">
<th className="py-[7px] px-2 text-left border-b-2 border-[var(--stroke-light)] text-fg-sub">
</th>
{chartData.map((d) => (
@ -1398,7 +1334,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
>
{d.id}
<br />
<span className="font-normal text-[8px] text-fg-disabled">{d.label}</span>
<span className="font-normal text-caption text-fg-disabled">{d.label}</span>
</th>
))}
</tr>

파일 보기

@ -1,281 +1,446 @@
import { sanitizeHtml } from '@common/utils/sanitize';
/* ── 논문 아이템 타입 ── */
interface PaperItem {
number: string;
numberBg: string;
numberColor?: string;
title: string;
citation: React.ReactNode;
tags: { label: string; color: string }[];
description: string;
}
/* ── 논문 카드 ── */
function PaperCard({ item }: { item: PaperItem }) {
return (
<div
className="grid gap-2 rounded-md bg-bg-base p-[8px_10px]"
style={{ gridTemplateColumns: '24px 1fr' }}
>
<div
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-label-2 font-bold"
style={{ background: item.numberBg, color: item.numberColor }}
>
{item.number}
</div>
<div>
<div className="mb-0.5 text-label-1 font-bold text-fg">{item.title}</div>
<div className="text-label-2 leading-relaxed text-fg-disabled">{item.citation}</div>
<div className="mt-1 flex flex-wrap gap-1">
{item.tags.map((t) => (
<span
key={t.label}
className="rounded px-1.5 py-px text-label-2"
style={{
background: `color-mix(in srgb, ${t.color} 8%, transparent)`,
border: `1px solid color-mix(in srgb, ${t.color} 20%, transparent)`,
color: t.color,
}}
>
{t.label}
</span>
))}
</div>
<div className="mt-0.5 text-label-2 text-fg-sub">{item.description}</div>
</div>
</div>
);
}
/* ── 섹션 카드 (헤더 + 논문 목록) ── */
function SectionCard({
icon,
title,
papers,
}: {
icon: string;
title: string;
papers: PaperItem[];
}) {
return (
<div className="overflow-hidden rounded-[var(--radius-md)] border border-stroke bg-bg-card">
<div className="flex items-center gap-2 border-b border-stroke bg-bg-base px-4 py-3">
<span className="text-title-3">{icon}</span>
<span className="text-label-1 font-bold font-korean text-fg">{title}</span>
</div>
<div className="flex flex-col gap-2 p-[14px_16px] text-label-2 font-korean">
{papers.map((p) => (
<PaperCard key={p.number + p.title} item={p} />
))}
</div>
</div>
);
}
/* ── 논문 데이터 ── */
const DOI_LINK_CLASS = 'text-color-info no-underline';
const optimizationPapers: PaperItem[] = [
{
number: '①',
numberBg: 'rgba(6,182,212,.2)',
numberColor: 'var(--color-accent)',
title:
'A Multi-Objective Optimization Method for Maritime SAR Resource Allocation: South China Sea',
citation: (
<>
Dong, Y. et al. | <i>J. Marine Science and Engineering</i> Vol.12(1):184, 2024 · DOI:{' '}
<a
href="https://doi.org/10.3390/jmse12010184"
target="_blank"
rel="noreferrer"
className={DOI_LINK_CLASS}
>
10.3390/jmse12010184
</a>{' '}
· MDPI Open Access
</>
),
tags: [
{ label: 'DNSGA-II', color: 'var(--color-accent)' },
{ label: '다목적 최적화', color: 'var(--color-tertiary)' },
{ label: '구조선·항공기 배치', color: 'var(--color-info)' },
],
description:
'해상 사고 후 구조자원(구조선·항공기) 배치 최적화. 비용·응답시간 동시 최소화 DNSGA-II 진화적 최적화. 남중국해 적용 검증. WING 긴급구난 자산 우선순위 배치 알고리즘의 직접 참고 모델.',
},
{
number: '②',
numberBg: 'rgba(6,182,212,.2)',
numberColor: 'var(--color-accent)',
title: 'Optimized Maritime Emergency Resource Allocation under Dynamic Demand',
citation: (
<>
Zhang, W., Yan, X. &amp; Yang, J. | <i>PLoS ONE</i> 12(12):e0189411, 2017 · DOI:{' '}
<a
href="https://doi.org/10.1371/journal.pone.0189411"
target="_blank"
rel="noreferrer"
className={DOI_LINK_CLASS}
>
10.1371/journal.pone.0189411
</a>{' '}
· Open Access
</>
),
tags: [
{ label: '동적 수요 모델', color: 'var(--color-accent)' },
{ label: '강건 최적화(Robust)', color: 'var(--color-warning)' },
{ label: '불확실 수요 대응', color: 'var(--color-tertiary)' },
],
description:
'동적·불확실 수요 반영 긴급구난 자원 배치 다목적 최적화 및 강건 최적화. WING 해상 상황 변화에 따른 실시간 자원 재배치 모델 이론 근거.',
},
{
number: '③',
numberBg: 'rgba(6,182,212,.12)',
title: 'A Method for Optimizing Maritime Emergency Resource Allocation in Inland Waterways',
citation: (
<>
Ma, Q. et al. | <i>Ocean Engineering</i> Vol.282:116224, 2023 · DOI:{' '}
<a
href="https://doi.org/10.1016/j.oceaneng.2023.116224"
target="_blank"
rel="noreferrer"
className={DOI_LINK_CLASS}
>
10.1016/j.oceaneng.2023.116224
</a>{' '}
· Open Access
</>
),
tags: [
{ label: 'AHP', color: 'var(--color-success)' },
{ label: 'DEA 효율성 평가', color: 'var(--color-info)' },
{ label: '내수로 적용', color: 'var(--color-warning)' },
],
description:
'AHP(계층분석법)+DEA(자료포락분석)로 긴급구난 자원 효율성 평가 및 최적 배치. WING 자원 효율성 지수 산정·우선순위 결정 방법론 참조.',
},
];
const oilSpillPapers: PaperItem[] = [
{
number: '①',
numberBg: 'rgba(249,115,22,.2)',
numberColor: 'var(--color-warning)',
title:
'Dynamic Optimization of Emergency Resource Scheduling in a Large-Scale Maritime Oil Spill Accident',
citation: (
<>
Zhang, L. | LJMU Research Online, 2020 ·{' '}
<a
href="https://researchonline.ljmu.ac.uk/id/eprint/14880/3/Dynamic%20optimization%20of%20emergency%20resource%20scheduling%20in%20a%20large-scale%20maritime%20oil%20spill%20accident.pdf"
target="_blank"
rel="noreferrer"
className={DOI_LINK_CLASS}
>
PDF
</a>
</>
),
tags: [
{ label: 'Location-Routing 최적화', color: 'var(--color-warning)' },
{ label: '하이브리드 휴리스틱', color: 'var(--color-tertiary)' },
{ label: 'Pareto 다목적', color: 'var(--color-accent)' },
],
description:
'동적 Location-Routing 최적화 모델 + 하이브리드 휴리스틱 알고리즘으로 대규모 해양 오일스필 응급자원 스케줄링·라우팅 다목적 최적화. WING 방제정 동적 라우팅 모델의 이론 선행연구.',
},
];
const uavPapers: PaperItem[] = [
{
number: '①',
numberBg: 'rgba(168,85,247,.12)',
title: 'From Forecast to Action: UAV-Based Maritime SAR Deployment Optimization',
citation: (
<>
arXiv:2512.09260 ·{' '}
<a
href="https://arxiv.org/abs/2512.09260"
target="_blank"
rel="noreferrer"
className={DOI_LINK_CLASS}
>
arxiv.org/abs/2512.09260
</a>
</>
),
tags: [
{ label: '예측 기반 UAV 배치', color: 'var(--color-tertiary)' },
{ label: 'SAR 최적화', color: 'var(--color-success)' },
],
description:
'해상 조난 예측 → UAV 사전 배치 최적화. 예측-행동 연계 프레임워크. WING 항공탐색 연동 구조자원 선제 배치 참고 연구.',
},
{
number: '②',
numberBg: 'rgba(168,85,247,.12)',
title: 'UAV-Assisted SAR Latency Optimization',
citation: (
<>
arXiv:2511.00844 ·{' '}
<a
href="https://arxiv.org/abs/2511.00844"
target="_blank"
rel="noreferrer"
className={DOI_LINK_CLASS}
>
arxiv.org/abs/2511.00844
</a>
</>
),
tags: [
{ label: 'UAV 지원 구조', color: 'var(--color-tertiary)' },
{ label: '응답 지연 최소화', color: 'var(--color-danger)' },
],
description: 'UAV 지원 해상 구조 처리·지연 최적화. 통신·처리 지연 모델링 및 UAV 경로 최적화.',
},
{
number: '③',
numberBg: 'rgba(168,85,247,.12)',
title: 'UAV Path Planning for SAR: Performance Evaluation',
citation: (
<>
arXiv:2402.01494 ·{' '}
<a
href="https://arxiv.org/abs/2402.01494"
target="_blank"
rel="noreferrer"
className={DOI_LINK_CLASS}
>
arxiv.org/abs/2402.01494
</a>
</>
),
tags: [
{ label: 'UAV 경로계획', color: 'var(--color-tertiary)' },
{ label: '성능 평가', color: 'var(--color-info)' },
],
description: 'SAR 임무용 UAV 경로계획 알고리즘 성능 비교 평가. 탐색 커버리지·효율성 지표 분석.',
},
{
number: '④',
numberBg: 'rgba(168,85,247,.12)',
title: 'Probabilistic Submarine Search and Rescue Strategy',
citation: (
<>
arXiv:2505.02186 ·{' '}
<a
href="https://arxiv.org/abs/2505.02186"
target="_blank"
rel="noreferrer"
className={DOI_LINK_CLASS}
>
arxiv.org/abs/2505.02186
</a>
</>
),
tags: [
{ label: '확률론적 탐색', color: 'var(--color-tertiary)' },
{ label: '잠수함 구조', color: 'var(--color-danger)' },
],
description:
'잠수함 구조 전략 확률론적 최적화. Bayesian 탐색 이론 기반 구조자원 투입 전략 도출.',
},
];
/* ── 비교 요약 테이블 데이터 ── */
const comparisonRows = [
{ paper: 'Dong et al. (2024)', keyword: 'DNSGA-II 다목적 최적화', usage: '구조자산 배치 최적화' },
{ paper: 'Zhang et al. (2017)', keyword: '동적수요·강건 최적화', usage: '실시간 자원 재배치' },
{ paper: 'Ma et al. (2023)', keyword: 'AHP + DEA', usage: '자원 효율성 평가' },
{ paper: 'Zhang (2020)', keyword: '동적 Location-Routing', usage: '방제정 라우팅 모델' },
{ paper: 'arXiv:2512.09260', keyword: '예측→UAV 배치', usage: '항공탐색 연동 선제배치' },
{ paper: 'arXiv:2505.02186', keyword: '확률론적 탐색전략', usage: '구조구역 확률 탐색' },
];
/* ── 긴급구난 관련 논문 데이터 ── */
interface RelatedPaper {
tags: { label: string; color: string }[];
year: string;
title: string;
meta: string;
abstract: string;
}
const relatedPapers: RelatedPaper[] = [
{
tags: [
{ label: '수색구조', color: 'var(--color-info)' },
{ label: '의사결정지원', color: 'var(--fg-default)' },
],
year: '2025',
title: '지능형 해양수색구조 의사결정지원시스템: 신속한 대응을 위한 데이터와 기술 활용',
meta: '김충기, 정해상, 이성숙, 윤종휘 | 한국해양환경·에너지학회 학술대회논문집 | 2025.5 | pp.160',
abstract:
'초고해상도 3차원 연안 해양예측모델, 다중모델 앙상블 기법, AI 기반 확률론적 표류경로 예측 기술을 통합한 지능형 해양 수색구조 의사결정지원시스템 개발. 실해역 부유체 표류 실험과 예측 모델 검증을 통해 고정밀 성능을 확보하고, 수색 성공 확률 기반 스마트 수색계획 자동화 및 최적 자원 동원 알고리즘을 개발. 사고 발생부터 표류 예측, 수색계획 수립, 자원배치, 결과보고에 이르는 전 과정을 통합한 플랫폼을 시범 구축하고 시뮬레이션을 통해 현장 활용성을 확인. 해양경찰청 지원(RS-2022-KS221629).',
},
{
tags: [
{ label: 'AI·SAR', color: 'var(--color-info)' },
{ label: '한국형 시스템', color: 'var(--color-accent)' },
{ label: '표류예측', color: 'var(--color-success)' },
],
year: '2024',
title: 'AI 기반 한국형 해양수색구조 의사결정 지원시스템',
meta: '김충기, 정해상, 윤종휘, 박창석, 김종호 | 한국환경연구원 물국토연구본부, 한국해양대학교 해양경찰학부 | 한국해양환경·에너지학회 추계학술대회 | 2024.11 | pp.127',
abstract:
'한국형 부유체 표류특성을 분석하고, 초고해상도 3차원 연안 해양예측 모델 개발, 다중모델 앙상블 해양기상 예측, AI 기반 부유체 표류경로 예측, AI 기반 수색전략 수립 지원 등 AI 기반 한국형 해양수색구조 의사결정 지원 시스템을 구축. 연간 해양사고 경제적 손실 4,390억~5,420억 원 규모에 대응하여 신속하고 정확한 수색구조 활동을 지원하고 조난자의 생존 확률을 높이고 구조인력의 안전을 보장. 해양경찰청 지원(RS-2022-KS221629, 지능형 해양사고 대응 플랫폼 구축).',
},
];
/* ═══ 메인 컴포넌트 ═══ */
export function RescueTheoryView() {
const contentHtml = `
<div style="font-size:18px;font-weight:700;margin-bottom:4px;font-family:var(--font-korean)">📚 </div>
<div style="font-size:12px;color:var(--fg-disabled);margin-bottom:22px;font-family:var(--font-korean)"> · </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start">
<!-- -->
<div style="display:flex;flex-direction:column;gap:14px">
<!-- -->
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:var(--radius-md);overflow:hidden">
<div style="padding:12px 16px;background:rgba(6,182,212,.08);border-bottom:1px solid var(--stroke-default);display:flex;align-items:center;gap:8px">
<span style="font-size:14px"></span>
<span style="font-size:12px;font-weight:700;color:var(--color-accent);font-family:var(--font-korean)"> </span>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:8px;font-size:9px;font-family:var(--font-korean)">
<!-- Dong et al. 2024 -->
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px;border-left:2px solid var(--color-accent)">
<div style="width:20px;height:20px;border-radius:4px;background:rgba(6,182,212,.2);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0;font-weight:700;color:var(--color-accent)"></div>
<div>
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">A Multi-Objective Optimization Method for Maritime SAR Resource Allocation: South China Sea</div>
<div style="color:var(--fg-disabled);line-height:1.6">Dong, Y. et al. | <i>J. Marine Science and Engineering</i> Vol.12(1):184, 2024 · DOI: <a href="https://doi.org/10.3390/jmse12010184" target="_blank" style="color:var(--color-info);text-decoration:none">10.3390/jmse12010184</a> · MDPI Open Access</div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
<span style="padding:1px 5px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:3px;color:var(--color-accent);font-size:8px">DNSGA-II</span>
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px"> </span>
<span style="padding:1px 5px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.2);border-radius:3px;color:var(--color-info);font-size:8px">· </span>
</div>
<div style="margin-top:2px;color:var(--fg-sub)"> (·) . · DNSGA-II . . WING .</div>
</div>
return (
<div
className="flex flex-1 flex-col overflow-hidden bg-bg-base"
style={{ height: '100%', width: '100%' }}
>
<div
className="flex-1 overflow-y-auto px-9 py-7"
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
>
{/* ── 헤더 ── */}
<div className="mb-1 text-title-1 font-bold font-korean">📚 </div>
<div className="mb-6 text-label-1 font-korean text-fg-disabled">
·
</div>
<!-- Zhang et al. 2017 -->
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px;border-left:2px solid var(--color-accent)">
<div style="width:20px;height:20px;border-radius:4px;background:rgba(6,182,212,.2);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0;font-weight:700;color:var(--color-accent)"></div>
<div>
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">Optimized Maritime Emergency Resource Allocation under Dynamic Demand</div>
<div style="color:var(--fg-disabled);line-height:1.6">Zhang, W., Yan, X. &amp; Yang, J. | <i>PLoS ONE</i> 12(12):e0189411, 2017 · DOI: <a href="https://doi.org/10.1371/journal.pone.0189411" target="_blank" style="color:var(--color-info);text-decoration:none">10.1371/journal.pone.0189411</a> · Open Access</div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
<span style="padding:1px 5px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:3px;color:var(--color-accent);font-size:8px"> </span>
<span style="padding:1px 5px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:3px;color:var(--color-warning);font-size:8px"> (Robust)</span>
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px"> </span>
</div>
<div style="margin-top:2px;color:var(--fg-sub)">· . WING .</div>
</div>
{/* ── 2열 그리드 ── */}
<div className="grid grid-cols-2 items-start gap-[18px]">
{/* 왼쪽 컬럼 */}
<div className="flex flex-col gap-3.5">
<SectionCard icon="⚙️" title="다목적 최적화 자원 배치" papers={optimizationPapers} />
<SectionCard icon="🛢" title="오일스필 동적 자원 스케줄링" papers={oilSpillPapers} />
</div>
<!-- Ma et al. 2023 -->
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
<div style="width:20px;height:20px;border-radius:4px;background:rgba(6,182,212,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0"></div>
<div>
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">A Method for Optimizing Maritime Emergency Resource Allocation in Inland Waterways</div>
<div style="color:var(--fg-disabled);line-height:1.6">Ma, Q. et al. | <i>Ocean Engineering</i> Vol.282:116224, 2023 · DOI: <a href="https://doi.org/10.1016/j.oceaneng.2023.116224" target="_blank" style="color:var(--color-info);text-decoration:none">10.1016/j.oceaneng.2023.116224</a> · Open Access</div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
<span style="padding:1px 5px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:3px;color:var(--color-success);font-size:8px">AHP</span>
<span style="padding:1px 5px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.2);border-radius:3px;color:var(--color-info);font-size:8px">DEA </span>
<span style="padding:1px 5px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:3px;color:var(--color-warning);font-size:8px"> </span>
</div>
<div style="margin-top:2px;color:var(--fg-sub)">AHP()+DEA() . WING · .</div>
</div>
</div>
{/* 오른쪽 컬럼 */}
<div className="flex flex-col gap-3.5">
<SectionCard icon="🚁" title="UAV·예측 기반 해상구조 (보조자료)" papers={uavPapers} />
{/* 비교 요약 테이블 */}
<div className="overflow-hidden rounded-[var(--radius-md)] border border-stroke bg-bg-card">
<div className="flex items-center gap-2 border-b border-stroke bg-bg-base px-4 py-3">
<span className="text-title-3">📊</span>
<span className="text-label-1 font-bold font-korean text-fg"> </span>
</div>
</div>
<!-- -->
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:var(--radius-md);overflow:hidden">
<div style="padding:12px 16px;background:rgba(249,115,22,.08);border-bottom:1px solid var(--stroke-default);display:flex;align-items:center;gap:8px">
<span style="font-size:14px">🛢</span>
<span style="font-size:12px;font-weight:700;color:var(--color-warning);font-family:var(--font-korean)"> </span>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:8px;font-size:9px;font-family:var(--font-korean)">
<!-- Zhang 2020 -->
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px;border-left:2px solid var(--color-warning)">
<div style="width:20px;height:20px;border-radius:4px;background:rgba(249,115,22,.2);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0;font-weight:700;color:var(--color-warning)"></div>
<div>
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">Dynamic Optimization of Emergency Resource Scheduling in a Large-Scale Maritime Oil Spill Accident</div>
<div style="color:var(--fg-disabled);line-height:1.6">Zhang, L. | LJMU Research Online, 2020 · <a href="https://researchonline.ljmu.ac.uk/id/eprint/14880/3/Dynamic%20optimization%20of%20emergency%20resource%20scheduling%20in%20a%20large-scale%20maritime%20oil%20spill%20accident.pdf" target="_blank" style="color:var(--color-info);text-decoration:none">PDF </a></div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
<span style="padding:1px 5px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:3px;color:var(--color-warning);font-size:8px">Location-Routing </span>
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px"> </span>
<span style="padding:1px 5px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:3px;color:var(--color-accent);font-size:8px">Pareto </span>
</div>
<div style="margin-top:2px;color:var(--fg-sub)"> Location-Routing + · . WING .</div>
</div>
</div>
</div>
</div>
</div>
<!-- -->
<div style="display:flex;flex-direction:column;gap:14px">
<!-- UAV· SAR -->
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:var(--radius-md);overflow:hidden">
<div style="padding:12px 16px;background:rgba(168,85,247,.08);border-bottom:1px solid var(--stroke-default);display:flex;align-items:center;gap:8px">
<span style="font-size:14px">🚁</span>
<span style="font-size:12px;font-weight:700;color:var(--color-tertiary);font-family:var(--font-korean)">UAV· ()</span>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:8px;font-size:9px;font-family:var(--font-korean)">
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
<div style="width:20px;height:20px;border-radius:4px;background:rgba(168,85,247,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0"></div>
<div>
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">From Forecast to Action: UAV-Based Maritime SAR Deployment Optimization</div>
<div style="color:var(--fg-disabled);line-height:1.6">arXiv:2512.09260 · <a href="https://arxiv.org/abs/2512.09260" target="_blank" style="color:var(--color-info);text-decoration:none">arxiv.org/abs/2512.09260</a></div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px"> UAV </span>
<span style="padding:1px 5px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:3px;color:var(--color-success);font-size:8px">SAR </span>
</div>
<div style="margin-top:2px;color:var(--fg-sub)"> UAV . - . WING .</div>
</div>
</div>
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
<div style="width:20px;height:20px;border-radius:4px;background:rgba(168,85,247,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0"></div>
<div>
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">UAV-Assisted SAR Latency Optimization</div>
<div style="color:var(--fg-disabled);line-height:1.6">arXiv:2511.00844 · <a href="https://arxiv.org/abs/2511.00844" target="_blank" style="color:var(--color-info);text-decoration:none">arxiv.org/abs/2511.00844</a></div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">UAV </span>
<span style="padding:1px 5px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-radius:3px;color:var(--color-danger);font-size:8px"> </span>
</div>
<div style="margin-top:2px;color:var(--fg-sub)">UAV · . · UAV .</div>
</div>
</div>
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
<div style="width:20px;height:20px;border-radius:4px;background:rgba(168,85,247,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0"></div>
<div>
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">UAV Path Planning for SAR: Performance Evaluation</div>
<div style="color:var(--fg-disabled);line-height:1.6">arXiv:2402.01494 · <a href="https://arxiv.org/abs/2402.01494" target="_blank" style="color:var(--color-info);text-decoration:none">arxiv.org/abs/2402.01494</a></div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">UAV </span>
<span style="padding:1px 5px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.2);border-radius:3px;color:var(--color-info);font-size:8px"> </span>
</div>
<div style="margin-top:2px;color:var(--fg-sub)">SAR UAV . · .</div>
</div>
</div>
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
<div style="width:20px;height:20px;border-radius:4px;background:rgba(168,85,247,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0"></div>
<div>
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">Probabilistic Submarine Search and Rescue Strategy</div>
<div style="color:var(--fg-disabled);line-height:1.6">arXiv:2505.02186 · <a href="https://arxiv.org/abs/2505.02186" target="_blank" style="color:var(--color-info);text-decoration:none">arxiv.org/abs/2505.02186</a></div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px"> </span>
<span style="padding:1px 5px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-radius:3px;color:var(--color-danger);font-size:8px"> </span>
</div>
<div style="margin-top:2px;color:var(--fg-sub)"> . Bayesian .</div>
</div>
</div>
</div>
</div>
<!-- -->
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:var(--radius-md);overflow:hidden">
<div style="padding:12px 16px;background:rgba(34,197,94,.08);border-bottom:1px solid var(--stroke-default);display:flex;align-items:center;gap:8px">
<span style="font-size:14px">📊</span>
<span style="font-size:12px;font-weight:700;color:var(--color-success);font-family:var(--font-korean)"> </span>
</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:9px;font-family:var(--font-korean)">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-label-2 font-korean">
<thead>
<tr style="background:var(--bg-base)">
<th style="padding:8px 10px;text-align:left;font-weight:700;color:var(--fg-sub);border-bottom:1px solid var(--stroke-default)"></th>
<th style="padding:8px 10px;text-align:left;font-weight:700;color:var(--fg-sub);border-bottom:1px solid var(--stroke-default)"></th>
<th style="padding:8px 10px;text-align:left;font-weight:700;color:var(--fg-sub);border-bottom:1px solid var(--stroke-default)">WING </th>
<tr className="bg-bg-base">
{['논문', '키워드', 'WING 활용'].map((h) => (
<th
key={h}
className="border-b border-stroke px-2.5 py-2 text-left font-bold text-fg-sub"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid var(--stroke-default)">
<td style="padding:7px 10px;font-weight:700;color:var(--color-accent)">Dong et al. (2024)</td>
<td style="padding:7px 10px;color:var(--fg-sub)">DNSGA-II </td>
<td style="padding:7px 10px;color:var(--fg-disabled)"> </td>
</tr>
<tr style="border-bottom:1px solid var(--stroke-default)">
<td style="padding:7px 10px;font-weight:700;color:var(--color-accent)">Zhang et al. (2017)</td>
<td style="padding:7px 10px;color:var(--fg-sub)">· </td>
<td style="padding:7px 10px;color:var(--fg-disabled)"> </td>
</tr>
<tr style="border-bottom:1px solid var(--stroke-default)">
<td style="padding:7px 10px;font-weight:700;color:var(--color-accent)">Ma et al. (2023)</td>
<td style="padding:7px 10px;color:var(--fg-sub)">AHP + DEA</td>
<td style="padding:7px 10px;color:var(--fg-disabled)"> </td>
</tr>
<tr style="border-bottom:1px solid var(--stroke-default)">
<td style="padding:7px 10px;font-weight:700;color:var(--color-warning)">Zhang (2020)</td>
<td style="padding:7px 10px;color:var(--fg-sub)"> Location-Routing</td>
<td style="padding:7px 10px;color:var(--fg-disabled)"> </td>
</tr>
<tr style="border-bottom:1px solid var(--stroke-default)">
<td style="padding:7px 10px;font-weight:700;color:var(--color-tertiary)">arXiv:2512.09260</td>
<td style="padding:7px 10px;color:var(--fg-sub)">UAV </td>
<td style="padding:7px 10px;color:var(--fg-disabled)"> </td>
</tr>
<tr>
<td style="padding:7px 10px;font-weight:700;color:var(--color-tertiary)">arXiv:2505.02186</td>
<td style="padding:7px 10px;color:var(--fg-sub)"> </td>
<td style="padding:7px 10px;color:var(--fg-disabled)"> </td>
{comparisonRows.map((r, i) => (
<tr
key={r.paper}
className={i < comparisonRows.length - 1 ? 'border-b border-stroke' : ''}
>
<td className="px-2.5 py-[7px] font-bold text-fg">{r.paper}</td>
<td className="px-2.5 py-[7px] text-fg-sub">{r.keyword}</td>
<td className="px-2.5 py-[7px] text-fg-disabled">{r.usage}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- -->
<div style="margin-top:18px;border-radius:12px;padding:16px;background:linear-gradient(135deg,rgba(168,85,247,.05),rgba(59,130,246,.03));border:1px solid rgba(168,85,247,.2);position:relative;overflow:hidden">
<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="display:flex;align-items:center;gap:10px;margin-bottom:14px">
<div style="width:30px;height:30px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:14px;background:rgba(168,85,247,.12);border:1px solid rgba(168,85,247,.25);flex-shrink:0">📄</div>
{/* ── 긴급구난 관련 논문 ── */}
<div className="mt-[18px] overflow-hidden rounded-xl border border-stroke bg-bg-card p-4">
<div className="mb-3.5 flex items-center gap-2.5">
<div className="flex h-[30px] w-[30px] flex-shrink-0 items-center justify-content-center rounded-[7px] border border-stroke bg-bg-base text-title-3">
📄
</div>
<div>
<div style="font-size:12px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean)"> </div>
<div style="font-size:10px;margin-top:2px;color:var(--fg-disabled);font-family:var(--font-korean)"> · ·AI </div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="border-radius:9px;padding:12px;background:var(--bg-surface);border:1px solid rgba(168,85,247,.18);border-left:3px solid var(--color-tertiary)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<div style="display:flex;align-items:center;gap:5px">
<span style="padding:2px 7px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.22);border-radius:4px;font-size:10px;font-weight:700;color:#3b82f6;font-family:var(--font-korean)"></span>
<span style="padding:2px 7px;background:rgba(168,85,247,.1);border:1px solid rgba(168,85,247,.22);border-radius:4px;font-size:10px;font-weight:700;color:var(--color-tertiary);font-family:var(--font-korean)"></span>
</div>
<span style="font-size:10px;color:var(--fg-disabled);font-family:var(--font-korean)">2025</span>
</div>
<div style="font-size:11px;font-weight:700;margin-bottom:4px;color:var(--fg-default);font-family:var(--font-korean)"> 의사결정지원시스템: 신속한 </div>
<div style="font-size:11px;margin-bottom:6px;color:var(--fg-disabled);font-family:var(--font-korean)">, , , | · | 2025.5 | pp.160</div>
<div style="font-size:11px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7"> 3 , , AI . , . , , , . (RS-2022-KS221629).</div>
</div>
<div style="border-radius:9px;padding:12px;background:var(--bg-surface);border:1px solid rgba(59,130,246,.18);border-left:3px solid #3b82f6">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<div style="display:flex;align-items:center;gap:5px">
<span style="padding:2px 7px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.22);border-radius:4px;font-size:10px;font-weight:700;color:#3b82f6;font-family:var(--font-korean)">AI·SAR</span>
<span style="padding:2px 7px;background:rgba(6,182,212,.1);border:1px solid rgba(6,182,212,.22);border-radius:4px;font-size:10px;font-weight:700;color:var(--color-accent);font-family:var(--font-korean)"> </span>
<span style="padding:2px 7px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.22);border-radius:4px;font-size:10px;font-weight:700;color:var(--color-success);font-family:var(--font-korean)"></span>
</div>
<span style="font-size:10px;color:var(--fg-disabled);font-family:var(--font-korean)">2024</span>
</div>
<div style="font-size:11px;font-weight:700;margin-bottom:4px;color:var(--fg-default);font-family:var(--font-korean)">AI </div>
<div style="font-size:11px;margin-bottom:6px;color:var(--fg-disabled);font-family:var(--font-korean)">, , , , | , | · | 2024.11 | pp.127</div>
<div style="font-size:11px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7"> , 3 , , AI , AI AI . 4,390~5,420 . (RS-2022-KS221629, ).</div>
<div className="text-label-1 font-bold font-korean text-fg"> </div>
<div className="mt-0.5 text-label-2 font-korean text-fg-disabled">
· ·AI
</div>
</div>
</div>
`;
return (
<div
<div className="flex flex-col gap-2">
{relatedPapers.map((p) => (
<div key={p.title} className="rounded-[9px] border border-stroke bg-bg-surface p-3">
<div className="mb-1.5 flex items-center justify-between">
<div className="flex items-center gap-1.5">
{p.tags.map((t) => (
<span
key={t.label}
className="rounded px-[7px] py-0.5 text-label-2 font-bold font-korean"
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
flex: 1,
overflow: 'hidden',
background: 'var(--bg-base)',
background: `color-mix(in srgb, ${t.color} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${t.color} 22%, transparent)`,
color: t.color,
}}
>
<div
style={{
flex: 1,
overflowY: 'auto',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-light) transparent',
padding: '28px 36px',
}}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(contentHtml) }}
/>
{t.label}
</span>
))}
</div>
<span className="text-label-2 font-korean text-fg-disabled">{p.year}</span>
</div>
<div className="mb-1 text-label-2 font-bold font-korean text-fg">{p.title}</div>
<div className="mb-1.5 text-label-2 font-korean text-fg-disabled">{p.meta}</div>
<div className="text-label-2 font-korean leading-[1.7] text-fg-sub">
{p.abstract}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { Fragment, useState, useEffect, useCallback } from 'react';
import { useSubMenu } from '@common/hooks/useSubMenu';
import { RescueTheoryView } from './RescueTheoryView';
import { RescueScenarioView } from './RescueScenarioView';
@ -190,30 +190,25 @@ function TopInfoBar({ activeType }: { activeType: AccidentType }) {
<span className="text-title-3"></span>
<span className="text-label-1 font-bold text-fg font-korean"></span>
</div>
<div
className="px-3.5 py-0.5 rounded-xl text-label-2 font-bold text-color-danger font-korean"
style={{
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 35%, transparent)',
}}
>
<div className="flex items-center gap-1.5 text-label-2 text-color-danger font-korean">
{/* <span className="w-2.5 h-2.5 rounded-sm bg-color-danger inline-block" /> */}
: {d.incident}
</div>
<div className="flex gap-3 text-caption font-mono text-fg-disabled ml-auto">
<span>
: <b className="text-fg">{d.survivors}</b>/{d.total}
: <span className="text-fg">{d.survivors}</span>/{d.total}
</span>
<span>
: <b className="text-color-danger">{d.missing}</b>
: <span className="text-fg">{d.missing}</span>
</span>
<span>
GM: <b style={{ color: gmColor(d.gm) }}>{d.gm}m</b>
GM: <span className="text-fg">{d.gm}m</span>
</span>
<span>
: <b style={{ color: listColor(d.list) }}>{d.list}°</b>
: <span className="text-fg">{d.list}°</span>
</span>
<span>
: <b className="text-color-warning">{d.oilRate}</b>
: <span className="text-fg">{d.oilRate}</span>
</span>
</div>
<div className="text-title-4 font-bold text-fg font-mono">{clock}</div>
@ -233,7 +228,7 @@ function LeftPanel({
return (
<div className="w-[208px] min-w-[208px] bg-bg-base border-r border-stroke flex flex-col overflow-y-auto scrollbar-thin p-2 gap-0.5">
{/* 사고유형 제목 */}
<div className="text-caption font-bold text-color-info font-korean mb-0.5 tracking-wider">
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
(INCIDENT TYPE)
</div>
@ -264,16 +259,16 @@ function LeftPanel({
<div className="text-caption font-bold text-fg-disabled font-korean mt-2.5 mb-1">
(CRITICAL ALERTS)
</div>
<div className="py-1.5 px-2.5 bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] border-l-[3px] border-l-[var(--color-danger)] rounded-r text-caption font-bold text-color-danger font-korean">
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
GM
</div>
<div className="py-1.5 px-2.5 bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] border-l-[3px] border-l-[var(--color-danger)] rounded-r text-caption font-bold text-color-danger font-korean">
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
5
</div>
<div className="py-1.5 px-2.5 bg-[color-mix(in_srgb,var(--color-warning)_12%,transparent)] border-l-[3px] border-l-[var(--color-warning)] rounded-r text-caption font-bold text-color-warning font-korean">
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
-
</div>
<div className="py-1.5 px-2.5 bg-[color-mix(in_srgb,var(--color-caution)_10%,transparent)] border-l-[3px] border-l-[var(--color-caution)] rounded-r text-caption font-bold text-color-caution font-korean">
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
88%
</div>
@ -297,7 +292,6 @@ function LeftPanel({
/* ─── 중앙 지도 영역 ─── */
function CenterMap({ activeType }: { activeType: AccidentType }) {
const d = rscTypeData[activeType];
const at = accidentTypes.find((t) => t.id === activeType)!;
return (
<div className="flex-1 relative overflow-hidden bg-bg-base">
@ -427,12 +421,12 @@ function CenterMap({ activeType }: { activeType: AccidentType }) {
</div>
{/* 사고 유형 표시 */}
<div className="absolute bottom-2.5 right-2.5 z-20 bg-[rgba(13,17,23,0.85)] border border-stroke rounded px-3 py-1.5">
{/* <div className="absolute bottom-2.5 right-2.5 z-20 bg-[rgba(13,17,23,0.85)] border border-stroke rounded px-3 py-1.5">
<div className="text-caption text-fg-disabled font-korean"> </div>
<div className="text-label-2 font-bold font-korean text-color-accent">
{at.icon} {at.label} ({at.eng})
</div>
</div>
</div> */}
{/* 타임라인 시뮬레이션 컨트롤 */}
<div className="absolute bottom-2.5 left-1/2 -translate-x-1/2 z-20 bg-[rgba(13,17,23,0.9)] border border-stroke rounded-md px-4 py-2 flex items-center gap-4 backdrop-blur-sm">
@ -519,19 +513,13 @@ function RightPanel({
{/* Bottom Action Buttons */}
<div className="flex gap-1.5 p-3 border-t border-stroke flex-shrink-0">
<button
className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer"
style={{ border: '1px solid rgba(6,182,212,.3)', background: 'rgba(6,182,212,.08)' }}
>
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)]">
💾
</button>
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold bg-bg-elevated border border-stroke text-fg font-korean cursor-pointer">
🔄
</button>
<button
className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer"
style={{ border: '1px solid rgba(6,182,212,.3)', background: 'rgba(6,182,212,.08)' }}
>
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)]">
📄
</button>
</div>
@ -547,14 +535,8 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
return (
<div className="flex flex-col p-2.5 gap-2">
<div className="text-label-1 font-bold text-fg font-korean"> (RESCUE ANALYSIS)</div>
<div
className="text-caption text-color-info font-korean px-2 py-1 rounded"
style={{
background: 'color-mix(in srgb, var(--color-info) 8%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-info) 20%, transparent)',
}}
>
📌 : {at.label} ({at.eng})
<div className="text-caption text-fg-sub font-korean px-2 py-1 rounded bg-bg-card border border-stroke">
: {at.label} ({at.eng})
</div>
{/* 선박 단면도 SVG */}
@ -589,14 +571,14 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
width="30"
height="18"
rx="1"
fill="rgba(239,68,68,.12)"
stroke="rgba(239,68,68,.35)"
fill="color-mix(in srgb, var(--color-danger) 12%, transparent)"
stroke="color-mix(in srgb, var(--color-danger) 35%, transparent)"
strokeWidth=".6"
/>
<text
x="57"
y="39"
fill="rgba(239,68,68,.5)"
fill="color-mix(in srgb, var(--color-danger) 50%, transparent)"
fontSize="4.5"
textAnchor="middle"
fontFamily="var(--font-mono)"
@ -645,7 +627,7 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
y1="8"
x2="130"
y2="60"
stroke="rgba(251,191,36,.15)"
stroke="color-mix(in srgb, var(--fg-disabled) 15%, transparent)"
strokeWidth=".4"
strokeDasharray="2,2"
/>
@ -666,7 +648,6 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
unit="m"
color={gmColor(d.gm)}
sub={`${parseFloat(d.gm) < 1.0 ? '위험' : parseFloat(d.gm) < 1.5 ? '주의' : '정상'} (기준: 1.5m 이상)`}
subColor={gmColor(d.gm)}
/>
<MetricCard
label="LIST (횡경사)"
@ -674,7 +655,6 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
unit="°"
color={listColor(d.list)}
sub={`${parseFloat(d.list) > 20 ? '위험' : parseFloat(d.list) > 10 ? '주의' : '정상'} (기준: 10° 이내)`}
subColor={listColor(d.list)}
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
@ -684,7 +664,6 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
unit="m"
color={trimColor(d.trim)}
sub="선미 침하 (AFT SINKAGE)"
subColor={trimColor(d.trim)}
/>
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">
@ -709,77 +688,45 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
</div>
<div className="grid grid-cols-2 gap-1.5">
{[
{ en: 'BALLAST INJECT', ko: '밸러스트 주입', color: 'var(--color-danger)' },
{ en: 'BALLAST DISCHARGE', ko: '밸러스트 배출', color: 'var(--color-danger)' },
{ en: 'ENGINE STOP', ko: '기관 정지', color: 'var(--color-warning)' },
{ en: 'ANCHOR DROP', ko: '묘 투하', color: 'var(--color-warning)' },
{ en: 'BALLAST INJECT', ko: '밸러스트 주입' },
{ en: 'BALLAST DISCHARGE', ko: '밸러스트 배출' },
{ en: 'ENGINE STOP', ko: '기관 정지' },
{ en: 'ANCHOR DROP', ko: '묘 투하' },
].map((btn, i) => (
<button
key={i}
className="py-[7px] rounded-[5px] text-center cursor-pointer transition-all hover:brightness-125"
style={{
background: `color-mix(in srgb, ${btn.color} 8%, transparent)`,
border: `1px solid color-mix(in srgb, ${btn.color} 25%, transparent)`,
}}
className="py-[7px] rounded-[5px] text-center cursor-pointer transition-all hover:brightness-125 bg-bg-card border border-stroke"
>
<div className="text-label-2 font-bold text-fg font-mono">{btn.en}</div>
<div className="text-caption font-korean" style={{ color: btn.color }}>
{btn.ko}
</div>
<div className="text-label-2 font-bold text-fg-sub font-mono">{btn.en}</div>
<div className="text-caption font-korean text-color-danger">{btn.ko}</div>
</button>
))}
</div>
{/* 구난 의사결정 프로세스 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(KRISO Decision Support)
</div>
<div className="flex flex-col gap-0.5 text-caption font-korean">
<div className="flex items-center gap-1">
{[
{ label: '① 상태평가', color: 'var(--color-accent)' },
{ label: '② 사례분석', color: 'var(--color-info)' },
{ label: '③ 장비선정', color: 'var(--fg-sub)' },
].map((s, i) => (
<>
{['① 상태평가', '② 사례분석', '③ 장비선정'].map((label, i) => (
<Fragment key={i}>
{i > 0 && <span className="text-fg-disabled"></span>}
<div
key={i}
className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0"
style={{
background: `color-mix(in srgb, ${s.color} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${s.color} 25%, transparent)`,
color: s.color,
minWidth: '68px',
}}
>
{s.label}
<div className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0 min-w-[68px] bg-bg-card border border-stroke text-fg-sub">
{label}
</div>
</>
</Fragment>
))}
</div>
<div className="flex items-center gap-1">
{[
{ label: '④ 예인력', color: 'var(--color-caution)' },
{ label: '⑤ 이초/인양', color: 'var(--color-warning)' },
{ label: '⑥ 유출량', color: 'var(--color-danger)' },
].map((s, i) => (
<>
{['④ 예인력', '⑤ 이초/인양', '⑥ 유출량'].map((label, i) => (
<Fragment key={i}>
{i > 0 && <span className="text-fg-disabled"></span>}
<div
key={i}
className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0"
style={{
background: `color-mix(in srgb, ${s.color} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${s.color} 25%, transparent)`,
color: s.color,
minWidth: '68px',
}}
>
{s.label}
<div className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0 min-w-[68px] bg-bg-card border border-stroke text-fg-sub">
{label}
</div>
</>
</Fragment>
))}
</div>
</div>
@ -787,7 +734,7 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
{/* 유체 정역학 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(Hydrostatics)
</div>
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
@ -799,10 +746,10 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
{ label: 'TPC', value: '22.8 t/cm' },
{ label: 'MTC', value: '185 t·m' },
].map((r, i) => (
<div key={i} className="px-1.5 py-1 bg-bg-base rounded-sm">
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
<br />
<b className="text-fg">{r.value}</b>
<b className="text-fg-sub">{r.value}</b>
</div>
))}
</div>
@ -810,20 +757,20 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
{/* 예인력/이초력 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
/ (Towing & Refloating)
</div>
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
{[
{ label: '필요 예인력', value: '285 kN', color: 'var(--color-caution)' },
{ label: '비상 예인력', value: '420 kN', color: 'var(--color-danger)' },
{ label: '이초 반력', value: '1,850 kN', color: 'var(--color-warning)' },
{ label: '인양 안전성', value: 'FAIL', color: 'var(--color-danger)' },
{ label: '필요 예인력', value: '285 kN' },
{ label: '비상 예인력', value: '420 kN' },
{ label: '이초 반력', value: '1,850 kN' },
{ label: '인양 안전성', value: 'FAIL' },
].map((r, i) => (
<div key={i} className="px-1.5 py-1 bg-bg-base rounded-sm">
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
<br />
<b style={{ color: r.color }}>{r.value}</b>
<b className="text-fg-sub">{r.value}</b>
</div>
))}
</div>
@ -834,19 +781,19 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
{/* 유출량 추정 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(Oil Outflow Estimation)
</div>
<div className="grid grid-cols-3 gap-1 text-caption font-mono">
{[
{ label: '현재 유출률', value: d.oilRate, color: 'var(--color-warning)' },
{ label: '누적 유출량', value: '6.8 kL', color: 'var(--color-danger)' },
{ label: '24h 예측', value: '145 kL', color: 'var(--color-danger)' },
{ label: '현재 유출률', value: d.oilRate },
{ label: '누적 유출량', value: '6.8 kL' },
{ label: '24h 예측', value: '145 kL' },
].map((r, i) => (
<div key={i} className="px-1.5 py-1 bg-bg-base rounded-sm text-center">
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm text-center">
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
<br />
<b style={{ color: r.color }}>{r.value}</b>
<b className="text-fg-sub">{r.value}</b>
</div>
))}
</div>
@ -855,7 +802,7 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
className="h-full rounded-sm"
style={{
width: '68%',
background: 'linear-gradient(90deg, var(--color-warning), var(--color-danger))',
background: 'var(--color-danger)',
}}
/>
</div>
@ -866,49 +813,24 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
{/* CBR 사례기반 추론 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
CBR (Case-Based Reasoning)
</div>
<div className="flex flex-col gap-0.5">
{[
{
pct: '94%',
name: 'Hebei Spirit (2007)',
desc: '태안 · 충돌 · 원유 12,547kL 유출',
color: 'var(--color-accent)',
},
{
pct: '87%',
name: 'Sea Empress (1996)',
desc: '밀포드 · 좌초 · 72,000t 유출',
color: 'var(--color-info)',
},
{
pct: '82%',
name: 'Rena (2011)',
desc: '타우랑가 · 좌초 · 350t HFO 유출',
color: 'var(--fg-sub)',
},
{ pct: '94%', name: 'Hebei Spirit (2007)', desc: '태안 · 충돌 · 원유 12,547kL 유출' },
{ pct: '87%', name: 'Sea Empress (1996)', desc: '밀포드 · 좌초 · 72,000t 유출' },
{ pct: '82%', name: 'Rena (2011)', desc: '타우랑가 · 좌초 · 350t HFO 유출' },
].map((c, i) => (
<div
key={i}
className="flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer hover:brightness-125"
style={{
background: `color-mix(in srgb, ${c.color} 6%, transparent)`,
border: `1px solid color-mix(in srgb, ${c.color} 15%, transparent)`,
}}
>
<div
className="w-7 h-4 rounded-sm flex items-center justify-center text-caption font-bold font-mono"
style={{
background: `color-mix(in srgb, ${c.color} 20%, transparent)`,
color: c.color,
}}
className="flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer hover:brightness-125 bg-bg-card border border-stroke"
>
<div className="w-7 h-4 rounded-sm flex items-center justify-center text-caption font-bold font-mono text-fg-sub bg-bg-surface-hover">
{c.pct}
</div>
<div className="flex-1 text-caption font-korean">
<b className="text-fg">{c.name}</b>
<b className="text-fg-sub">{c.name}</b>
<br />
<span className="text-fg-disabled">{c.desc}</span>
</div>
@ -918,37 +840,21 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
</div>
{/* 위험도 평가 */}
<div
className="rounded-md p-2"
style={{
background: 'color-mix(in srgb, var(--color-danger) 4%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 15%, transparent)',
}}
>
<div className="text-caption font-bold text-color-danger font-korean mb-1.5">
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
2
</div>
<div className="flex flex-col gap-0.5 text-caption font-korean">
{[
{ label: '침수 확대 → 전복', level: 'HIGH', color: 'var(--color-danger)' },
{ label: '유류 대량 유출 → 해양오염', level: 'HIGH', color: 'var(--color-warning)' },
{ label: '선체 절단 (BM 초과)', level: 'MED', color: 'var(--color-caution)' },
{ label: '화재/폭발', level: 'LOW', color: 'var(--fg-sub)' },
{ label: '침수 확대 → 전복', level: 'HIGH', danger: true },
{ label: '유류 대량 유출 → 해양오염', level: 'HIGH', danger: true },
{ label: '선체 절단 (BM 초과)', level: 'MED', danger: true },
{ label: '화재/폭발', level: 'LOW', danger: false },
].map((r, i) => (
<div
key={i}
className="flex items-center justify-between px-1.5 py-0.5 rounded-sm"
style={{
background: `color-mix(in srgb, ${r.color} 8%, transparent)`,
}}
>
<span className="text-fg">{r.label}</span>
<div key={i} className="flex items-center justify-between px-1.5 py-0.5 rounded-sm">
<span className="text-fg-sub">{r.label}</span>
<span
className="px-1.5 py-px rounded-lg text-caption font-bold"
style={{
background: `color-mix(in srgb, ${r.color} 20%, transparent)`,
color: r.color,
}}
className={`px-1.5 py-px rounded-lg text-caption font-bold ${r.danger ? 'text-color-danger' : 'text-fg-disabled'}`}
>
{r.level}
</span>
@ -959,30 +865,24 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
{/* 해상 e-Call */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
e-Call (GMDSS / VHF-DSC)
</div>
<div className="flex flex-col gap-0.5 text-caption font-mono text-fg-disabled">
{[
{ label: 'MMSI', value: '440123456' },
{ label: 'Nature of Distress', value: 'COLLISION', color: 'var(--color-danger)' },
{ label: 'DSC Alert', value: 'SENT ✓', color: 'var(--color-success)' },
{ label: 'EPIRB', value: 'ACTIVATED ✓', color: 'var(--color-success)' },
{ label: 'VTS 인천', value: 'ACK 10:36', color: 'var(--color-info)' },
{ label: 'MMSI', value: '440123456', danger: false },
{ label: 'Nature of Distress', value: 'COLLISION', danger: true },
{ label: 'DSC Alert', value: 'SENT ✓', danger: false },
{ label: 'EPIRB', value: 'ACTIVATED ✓', danger: false },
{ label: 'VTS 인천', value: 'ACK 10:36', danger: false },
].map((r, i) => (
<div key={i} className="flex justify-between">
<span className="font-korean">{r.label}</span>
<b style={{ color: r.color }}>{r.value}</b>
<b className={r.danger ? 'text-color-danger' : 'text-fg-sub'}>{r.value}</b>
</div>
))}
</div>
<button
className="w-full mt-1 py-1 rounded text-caption font-bold text-color-danger cursor-pointer font-korean"
style={{
background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 30%, transparent)',
}}
>
<button className="w-full mt-1 py-1 rounded text-caption font-bold cursor-pointer font-korean bg-bg-card border border-stroke text-fg-sub">
📡 DISTRESS RELAY
</button>
</div>
@ -1006,7 +906,7 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
{/* GZ Curve SVG */}
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
GZ (Righting Lever Curve)
</div>
<svg viewBox="0 0 260 120" className="w-full" style={{ height: '100px' }}>
@ -1060,14 +960,14 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
y1="78"
x2="255"
y2="78"
stroke="rgba(251,191,36,.4)"
stroke="var(--color-danger)"
strokeWidth=".6"
strokeDasharray="4,2"
/>
<text
x="240"
y="76"
fill="var(--color-caution)"
fill="var(--color-danger)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
@ -1150,42 +1050,34 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
unit="m"
color="var(--color-danger)"
sub="기준: 0.2m 이상 ⚠"
subColor="var(--color-danger)"
/>
<MetricCard
label="θ_max (최대복원각)"
value="28"
unit="°"
color="var(--color-caution)"
color="var(--color-danger)"
sub="기준: 25° 이상 ✓"
subColor="var(--color-caution)"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean"> </div>
<div className="text-title-3 font-bold text-color-danger font-mono">
<div className="text-title-3 font-bold text-fg-sub font-mono">
2 <span className="text-caption"></span>
</div>
<div className="text-caption text-fg-disabled font-korean">#1 / #3 </div>
</div>
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">Margin Line </div>
<div className="text-title-3 font-bold text-color-danger font-mono">
<div className="text-title-3 font-bold text-fg-sub font-mono">
0.12 <span className="text-caption">m</span>
</div>
<div className="text-caption text-color-danger font-korean"> </div>
<div className="text-caption text-fg-disabled font-korean"> </div>
</div>
</div>
{/* SOLAS 판정 */}
<div
className="rounded-md p-2.5"
style={{
background: 'color-mix(in srgb, var(--color-danger) 6%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 20%, transparent)',
}}
>
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-label-2 font-bold text-color-danger font-korean mb-1">
SOLAS 판정: 부적합 (FAIL)
</div>
@ -1201,20 +1093,20 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
{/* 좌초시 복원성 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg font-korean mb-1">
<div className="text-caption font-bold text-fg-sub font-korean mb-1">
(Grounded Stability)
</div>
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
{[
{ label: '지반반력', value: '1,240 kN', color: 'var(--color-caution)' },
{ label: '지반반력', value: '1,240 kN' },
{ label: '접촉 면적', value: '12.5 m²' },
{ label: '제거력(Removal)', value: '1,850 kN', color: 'var(--color-danger)' },
{ label: '좌초 GM', value: '0.65 m', color: 'var(--color-caution)' },
{ label: '제거력(Removal)', value: '1,850 kN' },
{ label: '좌초 GM', value: '0.65 m' },
].map((r, i) => (
<div key={i} className="px-1.5 py-1 bg-bg-base rounded-sm">
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
<br />
<b style={{ color: r.color }}>{r.value}</b>
<b className="text-fg-sub">{r.value}</b>
</div>
))}
</div>
@ -1222,27 +1114,31 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
{/* 탱크 상태 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg font-korean mb-1">
<div className="text-caption font-bold text-fg-sub font-korean mb-1">
(Tank Volume Status)
</div>
<div className="flex flex-col gap-0.5 text-caption font-korean">
{[
{ name: '#1 FP Tank', pct: 100, status: '침수', color: 'var(--color-danger)' },
{ name: '#3 Port Tank', pct: 85, status: '85%', color: 'var(--color-danger)' },
{ name: '#2 DB Tank', pct: 45, status: '45%', color: 'var(--color-success)' },
{ name: 'Ballast #4', pct: 72, status: '72%', color: 'var(--color-info)' },
{ name: 'Fuel Oil #5', pct: 68, status: '68%', color: 'var(--color-warning)' },
{ name: '#1 FP Tank', pct: 100, status: '침수', danger: true },
{ name: '#3 Port Tank', pct: 85, status: '85%', danger: true },
{ name: '#2 DB Tank', pct: 45, status: '45%', danger: false },
{ name: 'Ballast #4', pct: 72, status: '72%', danger: false },
{ name: 'Fuel Oil #5', pct: 68, status: '68%', danger: false },
].map((t, i) => (
<div key={i} className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm flex-shrink-0" style={{ background: t.color }} />
<div
className={`w-2 h-2 rounded-sm flex-shrink-0 ${t.danger ? 'bg-color-danger' : 'bg-color-info'}`}
/>
<span className="w-[65px] text-fg-disabled">{t.name}</span>
<div className="flex-1 h-1 bg-bg-surface-hover rounded-sm">
<div
className="h-full rounded-sm"
style={{ width: `${t.pct}%`, background: t.color }}
className={`h-full rounded-sm ${t.danger ? 'bg-color-danger' : 'bg-color-info'}`}
style={{ width: `${t.pct}%` }}
/>
</div>
<span className="min-w-[35px] text-right" style={{ color: t.color }}>
<span
className={`min-w-[35px] text-right ${t.danger ? 'text-fg-sub' : 'text-fg-disabled'}`}
>
{t.status}
</span>
</div>
@ -1269,7 +1165,7 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
{/* 전단력 분포 SVG */}
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(Shear Force Distribution)
</div>
<svg viewBox="0 0 260 90" className="w-full" style={{ height: '75px' }}>
@ -1280,7 +1176,7 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
y1="18"
x2="255"
y2="18"
stroke="rgba(239,68,68,.25)"
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
strokeWidth=".5"
strokeDasharray="3,2"
/>
@ -1289,17 +1185,22 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
y1="72"
x2="255"
y2="72"
stroke="rgba(239,68,68,.25)"
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
strokeWidth=".5"
strokeDasharray="3,2"
/>
<text x="240" y="15" fill="rgba(239,68,68,.4)" fontSize="4">
<text
x="240"
y="15"
fill="color-mix(in srgb, var(--color-danger) 40%, transparent)"
fontSize="4"
>
LIMIT
</text>
<polyline
points="25,45 50,38 75,28 100,22 120,20 140,25 160,35 180,45 200,52 220,58 240,55 255,50"
fill="none"
stroke="var(--color-accent)"
stroke="var(--color-info)"
strokeWidth="1.5"
/>
<polyline
@ -1335,7 +1236,7 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
{/* 굽힘모멘트 분포 SVG */}
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-caption font-bold text-fg font-korean mb-1.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(Bending Moment Distribution)
</div>
<svg viewBox="0 0 260 90" className="w-full" style={{ height: '75px' }}>
@ -1346,27 +1247,32 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
y1="15"
x2="255"
y2="15"
stroke="rgba(239,68,68,.25)"
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
strokeWidth=".5"
strokeDasharray="3,2"
/>
<text x="240" y="12" fill="rgba(239,68,68,.4)" fontSize="4">
<text
x="240"
y="12"
fill="color-mix(in srgb, var(--color-danger) 40%, transparent)"
fontSize="4"
>
LIMIT
</text>
<polyline
points="25,45 50,40 75,32 100,22 130,18 160,22 190,32 220,40 255,45"
fill="none"
stroke="var(--color-success)"
stroke="var(--color-info)"
strokeWidth="1.5"
/>
<polyline
points="25,45 50,38 75,26 100,16 130,12 160,16 190,28 220,38 255,45"
fill="none"
stroke="var(--color-warning)"
stroke="var(--color-danger)"
strokeWidth="1.2"
strokeDasharray="3,2"
/>
<text x="115" y="8" fill="var(--color-warning)" fontSize="4.5">
<text x="115" y="8" fill="var(--color-danger)" fontSize="4.5">
BM
</text>
<text x="2" y="14" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
@ -1391,50 +1297,44 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
<div className="grid grid-cols-2 gap-1.5">
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">SF / </div>
<div className="text-title-3 font-bold text-color-caution font-mono">
<div className="text-title-3 font-bold text-fg-sub font-mono">
88<span className="text-caption">%</span>
</div>
<div className="h-[5px] bg-bg-surface-hover rounded-sm mt-0.5">
<div className="h-full rounded-sm w-[88%] bg-color-caution" />
<div className="h-full rounded-sm w-[88%] bg-color-danger" />
</div>
</div>
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">BM / </div>
<div className="text-title-3 font-bold text-color-warning font-mono">
<div className="text-title-3 font-bold text-fg-sub font-mono">
92<span className="text-caption">%</span>
</div>
<div className="h-[5px] bg-bg-surface-hover rounded-sm mt-0.5">
<div className="h-full rounded-sm w-[92%] bg-color-warning" />
<div className="h-full rounded-sm w-[92%] bg-color-danger" />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-1.5">
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">Section Modulus </div>
<div className="text-title-3 font-bold text-color-success font-mono">1.08</div>
<div className="text-caption text-color-success font-korean">Req'd: 1.00 </div>
<div className="text-title-3 font-bold text-fg-sub font-mono">1.08</div>
<div className="text-caption text-fg-disabled font-korean">Req'd: 1.00 </div>
</div>
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">Hull Girder ULS</div>
<div className="text-title-3 font-bold text-color-caution font-mono">1.12</div>
<div className="text-caption text-color-caution font-korean">Req'd: 1.10 </div>
<div className="text-title-3 font-bold text-fg-sub font-mono">1.12</div>
<div className="text-caption text-fg-disabled font-korean">Req'd: 1.10 </div>
</div>
</div>
{/* 판정 */}
<div
className="rounded-md p-2.5"
style={{
background: 'color-mix(in srgb, var(--color-caution) 6%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-caution) 20%, transparent)',
}}
>
<div className="text-label-2 font-bold text-color-caution font-korean mb-1">
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-label-2 font-bold text-color-danger font-korean mb-1">
판정: 주의 (CAUTION)
</div>
<div className="text-caption text-fg-disabled font-korean leading-snug">
· SF 최대: 허용치의 88% <span className="text-color-caution"> </span>
<br />· BM 최대: 허용치의 92% <span className="text-color-warning"> </span>
· SF 최대: 허용치의 88% <span className="text-color-danger"> </span>
<br />· BM 최대: 허용치의 92% <span className="text-color-danger"> </span>
<br />
· Hogging
<br />· <span className="text-color-danger"> BM </span>
@ -1446,89 +1346,61 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
/* ─── 하단 바: 이벤트 로그 + 타임라인 ─── */
function BottomBar() {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="h-[145px] min-h-[145px] border-t border-stroke flex bg-bg-base flex-shrink-0">
{/* 이벤트 로그 */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-stroke flex-shrink-0">
<div className="border-t border-stroke flex flex-col bg-bg-base flex-shrink-0">
{/* 제목 바 — 항상 표시 */}
<div className="flex items-center px-3 py-1 border-b border-stroke flex-shrink-0">
<span className="text-caption font-bold text-fg-disabled font-korean">
/ (EVENT LOG / COMMUNICATION TRANSCRIPT)
</span>
<div className="flex gap-0.5">
{[
{ label: '전체', color: 'var(--color-accent)' },
{ label: '긴급', color: 'var(--color-warning)' },
{ label: '통신', color: 'var(--color-info)' },
].map((f, i) => (
<button
onClick={() => setIsOpen(!isOpen)}
className="flex-1 text-center text-caption text-fg-disabled hover:text-fg cursor-pointer transition-colors"
>
{isOpen ? '▼' : '▲'}
</button>
<div className="flex items-center gap-1.5">
{['전체', '긴급', '통신'].map((label, i) => (
<button
key={i}
className="px-2 py-px rounded-sm text-caption font-bold cursor-pointer font-korean"
style={{
background: `color-mix(in srgb, ${f.color} 15%, transparent)`,
border: `1px solid color-mix(in srgb, ${f.color} 30%, transparent)`,
color: f.color,
}}
className="px-2 py-px rounded-sm text-caption cursor-pointer font-korean border border-stroke text-fg-disabled hover:text-fg"
>
{f.label}
{label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-1 font-mono text-caption leading-[1.7] scrollbar-thin">
{/* 이벤트 로그 내용 — 토글 */}
{isOpen && (
<div className="h-[120px] overflow-y-auto px-3 py-1 font-mono text-caption leading-[1.7] scrollbar-thin">
{[
{
time: '10:35',
msg: 'SOS FROM M/V SEA GUARDIAN',
color: 'var(--color-danger)',
bold: true,
},
{ time: '10:35', msg: 'OIL LEAK DETECTED SENSOR #3', bold: false },
{ time: '10:40', msg: 'CG HELO DISPATCHED', color: 'var(--color-danger)', bold: true },
{
time: '10:41',
msg: 'GM CRITICAL ALERT — DAMAGE STABILITY FAIL',
color: 'var(--color-danger)',
bold: true,
},
{
time: '10:42',
msg: 'Coast Guard 123 en route — ETA 15 min',
color: 'var(--color-info)',
bold: false,
},
{ time: '10:35', msg: 'SOS FROM M/V SEA GUARDIAN', important: true },
{ time: '10:35', msg: 'OIL LEAK DETECTED SENSOR #3', important: false },
{ time: '10:40', msg: 'CG HELO DISPATCHED', important: true },
{ time: '10:41', msg: 'GM CRITICAL ALERT — DAMAGE STABILITY FAIL', important: true },
{ time: '10:42', msg: 'Coast Guard 123 en route — ETA 15 min', important: false },
{
time: '10:43',
msg: 'LONGITUDINAL STRENGTH WARNING — BM 92% of LIMIT',
color: 'var(--color-caution)',
bold: false,
},
{ time: '10:45', msg: 'BALLAST TRANSFER INITIATED — PORT #2 → STBD #3', bold: false },
{
time: '10:48',
msg: 'LIST INCREASING — 12° → 15°',
color: 'var(--color-caution)',
bold: false,
important: false,
},
{
time: '10:50',
msg: 'RESCUE HELO ON SCENE — HOISTING OPS',
color: 'var(--color-info)',
bold: false,
},
{
time: '10:55',
msg: '5 SURVIVORS RECOVERED BY HELO',
color: 'var(--color-success)',
bold: false,
time: '10:45',
msg: 'BALLAST TRANSFER INITIATED — PORT #2 → STBD #3',
important: false,
},
{ time: '10:48', msg: 'LIST INCREASING — 12° → 15°', important: false },
{ time: '10:50', msg: 'RESCUE HELO ON SCENE — HOISTING OPS', important: false },
{ time: '10:55', msg: '5 SURVIVORS RECOVERED BY HELO', important: true },
].map((e, i) => (
<div key={i}>
<span className="text-fg-disabled">[{e.time}]</span>{' '}
<span style={{ color: e.color, fontWeight: e.bold ? 700 : 400 }}>{e.msg}</span>
<span className={e.important ? 'text-color-accent' : 'text-fg'}>{e.msg}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
@ -1540,25 +1412,21 @@ function MetricCard({
unit,
color,
sub,
subColor,
}: {
label: string;
value: string;
unit: string;
color: string;
sub: string;
subColor: string;
}) {
return (
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">{label}</div>
<div className="text-title-2 font-bold font-mono" style={{ color }}>
<div className="text-title-3 font-bold font-mono" style={{ color }}>
{value}
<span className="text-label-2"> {unit}</span>
</div>
<div className="text-caption font-korean" style={{ color: subColor }}>
{sub}
<span className="text-caption"> {unit}</span>
</div>
<div className="text-caption font-korean text-fg-disabled">{sub}</div>
</div>
);
}

파일 보기

@ -62,43 +62,43 @@ function SegRow(
}}
className={`bg-bg-card border border-stroke rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
isSelected
? 'border-status-green bg-[rgba(34,197,94,0.05)]'
? 'border-status-green bg-[color-mix(in_srgb,var(--color-success)_5%,transparent)]'
: 'hover:border-stroke-light hover:bg-bg-surface-hover'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
<span className="text-caption font-semibold font-korean flex items-center gap-1.5">
📍 {seg.code} {seg.area}
</span>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white"
className="text-caption font-bold px-1.5 py-0.5 rounded-lg text-white"
style={{ background: esiColor(seg.esiNum) }}
>
ESI {seg.esi}
</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<div className="flex justify-between text-[11px]">
<div className="flex justify-between text-label-2">
<span className="text-fg-sub font-korean"></span>
<span className="text-fg font-medium font-mono text-[11px]">{seg.type}</span>
<span className="text-fg font-medium font-mono text-label-2">{seg.type}</span>
</div>
<div className="flex justify-between text-[11px]">
<div className="flex justify-between text-label-2">
<span className="text-fg-sub font-korean"></span>
<span className="text-fg font-medium font-mono text-[11px]">{seg.length}</span>
<span className="text-fg font-medium font-mono text-label-2">{seg.length}</span>
</div>
<div className="flex justify-between text-[11px]">
<div className="flex justify-between text-label-2">
<span className="text-fg-sub font-korean"></span>
<span
className="font-medium font-mono text-[11px]"
className="font-medium font-mono text-label-2"
style={{ color: sensColor[seg.sensitivity] }}
>
{seg.sensitivity}
</span>
</div>
<div className="flex justify-between text-[11px]">
<div className="flex justify-between text-label-2">
<span className="text-fg-sub font-korean"></span>
<span
className="font-medium font-mono text-[11px]"
className="font-medium font-mono text-label-2"
style={{ color: statusColor[seg.status] }}
>
{seg.status}
@ -159,13 +159,13 @@ function ScatLeftPanel({
<div className="w-[340px] min-w-[340px] bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
{/* Filters */}
<div className="p-3.5 border-b border-stroke">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-fg mb-3">
<div className="flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-fg mb-3">
<span className="w-[3px] h-2.5 bg-color-success rounded-sm" />
</div>
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-fg mb-1 font-korean">
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
</label>
<select
@ -182,7 +182,7 @@ function ScatLeftPanel({
</div>
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-fg mb-1 font-korean">
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
</label>
<select
@ -200,7 +200,7 @@ function ScatLeftPanel({
</div>
{/* <div className="mb-2.5">
<label className="block text-[11px] font-medium text-fg mb-1 font-korean">
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
</label>
<select
@ -218,7 +218,7 @@ function ScatLeftPanel({
</div> */}
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-fg mb-1 font-korean">
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
</label>
<select
@ -255,12 +255,12 @@ function ScatLeftPanel({
{/* Segment List */}
<div className="flex-1 flex flex-col overflow-hidden p-3.5 pt-2">
<div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider text-fg mb-2.5">
<div className="flex items-center justify-between text-caption font-bold uppercase tracking-wider text-fg mb-2.5">
<span className="flex items-center gap-1.5">
<span className="w-[3px] h-2.5 bg-color-success rounded-sm" />
</span>
<span className="text-color-accent font-mono text-[10px]">
<span className="text-color-accent font-mono text-caption">
{filtered.length}
</span>
</div>

파일 보기

@ -1,66 +1,72 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
import { useMapStore } from '@common/store/mapStore'
import type { ScatSegment } from './scatTypes'
import type { ApiZoneItem } from '../services/scatApi'
import { esiColor } from './scatConstants'
import { hexToRgba } from '@common/components/map/mapUtils'
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import type { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi';
import { esiColor } from './scatConstants';
import { hexToRgba } from '@common/components/map/mapUtils';
interface ScatMapProps {
segments: ScatSegment[]
zones: ApiZoneItem[]
selectedSeg: ScatSegment
jurisdictionFilter: string
onSelectSeg: (s: ScatSegment) => void
onOpenPopup: (idx: number) => void
segments: ScatSegment[];
zones: ApiZoneItem[];
selectedSeg: ScatSegment;
jurisdictionFilter: string;
onSelectSeg: (s: ScatSegment) => void;
onOpenPopup: (idx: number) => void;
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
function FlyToController({ selectedSeg, zones }: { selectedSeg: ScatSegment; zones: ApiZoneItem[] }) {
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
const prevZonesLenRef = useRef<number>(0)
function FlyToController({
selectedSeg,
zones,
}: {
selectedSeg: ScatSegment;
zones: ApiZoneItem[];
}) {
const { current: map } = useMap();
const prevIdRef = useRef<number | undefined>(undefined);
const prevZonesLenRef = useRef<number>(0);
// 선택 구간 변경 시
useEffect(() => {
if (!map) return
if (!map) return;
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 })
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 });
}
prevIdRef.current = selectedSeg.id
}, [map, selectedSeg])
prevIdRef.current = selectedSeg.id;
}, [map, selectedSeg]);
// 관할해경(zones) 변경 시 지도 중심 이동
useEffect(() => {
if (!map || zones.length === 0) return
if (prevZonesLenRef.current === zones.length) return
prevZonesLenRef.current = zones.length
const validZones = zones.filter(z => z.latCenter && z.lngCenter)
if (validZones.length === 0) return
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length
map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 })
}, [map, zones])
if (!map || zones.length === 0) return;
if (prevZonesLenRef.current === zones.length) return;
prevZonesLenRef.current = zones.length;
const validZones = zones.filter((z) => z.latCenter && z.lngCenter);
if (validZones.length === 0) return;
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length;
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length;
map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 });
}, [map, zones]);
return null
return null;
}
// ── 줌 기반 스케일 계산 ─────────────────────────────────
function getZoomScale(zoom: number) {
const zScale = Math.max(0, zoom - 9) / 5
const zScale = Math.max(0, zoom - 9) / 5;
return {
polyWidth: 1 + zScale * 4,
selPolyWidth: 2 + zScale * 5,
@ -68,7 +74,7 @@ function getZoomScale(zoom: number) {
halfLenScale: 0.15 + zScale * 0.85,
markerRadius: Math.round(6 + zScale * 16),
showStatusMarker: zoom >= 11,
}
};
}
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
@ -78,46 +84,53 @@ function buildSegCoords(
halfLenScale: number,
segments: ScatSegment[],
): [number, number][] {
const idx = segments.indexOf(seg)
const prev = idx > 0 ? segments[idx - 1] : seg
const next = idx < segments.length - 1 ? segments[idx + 1] : seg
const dlat = next.lat - prev.lat
const dlng = next.lng - prev.lng
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
const nDlat = dist > 0 ? dlat / dist : 0
const nDlng = dist > 0 ? dlng / dist : 1
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
const idx = segments.indexOf(seg);
const prev = idx > 0 ? segments[idx - 1] : seg;
const next = idx < segments.length - 1 ? segments[idx + 1] : seg;
const dlat = next.lat - prev.lat;
const dlng = next.lng - prev.lng;
const dist = Math.sqrt(dlat * dlat + dlng * dlng);
const nDlat = dist > 0 ? dlat / dist : 0;
const nDlng = dist > 0 ? dlng / dist : 1;
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale;
return [
[seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen],
[seg.lng, seg.lat],
[seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen],
]
];
}
// ── 툴팁 상태 ───────────────────────────────────────────
interface TooltipState {
x: number
y: number
seg: ScatSegment
x: number;
y: number;
seg: ScatSegment;
}
// ── ScatMap ─────────────────────────────────────────────
function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) {
const currentMapStyle = useBaseMapStyle()
const mapToggles = useMapStore((s) => s.mapToggles)
function ScatMap({
segments,
zones,
selectedSeg,
jurisdictionFilter,
onSelectSeg,
onOpenPopup,
}: ScatMapProps) {
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const [zoom, setZoom] = useState(10)
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const [zoom, setZoom] = useState(10);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const handleClick = useCallback(
(seg: ScatSegment) => {
onSelectSeg(seg)
onOpenPopup(seg.id)
onSelectSeg(seg);
onOpenPopup(seg.id);
},
[onSelectSeg, onOpenPopup],
)
);
const zs = useMemo(() => getZoomScale(zoom), [zoom])
const zs = useMemo(() => getZoomScale(zoom), [zoom]);
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
// const coastlineLayer = useMemo(
@ -153,7 +166,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
},
}),
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
)
);
// ESI 색상 세그먼트 폴리라인
const segPathLayer = useMemo(
@ -163,9 +176,9 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
data: segments,
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
getColor: (d: ScatSegment) => {
const isSelected = selectedSeg.id === d.id
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
return hexToRgba(hexCol, isSelected ? 242 : 178)
const isSelected = selectedSeg.id === d.id;
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum);
return hexToRgba(hexCol, isSelected ? 242 : 178);
},
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
capRounded: true,
@ -174,13 +187,13 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
pickable: true,
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
if (info.object) {
setTooltip({ x: info.x, y: info.y, seg: info.object })
setTooltip({ x: info.x, y: info.y, seg: info.object });
} else {
setTooltip(null)
setTooltip(null);
}
},
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object)
if (info.object) handleClick(info.object);
},
updateTriggers: {
getColor: [selectedSeg.id],
@ -189,25 +202,25 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
},
}),
[segments, selectedSeg, zs, handleClick],
)
);
// 조사 상태 마커 (줌 >= 11 시 표시)
const markerLayer = useMemo(() => {
if (!zs.showStatusMarker) return null
if (!zs.showStatusMarker) return null;
return new ScatterplotLayer({
id: 'scat-status-markers',
data: segments,
getPosition: (d: ScatSegment) => [d.lng, d.lat],
getRadius: zs.markerRadius,
getFillColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 51]
if (d.status === '진행중') return [234, 179, 8, 51]
return [100, 116, 139, 51]
if (d.status === '완료') return [34, 197, 94, 51];
if (d.status === '진행중') return [234, 179, 8, 51];
return [100, 116, 139, 51];
},
getLineColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 200]
if (d.status === '진행중') return [234, 179, 8, 200]
return [100, 116, 139, 200]
if (d.status === '완료') return [34, 197, 94, 200];
if (d.status === '진행중') return [234, 179, 8, 200];
return [100, 116, 139, 200];
},
getLineWidth: 1,
stroked: true,
@ -216,32 +229,27 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object)
if (info.object) handleClick(info.object);
},
updateTriggers: {
getRadius: [zs.markerRadius],
},
})
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick])
});
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = [glowLayer, segPathLayer]
if (markerLayer) layers.push(markerLayer)
return layers
}, [glowLayer, segPathLayer, markerLayer])
const layers: any[] = [glowLayer, segPathLayer];
if (markerLayer) layers.push(markerLayer);
return layers;
}, [glowLayer, segPathLayer, markerLayer]);
const doneCount = segments.filter(s => s.status === '완료').length
const progCount = segments.filter(s => s.status === '진행중').length
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0)
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0);
const highSens = segments
.filter(s => s.sensitivity === '최상' || s.sensitivity === '상')
.reduce((a, s) => a + s.lengthM, 0)
const donePct = Math.round((doneCount / segments.length) * 100)
const progPct = Math.round((progCount / segments.length) * 100)
const notPct = 100 - donePct - progPct
.filter((s) => s.sensitivity === '최상' || s.sensitivity === '상')
.reduce((a, s) => a + s.lengthM, 0);
return (
<div className="absolute inset-0 overflow-hidden">
@ -257,7 +265,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
mapStyle={currentMapStyle}
className="w-full h-full"
attributionControl={false}
onZoom={e => setZoom(e.viewState.zoom)}
onZoom={(e) => setZoom(e.viewState.zoom)}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
@ -267,27 +275,22 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
{/* 호버 툴팁 */}
{tooltip && (
<div
className="pointer-events-none"
className="pointer-events-none text-fg rounded-[6px] px-2 py-1 font-korean text-label-2"
style={{
position: 'absolute',
left: tooltip.x + 12,
top: tooltip.y - 48,
background: 'rgba(15,21,36,0.92)',
border: '1px solid rgba(30,42,66,0.8)',
color: '#e4e8f1',
borderRadius: 6,
padding: '4px 8px',
background: 'color-mix(in srgb, var(--bg-base) 92%, transparent)',
border: '1px solid var(--stroke-default)',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
fontSize: 11,
fontFamily: "'Noto Sans KR', sans-serif",
zIndex: 1000,
whiteSpace: 'nowrap',
}}
>
<div style={{ fontWeight: 700 }}>
<div className="font-bold">
{tooltip.seg.code} {tooltip.seg.area}
</div>
<div style={{ fontSize: 10, opacity: 0.7 }}>
<div className="text-caption opacity-70">
ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '}
{tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '}
{tooltip.seg.status}
@ -297,11 +300,11 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
{/* Status chips */}
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-full text-[11px] text-fg-sub font-korean">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
<span className="w-1.5 h-1.5 rounded-full bg-color-success shadow-[0_0_6px_var(--color-success)]" />
Pre-SCAT
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-full text-[11px] text-fg-sub font-korean">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
{jurisdictionFilter || '전체'} · {segments.length}
</div>
</div>
@ -309,8 +312,10 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
{/* Right info cards */}
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
{/* ESI Legend */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-fg-disabled mb-2.5">ESI </div>
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
ESI
</div>
{[
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
@ -321,37 +326,61 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
{ esi: 'ESI 3-4', label: '모래 해안', color: '#facc15' },
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
].map((item, i) => (
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
<span className="w-3.5 h-1.5 rounded-sm flex-shrink-0" style={{ background: item.color }} />
<div key={i} className="flex items-center gap-2 py-1 text-label-2">
<span
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
style={{ background: item.color }}
/>
<span className="text-fg-sub font-korean">{item.label}</span>
<span className="ml-auto font-mono text-[10px] text-fg">{item.esi}</span>
<span className="ml-auto font-mono text-caption text-fg">{item.esi}</span>
</div>
))}
</div>
{/* Progress */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-fg-disabled mb-2.5"> </div>
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
<div className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--color-success)' }} />
<div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--color-warning)' }} />
<div className="h-full transition-all duration-500" style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }} />
</div>
<div className="flex justify-between mt-1">
<span className="text-[9px] font-mono text-color-success"> {donePct}%</span>
<span className="text-[9px] font-mono text-color-warning"> {progPct}%</span>
<span className="text-[9px] font-mono text-fg-disabled"> {notPct}%</span>
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
</div>
{/* <div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
<div
className="h-full transition-all duration-500"
style={{ width: `${donePct}%`, background: 'var(--color-success)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${progPct}%`, background: 'var(--color-warning)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }}
/>
</div> */}
{/* <div className="flex justify-between mt-1">
<span className="text-caption font-mono text-color-success"> {donePct}%</span>
<span className="text-caption font-mono text-color-warning"> {progPct}%</span>
<span className="text-caption font-mono text-fg-disabled"> {notPct}%</span>
</div> */}
<div className="mt-2.5">
{[
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--color-danger)'],
['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}`, 'var(--color-warning)'],
[
'방제 우선 구간',
`${segments.filter((s) => s.sensitivity === '최상').length}`,
'var(--color-warning)',
],
].map(([label, val, color], i) => (
<div key={i} className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]">
<div
key={i}
className="flex justify-between py-1.5 border-b border-stroke last:border-b-0 text-label-2"
>
<span className="text-fg-sub font-korean">{label}</span>
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>
<span
className="font-mono font-medium text-label-2"
style={{ color: color || undefined }}
>
{val}
</span>
</div>
@ -361,7 +390,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
</div>
{/* Coordinates */}
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-stroke rounded-sm px-3 py-1.5 font-mono text-[11px] text-fg-sub flex gap-3.5">
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-stroke rounded-sm px-3 py-1.5 font-mono text-label-2 text-fg-sub flex gap-3.5">
<span>
<span className="text-color-success font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
</span>
@ -373,7 +402,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
</span>
</div> */}
</div>
)
);
}
export default ScatMap
export default ScatMap;

파일 보기

@ -108,27 +108,22 @@ function PopupMap({
</Map>
{/* 마커 레이블 오버레이 */}
<div
className="pointer-events-none absolute"
className="pointer-events-none absolute text-fg font-korean text-[10px] rounded-[6px] text-center"
style={{
left: '50%',
top: 'calc(50% - 44px)',
transform: 'translateX(-50%)',
background: 'rgba(15,21,36,0.92)',
border: '1px solid rgba(30,42,66,0.8)',
color: '#e4e8f1',
borderRadius: 6,
background: 'color-mix(in srgb, var(--bg-base) 92%, transparent)',
border: '1px solid var(--stroke-default)',
padding: '3px 7px',
fontSize: 10,
fontFamily: "'Noto Sans KR', sans-serif",
whiteSpace: 'nowrap',
zIndex: 10,
textAlign: 'center',
}}
>
<div style={{ fontWeight: 700, fontSize: 11 }}>
<div className="text-label-2 font-bold">
{code} {name}
</div>
<div style={{ opacity: 0.7 }}>ESI {esi}</div>
<div className="opacity-70">ESI {esi}</div>
</div>
</div>
);
@ -142,8 +137,6 @@ interface ScatPopupProps {
}
function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
console.log(segCode, '코드');
const [popTab, setPopTab] = useState(0);
const [imgLoaded, setImgLoaded] = useState(false);
const [imgError, setImgError] = useState(false);
@ -179,12 +172,12 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke flex-shrink-0">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-bold text-color-success font-mono px-2.5 py-1 bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.25)] rounded-sm">
<span className="text-title-4 font-bold text-color-success font-mono px-2.5 py-1 bg-[color-mix(in_srgb,var(--color-success)_10%,transparent)] border border-[color-mix(in_srgb,var(--color-success)_25%,transparent)] rounded-sm">
{data.code}
</span>
<span className="text-base font-bold font-korean">{data.name}</span>
<span className="text-title-3 font-bold font-korean">{data.name}</span>
<span
className="text-[11px] font-bold px-2.5 py-0.5 rounded-xl text-white"
className="text-label-2 font-bold px-2.5 py-0.5 rounded-xl text-white"
style={{ background: data.esiColor }}
>
ESI {data.esi}
@ -192,7 +185,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
</div>
<button
onClick={onClose}
className="w-9 h-9 rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-[rgba(239,68,68,0.15)] hover:text-color-danger hover:border-[rgba(239,68,68,0.3)] transition-colors text-lg"
className="w-9 h-9 rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] hover:text-color-danger hover:border-[color-mix(in_srgb,var(--color-danger)_30%,transparent)] transition-colors text-lg"
>
</button>
@ -243,14 +236,14 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
<span> </span>
</div>
)}
<div className="absolute top-2 left-2 px-2 py-0.5 bg-[rgba(10,14,26,0.8)] border border-[rgba(255,255,255,0.1)] rounded text-[10px] font-bold text-white font-mono backdrop-blur-sm">
<div className="absolute top-2 left-2 px-2 py-0.5 bg-[color-mix(in_srgb,var(--bg-base)_80%,transparent)] border border-stroke rounded text-caption font-bold text-fg font-mono backdrop-blur-sm">
{segCode}
</div>
</div>
{/* Survey Info */}
<div className="mb-4">
<div className="text-[11px] font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
<div className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
🏖
</div>
{[
@ -288,40 +281,40 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
].map(([k, v, cls], i) => (
<div
key={i}
className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.06)] last:border-b-0"
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
>
<span className="text-fg-sub font-korean">{k}</span>
<span className={`text-white font-semibold font-korean ${cls}`}>{v}</span>
<span className={`text-fg font-korean ${cls}`}>{v}</span>
</div>
))}
</div>
{/* Sensitive Resources */}
<div className="mb-4">
<div className="text-[11px] font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
<div className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
🌿
</div>
{data.sensitive.map((s, i) => (
<div
key={i}
className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.06)] last:border-b-0"
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
>
<span className="text-fg-sub font-korean">{s.t}</span>
<span className="text-white font-semibold font-korean">{s.v}</span>
<span className="text-fg font-korean">{s.v}</span>
</div>
))}
</div>
{/* Cleanup Methods */}
<div className="mb-4">
<div className="text-[11px] font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
<div className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
🧹
</div>
<div className="flex flex-wrap gap-1">
{data.cleanup.map((c, i) => (
<span
key={i}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-[rgba(255,255,255,0.06)] border border-[rgba(255,255,255,0.10)] rounded text-[10px] text-fg font-medium font-korean"
className="inline-flex items-center gap-1 px-2 py-0.5 bg-bg-base border border-stroke rounded text-caption text-fg font-medium font-korean"
>
{c}
</span>
@ -331,10 +324,10 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
{/* End Criteria */}
<div className="mb-4">
<div className="text-[11px] font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
<div className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
</div>
<div className="px-3 py-2.5 bg-[rgba(234,179,8,0.06)] border border-[rgba(234,179,8,0.15)] rounded-sm text-[11px] text-fg-sub leading-[1.7] font-korean">
<div className="px-3 py-2.5 bg-[color-mix(in_srgb,var(--color-caution)_6%,transparent)] border border-[color-mix(in_srgb,var(--color-caution)_15%,transparent)] rounded-sm text-label-2 text-fg-sub leading-[1.7] font-korean">
{data.endCriteria.map((e, i) => (
<div key={i} className="pl-3.5 relative mb-1">
<span className="absolute left-0 text-color-caution"></span>
@ -346,10 +339,10 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
{/* Notes */}
<div>
<div className="text-[11px] font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
<div className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
📝
</div>
<div className="px-3 py-2.5 bg-[rgba(234,179,8,0.06)] border border-[rgba(234,179,8,0.15)] rounded-sm text-[11px] text-fg-sub leading-[1.7] font-korean">
<div className="px-3 py-2.5 bg-[color-mix(in_srgb,var(--color-caution)_6%,transparent)] border border-[color-mix(in_srgb,var(--color-caution)_15%,transparent)] rounded-sm text-label-2 text-fg-sub leading-[1.7] font-korean">
{data.notes.map((n, i) => (
<div key={i} className="pl-3.5 relative mb-1">
<span className="absolute left-0 text-color-caution"></span>
@ -391,7 +384,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
].map((item, i) => (
<div
key={i}
className="flex items-center gap-1.5 text-[10px] text-fg-sub font-korean"
className="flex items-center gap-1.5 text-caption text-fg-sub font-korean"
>
<span className="w-5 h-1 rounded-sm" style={{ background: item.color }} />
{item.label}
@ -401,7 +394,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
{/* Coordinates */}
<div className="mb-4">
<div className="text-[11px] font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
<div className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
📍
</div>
{[
@ -412,7 +405,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
].map(([k, v], i) => (
<div
key={i}
className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.03)] last:border-b-0"
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
>
<span className="text-fg-disabled font-korean">{k}</span>
<span className="text-color-success font-mono font-medium">{v}</span>
@ -422,7 +415,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
{/* Survey parameters */}
<div>
<div className="text-[11px] font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
<div className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
</div>
{[
@ -435,7 +428,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
].map(([k, v], i) => (
<div
key={i}
className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.03)] last:border-b-0"
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
>
<span className="text-fg-disabled font-korean">{k}</span>
<span className="text-fg font-medium font-korean">{v}</span>
@ -448,7 +441,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
{popTab === 1 && (
<div className="p-6 overflow-y-auto h-full scrollbar-thin">
<div className="text-sm font-bold font-korean mb-4">
<div className="text-caption font-bold font-korean mb-4">
{data.code} {data.name}
</div>
<div className="flex flex-col gap-3">
@ -465,17 +458,19 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold font-mono">{h.date}</span>
<span
className={`text-[10px] font-bold px-2 py-0.5 rounded-lg ${
className={`text-caption font-bold px-2 py-0.5 rounded-lg ${
h.type === 'Pre-SCAT'
? 'bg-[rgba(34,197,94,0.15)] text-color-success'
: 'bg-[rgba(59,130,246,0.15)] text-color-info'
? 'bg-[color-mix(in_srgb,var(--color-success)_15%,transparent)] text-color-success'
: 'bg-[color-mix(in_srgb,var(--color-info)_15%,transparent)] text-color-info'
}`}
>
{h.type}
</span>
</div>
<div className="text-[11px] text-fg-sub font-korean mb-1">: {h.team}</div>
<div className="text-[11px] text-fg-disabled font-korean">{h.note}</div>
<div className="text-label-2 text-fg-sub font-korean mb-1">
: {h.team}
</div>
<div className="text-label-2 text-fg-disabled font-korean">{h.note}</div>
</div>
))}
</div>

파일 보기

@ -25,7 +25,7 @@ export default function ScatRightPanel({
return (
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px] h-full">
<div className="text-3xl mb-2">🏖</div>
<div className="text-center text-fg-disabled text-[11px] leading-relaxed">
<div className="text-center text-fg-disabled text-label-2 leading-relaxed">
<br />
@ -43,14 +43,14 @@ export default function ScatRightPanel({
{detail ? (
<div className="flex items-center gap-2">
<span
className="px-2 py-0.5 rounded text-[10px] font-bold text-white"
className="px-2 py-0.5 rounded text-caption font-bold text-white"
style={{ background: detail.esiColor || 'var(--color-accent)' }}
>
{detail.esi}
</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-bold truncate">{detail.name}</div>
<div className="text-[10px] text-fg-disabled">{detail.code}</div>
<div className="text-caption text-fg-disabled">{detail.code}</div>
</div>
</div>
) : (
@ -64,7 +64,7 @@ export default function ScatRightPanel({
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 py-2 text-center text-[11px] font-semibold cursor-pointer transition-colors ${
className={`flex-1 py-2 text-center text-label-2 font-semibold cursor-pointer transition-colors ${
activeTab === tab.id
? 'text-color-accent border-b-2 border-color-accent'
: 'text-fg-disabled hover:text-fg-sub'
@ -78,7 +78,7 @@ export default function ScatRightPanel({
{/* 스크롤 영역 */}
<div className="flex-1 h-0 overflow-y-auto p-2.5 scrollbar-thin">
{loading ? (
<div className="flex items-center justify-center h-full text-fg-disabled text-[11px]">
<div className="flex items-center justify-center h-full text-fg-disabled text-label-2">
...
</div>
) : detail ? (
@ -93,12 +93,12 @@ export default function ScatRightPanel({
{/* 하단 버튼 */}
{/* <div className="flex flex-col gap-1.5 p-2.5 border-t border-stroke shrink-0">
<button onClick={onOpenReport}
className="w-full py-2 text-[11px] font-semibold rounded-md cursor-pointer text-color-accent"
className="w-full py-2 text-label-2 font-semibold rounded-md cursor-pointer text-color-accent"
style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.25)' }}>
📄
</button>
<button onClick={onNewSurvey}
className="w-full py-2 text-[11px] font-semibold rounded-md cursor-pointer text-color-success"
className="w-full py-2 text-label-2 font-semibold rounded-md cursor-pointer text-color-success"
style={{ background: 'rgba(34,197,94,.08)', border: '1px solid rgba(34,197,94,.25)' }}>
</button>
@ -127,19 +127,18 @@ function DetailTab({ detail }: { detail: ScatDetail }) {
{/* 접근성 */}
<Section title="접근성">
<InfoRow label="접근 방법" value={detail.access || '-'} />
<InfoRow label="접근 포인트" value={detail.accessPt || '-'} />
<div className="flex flex-col gap-2">
<InfoBlock label="접근 방법" value={detail.access || '-'} />
<InfoBlock label="접근 포인트" value={detail.accessPt || '-'} />
</div>
</Section>
{/* 민감 자원 */}
{detail.sensitive && detail.sensitive.length > 0 && (
<Section title="민감 자원">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-2">
{detail.sensitive.map((s, i) => (
<div key={i} className="flex items-start gap-1.5 text-[10px]">
<span className="text-color-accent font-bold shrink-0">{s.t}</span>
<span className="text-fg-sub">{s.v}</span>
</div>
<InfoBlock key={i} label={s.t} value={s.v} />
))}
</div>
</Section>
@ -157,7 +156,7 @@ function PhotoTab({ detail }: { detail: ScatDetail }) {
return (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<div className="text-3xl">📷</div>
<div className="text-[11px] text-fg-disabled text-center leading-relaxed">
<div className="text-label-2 text-fg-disabled text-center leading-relaxed">
<br />
.
@ -176,7 +175,7 @@ function PhotoTab({ detail }: { detail: ScatDetail }) {
onError={() => setImgError(true)}
/>
</div>
<div className="text-[10px] text-fg-disabled text-center">{detail.code} </div>
<div className="text-caption text-fg-disabled text-center">{detail.code} </div>
</div>
);
}
@ -192,18 +191,14 @@ function CleanupTab({ detail }: { detail: ScatDetail }) {
{detail.cleanup.map((method, i) => (
<span
key={i}
className="px-2 py-0.5 rounded text-[10px] font-semibold text-color-accent"
style={{
background: 'rgba(6,182,212,.08)',
border: '1px solid rgba(6,182,212,.2)',
}}
className="px-2 py-0.5 rounded text-caption font-semibold text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_20%,transparent)]"
>
{method}
</span>
))}
</div>
) : (
<div className="text-[10px] text-fg-disabled"> </div>
<div className="text-caption text-fg-disabled"> </div>
)}
</Section>
@ -212,14 +207,14 @@ function CleanupTab({ detail }: { detail: ScatDetail }) {
{detail.endCriteria && detail.endCriteria.length > 0 ? (
<div className="flex flex-col gap-1">
{detail.endCriteria.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[10px] text-fg-sub">
<div key={i} className="flex items-start gap-1.5 text-caption text-fg-sub">
<span className="text-color-success font-bold shrink-0"></span>
<span>{c}</span>
</div>
))}
</div>
) : (
<div className="text-[10px] text-fg-disabled"> </div>
<div className="text-caption text-fg-disabled"> </div>
)}
</Section>
@ -230,14 +225,14 @@ function CleanupTab({ detail }: { detail: ScatDetail }) {
{detail.notes.map((note, i) => (
<div
key={i}
className="text-[10px] text-fg-sub leading-[1.6] px-2 py-1.5 rounded bg-bg-base"
className="text-caption text-fg-sub leading-[1.6] px-2 py-1.5 rounded bg-bg-base"
>
{note}
</div>
))}
</div>
) : (
<div className="text-[10px] text-fg-disabled"> </div>
<div className="text-caption text-fg-disabled"> </div>
)}
</Section>
</div>
@ -248,7 +243,7 @@ function CleanupTab({ detail }: { detail: ScatDetail }) {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
<div className="text-[11px] font-bold mb-2 text-fg-sub">{title}</div>
<div className="text-label-2 font-bold mb-2 text-fg-sub">{title}</div>
{children}
</div>
);
@ -264,10 +259,10 @@ function InfoRow({
valueColor?: string;
}) {
return (
<div className="flex items-center justify-between py-0.5">
<span className="text-[10px] text-fg-disabled">{label}</span>
<div className="flex items-center justify-between py-0.5 gap-2">
<span className="text-caption text-fg-disabled shrink-0">{label}</span>
<span
className="text-[10px] font-semibold"
className="text-caption text-right min-w-0 break-all"
style={valueColor ? { color: valueColor } : undefined}
>
{value}
@ -275,3 +270,15 @@ function InfoRow({
</div>
);
}
function InfoBlock({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-caption text-fg-disabled mb-0.5">{label}</div>
<div className="flex items-start gap-1.5 text-caption text-fg-sub pl-1">
<span className="text-color-accent shrink-0 leading-tight"></span>
<span className="break-all">{value}</span>
</div>
</div>
);
}

파일 보기

@ -110,7 +110,7 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
{displaySegs.map((s, i) => (
<span
key={i}
className={`text-[10px] font-mono cursor-pointer ${i === currentIdx ? 'text-color-success font-semibold' : 'text-fg-disabled'}`}
className={`text-caption font-mono cursor-pointer ${i === currentIdx ? 'text-color-success font-semibold' : 'text-fg-disabled'}`}
onClick={() => onSeek(i)}
>
{s.code}
@ -137,14 +137,14 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
>
{s.status === '완료' && (
<span
className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px] cursor-pointer"
className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-caption cursor-pointer"
style={{ filter: 'drop-shadow(0 0 4px rgba(34,197,94,0.5))' }}
>
</span>
)}
{s.status === '진행중' && (
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px]">
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-caption">
</span>
)}
@ -166,19 +166,19 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
{displaySegs[currentIdx]?.code || 'S-001'} / {total}
</span>
<div className="flex gap-3.5">
<span className="flex items-center gap-1.5 text-[11px]">
<span className="flex items-center gap-1.5 text-label-2">
<span className="text-fg-sub font-korean"></span>
<span className="text-fg font-semibold font-mono text-color-success">
{doneCount}/{segments.length}
</span>
</span>
<span className="flex items-center gap-1.5 text-[11px]">
<span className="flex items-center gap-1.5 text-label-2">
<span className="text-fg-sub font-korean"></span>
<span className="text-fg font-semibold font-mono text-color-warning">
{progCount}/{segments.length}
</span>
</span>
<span className="flex items-center gap-1.5 text-[11px]">
<span className="flex items-center gap-1.5 text-label-2">
<span className="text-fg-sub font-korean"> </span>
<span className="text-fg font-semibold font-mono">
{(totalLen / 1000).toFixed(1)} km

파일 보기

@ -144,7 +144,7 @@ export function WeatherMapOverlay({
</div>
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
className="w-6 h-6 rounded-full flex items-center justify-center text-label-2 text-white font-bold"
style={{ background: 'linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
>
🌡
@ -153,12 +153,12 @@ export function WeatherMapOverlay({
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.temperature.current.toFixed(1)}
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
className="w-6 h-6 rounded-full flex items-center justify-center text-label-2 text-white font-bold"
style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
>
🌊
@ -167,12 +167,12 @@ export function WeatherMapOverlay({
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.wave.height.toFixed(1)}
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
className="w-6 h-6 rounded-full flex items-center justify-center text-label-2 text-white font-bold"
style={{ background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
>
💨
@ -181,7 +181,7 @@ export function WeatherMapOverlay({
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.wind.speed.toFixed(1)}
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
</div>
</div>
</div>

파일 보기

@ -90,7 +90,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
return (
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
<div className="p-6 text-center">
<p className="text-fg-disabled text-[13px] font-korean">
<p className="text-fg-disabled text-title-4 font-korean">
</p>
</div>
@ -110,14 +110,14 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* 헤더 */}
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated">
<div className="flex items-center gap-2 mb-1">
<span className="text-[13px] font-bold text-color-accent font-korean">
<span className="text-title-4 font-bold text-color-accent font-korean">
📍 {weatherData.stationName}
</span>
<span className="px-1.5 py-px text-[11px] rounded bg-color-accent/15 text-color-accent font-bold">
<span className="px-1.5 py-px text-label-2 rounded bg-[rgba(6,182,212,0.15)] text-color-accent font-bold">
</span>
</div>
<p className="text-[11px] text-fg-disabled font-mono">
<p className="text-label-2 text-fg-disabled font-mono">
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '}
{weatherData.currentTime}
</p>
@ -134,25 +134,25 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
<div className="text-[20px] font-bold font-mono" style={{ color: windColor(wSpd) }}>
{wSpd.toFixed(1)}
</div>
<div className="text-[11px] text-fg-disabled font-korean"> (m/s)</div>
<div className="text-label-2 text-fg-disabled font-korean"> (m/s)</div>
</div>
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
<div className="text-[20px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
{wHgt.toFixed(1)}
</div>
<div className="text-[11px] text-fg-disabled font-korean"> (m)</div>
<div className="text-label-2 text-fg-disabled font-korean"> (m)</div>
</div>
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
<div className="text-[20px] font-bold font-mono text-color-accent">
{wTemp.toFixed(1)}
</div>
<div className="text-[11px] text-fg-disabled font-korean"> (°C)</div>
<div className="text-label-2 text-fg-disabled font-korean"> (°C)</div>
</div>
</div>
{/* ── 바람 상세 ── */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
🌬
</div>
<div className="flex items-center gap-3 mb-2">
@ -209,32 +209,38 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
<circle cx="25" cy="25" r="3" fill={windColor(wSpd)} />
</svg>
</div>
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px]">
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-label-2">
<div className="flex justify-between">
<span className="text-fg-disabled"></span>
<span className="text-fg font-mono font-bold text-[13px]">
<span className="text-fg font-mono font-bold text-title-4">
{windDir} {wind.direction}°
</span>
</div>
<div className="flex justify-between">
<span className="text-fg-disabled"></span>
<span className="text-fg font-mono text-[13px]">{pressure} hPa</span>
<span className="text-fg font-mono text-title-4">{pressure} hPa</span>
</div>
<div className="flex justify-between">
<span className="text-fg-disabled">1k </span>
<span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_1k) }}>
<span
className="font-mono text-title-4"
style={{ color: windColor(wind.speed_1k) }}
>
{Number(wind.speed_1k).toFixed(1)}
</span>
</div>
<div className="flex justify-between">
<span className="text-fg-disabled">3k </span>
<span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_3k) }}>
<span
className="font-mono text-title-4"
style={{ color: windColor(wind.speed_3k) }}
>
{Number(wind.speed_3k).toFixed(1)}
</span>
</div>
<div className="col-span-2 flex justify-between">
<span className="text-fg-disabled"></span>
<span className="text-fg font-mono text-[13px]">{visibility} km</span>
<span className="text-fg font-mono text-title-4">{visibility} km</span>
</div>
</div>
</div>
@ -249,7 +255,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
}}
/>
</div>
<span className="text-[11px] font-mono text-fg-disabled shrink-0">
<span className="text-label-2 font-mono text-fg-disabled shrink-0">
{wSpd.toFixed(1)}/20
</span>
</div>
@ -257,29 +263,29 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 파도 상세 ── */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">🌊 </div>
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">🌊 </div>
<div className="grid grid-cols-4 gap-1">
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-[14px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
<div className="text-title-3 font-bold font-mono" style={{ color: waveColor(wHgt) }}>
{wHgt.toFixed(1)}m
</div>
<div className="text-[10px] text-fg-disabled"></div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-[14px] font-bold font-mono text-color-danger">
<div className="text-title-3 font-bold font-mono text-color-danger">
{wave.maxHeight.toFixed(1)}m
</div>
<div className="text-[10px] text-fg-disabled"></div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-[14px] font-bold font-mono text-color-accent">
<div className="text-title-3 font-bold font-mono text-color-accent">
{wave.period}s
</div>
<div className="text-[10px] text-fg-disabled"></div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-[14px] font-bold font-mono text-fg">{wave.direction}</div>
<div className="text-[10px] text-fg-disabled"></div>
<div className="text-title-3 font-bold font-mono text-fg">{wave.direction}</div>
<div className="text-caption text-fg-disabled"></div>
</div>
</div>
{/* 파고 게이지 바 */}
@ -293,7 +299,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
}}
/>
</div>
<span className="text-[11px] font-mono text-fg-disabled shrink-0">
<span className="text-label-2 font-mono text-fg-disabled shrink-0">
{wHgt.toFixed(1)}/5m
</span>
</div>
@ -301,32 +307,32 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 수온/공기 ── */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
🌡 ·
</div>
<div className="grid grid-cols-3 gap-1">
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-[14px] font-bold font-mono text-color-accent">
<div className="text-title-3 font-bold font-mono text-color-accent">
{wTemp.toFixed(1)}°
</div>
<div className="text-[10px] text-fg-disabled"></div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-[14px] font-bold font-mono text-fg">
<div className="text-title-3 font-bold font-mono text-fg">
{temperature.feelsLike.toFixed(1)}°
</div>
<div className="text-[10px] text-fg-disabled"></div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-[14px] font-bold font-mono text-fg">{salinity.toFixed(1)}</div>
<div className="text-[10px] text-fg-disabled">(PSU)</div>
<div className="text-title-3 font-bold font-mono text-fg">{salinity.toFixed(1)}</div>
<div className="text-caption text-fg-disabled">(PSU)</div>
</div>
</div>
</div>
{/* ── 시간별 예보 ── */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
</div>
<div className="grid grid-cols-5 gap-1">
@ -335,10 +341,10 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
key={i}
className="flex flex-col items-center py-2 px-1 bg-bg-base border border-stroke rounded"
>
<span className="text-[11px] text-fg-disabled mb-0.5">{f.hour}</span>
<span className="text-label-2 text-fg-disabled mb-0.5">{f.hour}</span>
<span className="text-lg mb-0.5">{f.icon}</span>
<span className="text-[13px] font-bold text-fg">{f.temperature}°</span>
<span className="text-[11px] text-fg-disabled font-mono">{f.windSpeed}</span>
<span className="text-title-4 font-bold text-fg">{f.temperature}°</span>
<span className="text-label-2 text-fg-disabled font-mono">{f.windSpeed}</span>
</div>
))}
</div>
@ -347,7 +353,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 천문/조석 ── */}
{astronomy && (
<div className="px-3 py-2 border-b border-stroke">
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
·
</div>
<div className="grid grid-cols-4 gap-1">
@ -359,12 +365,12 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
].map((item, i) => (
<div key={i} className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-base mb-0.5">{item.icon}</div>
<div className="text-[10px] text-fg-disabled">{item.label}</div>
<div className="text-[13px] font-bold font-mono text-fg">{item.value}</div>
<div className="text-caption text-fg-disabled">{item.label}</div>
<div className="text-title-4 font-bold font-mono text-fg">{item.value}</div>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-base border border-stroke rounded text-[11px]">
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-base border border-stroke rounded text-label-2">
<span className="text-sm">🌓</span>
<span className="text-fg-disabled">{astronomy.moonPhase}</span>
<span className="ml-auto text-fg font-mono"> {astronomy.tidalRange}m</span>
@ -375,16 +381,16 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 날씨 특보 ── */}
{alert && (
<div className="px-3 py-2">
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
🚨
</div>
<div
className="px-2.5 py-2 rounded border"
style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}
>
<div className="flex items-center gap-2 text-[11px]">
<div className="flex items-center gap-2 text-label-2">
<span
className="px-1.5 py-px rounded text-[11px] font-bold"
className="px-1.5 py-px rounded text-label-2 font-bold"
style={{ background: 'rgba(239,68,68,.15)', color: 'var(--color-danger)' }}
>

파일 보기

@ -1,55 +1,55 @@
import { useState, useMemo, useCallback } from 'react'
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import type { Layer } from '@deck.gl/core'
import type { MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
import { useMapStore } from '@common/store/mapStore'
import { WeatherRightPanel } from './WeatherRightPanel'
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
import { useState, useMemo, useCallback } from 'react';
import { Map, Marker, useControl } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer } from '@deck.gl/core';
import type { MapLayerMouseEvent } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import { WeatherRightPanel } from './WeatherRightPanel';
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay';
// import { OceanForecastOverlay } from './OceanForecastOverlay'
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
import { WindParticleLayer } from './WindParticleLayer'
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData'
import { useWaterTemperatureLayers } from './WaterTemperatureLayer';
import { WindParticleLayer } from './WindParticleLayer';
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer';
import { useWeatherData } from '../hooks/useWeatherData';
// import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls'
import { degreesToCardinal } from '../services/weatherUtils'
import { WeatherMapControls } from './WeatherMapControls';
import { degreesToCardinal } from '../services/weatherUtils';
type TimeOffset = '0' | '3' | '6' | '9'
type TimeOffset = '0' | '3' | '6' | '9';
interface WeatherStation {
id: string
name: string
location: { lat: number; lon: number }
id: string;
name: string;
location: { lat: number; lon: number };
wind: {
speed: number
direction: number
speed_1k: number
speed_3k: number
}
speed: number;
direction: number;
speed_1k: number;
speed_3k: number;
};
wave: {
height: number
period: number
}
height: number;
period: number;
};
temperature: {
current: number
feelsLike: number
}
pressure: number
visibility: number
salinity?: number
current: number;
feelsLike: number;
};
pressure: number;
visibility: number;
salinity?: number;
}
interface WeatherForecast {
time: string
hour: string
icon: string
temperature: number
windSpeed: number
time: string;
hour: string;
icon: string;
temperature: number;
windSpeed: number;
}
// Base weather station locations
@ -64,50 +64,50 @@ const BASE_STATIONS = [
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
]
];
// Generate forecast data based on time offset
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
const baseHour = parseInt(timeOffset)
const forecasts: WeatherForecast[] = []
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
const baseHour = parseInt(timeOffset);
const forecasts: WeatherForecast[] = [];
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️'];
for (let i = 0; i < 5; i++) {
const hour = baseHour + i * 3
const hour = baseHour + i * 3;
forecasts.push({
time: `+${hour}`,
hour: `${hour}`,
icon: icons[i % icons.length],
temperature: Math.floor(Math.random() * 5) + 5,
windSpeed: Math.floor(Math.random() * 5) + 6,
})
});
}
return forecasts
}
return forecasts;
};
// 한국 해역 중심 좌표 (한반도 중앙)
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5] // [lng, lat]
const WEATHER_MAP_ZOOM = 7
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
const WEATHER_MAP_ZOOM = 7;
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
/**
* WeatherMapInner Map (useMap / useControl )
*/
interface WeatherMapInnerProps {
weatherStations: WeatherStation[]
enabledLayers: Set<string>
selectedStationId: string | null
onStationClick: (station: WeatherStation) => void
mapCenter: [number, number]
mapZoom: number
clickedLocation: { lat: number; lon: number } | null
weatherStations: WeatherStation[];
enabledLayers: Set<string>;
selectedStationId: string | null;
onStationClick: (station: WeatherStation) => void;
mapCenter: [number, number];
mapZoom: number;
clickedLocation: { lat: number; lon: number } | null;
}
function WeatherMapInner({
@ -124,8 +124,8 @@ function WeatherMapInner({
weatherStations,
enabledLayers,
selectedStationId,
onStationClick
)
onStationClick,
);
// const oceanCurrentLayers = useOceanCurrentLayers({
// visible: enabledLayers.has('oceanCurrent'),
// opacity: 0.7,
@ -133,12 +133,12 @@ function WeatherMapInner({
const waterTempLayers = useWaterTemperatureLayers({
visible: enabledLayers.has('waterTemperature'),
opacity: 0.5,
})
});
const deckLayers = useMemo(
() => [...waterTempLayers, ...weatherDeckLayers],
[waterTempLayers, weatherDeckLayers]
)
[waterTempLayers, weatherDeckLayers],
);
return (
<>
@ -153,9 +153,7 @@ function WeatherMapInner({
/> */}
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
<OceanCurrentParticleLayer
visible={enabledLayers.has('oceanCurrentParticle')}
/>
<OceanCurrentParticleLayer visible={enabledLayers.has('oceanCurrentParticle')} />
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
<WeatherMapOverlay
@ -166,18 +164,11 @@ function WeatherMapInner({
/>
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
<WindParticleLayer
visible={enabledLayers.has('windParticle')}
stations={weatherStations}
/>
<WindParticleLayer visible={enabledLayers.has('windParticle')} stations={weatherStations} />
{/* 클릭 위치 마커 */}
{clickedLocation && (
<Marker
longitude={clickedLocation.lon}
latitude={clickedLocation.lat}
anchor="bottom"
>
<Marker longitude={clickedLocation.lon} latitude={clickedLocation.lat} anchor="bottom">
<div className="flex flex-col items-center pointer-events-none">
{/* 펄스 링 */}
<div className="relative flex items-center justify-center">
@ -187,7 +178,7 @@ function WeatherMapInner({
{/* 핀 꼬리 */}
<div className="w-px h-3 bg-color-accent" />
{/* 좌표 라벨 */}
<div className="px-2 py-1 bg-bg-base/90 border border-color-accent rounded text-[10px] text-color-accent whitespace-nowrap backdrop-blur-sm">
<div className="px-2 py-1 bg-bg-base/90 border border-color-accent rounded text-caption text-color-accent whitespace-nowrap backdrop-blur-sm">
{clickedLocation.lat.toFixed(3)}°N&nbsp;{clickedLocation.lon.toFixed(3)}°E
</div>
</div>
@ -197,14 +188,13 @@ function WeatherMapInner({
{/* 줌 컨트롤 */}
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
</>
)
);
}
export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
const currentMapStyle = useBaseMapStyle()
const mapToggles = useMapStore((s) => s.mapToggles)
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS);
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
// const {
// selectedForecast,
@ -214,58 +204,55 @@ export function WeatherView() {
// selectForecast,
// } = useOceanForecast('KOREA')
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0');
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null);
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
null
)
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
null,
);
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']));
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
// 첫 관측소 자동 선택 (파생 값)
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null;
const handleStationClick = useCallback(
(station: WeatherStation) => {
setSelectedStation(station)
setSelectedLocation(null)
},
[]
)
const handleStationClick = useCallback((station: WeatherStation) => {
setSelectedStation(station);
setSelectedLocation(null);
}, []);
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
const { lat, lng } = e.lngLat
if (weatherStations.length === 0) return
const { lat, lng } = e.lngLat;
if (weatherStations.length === 0) return;
// 가장 가까운 관측소 선택
const nearestStation = weatherStations.reduce((nearest, station) => {
const distance = Math.sqrt(
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2)
)
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2),
);
const nearestDistance = Math.sqrt(
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2)
)
return distance < nearestDistance ? station : nearest
}, weatherStations[0])
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2),
);
return distance < nearestDistance ? station : nearest;
}, weatherStations[0]);
setSelectedStation(nearestStation)
setSelectedLocation({ lat, lon: lng })
setSelectedStation(nearestStation);
setSelectedLocation({ lat, lon: lng });
},
[weatherStations]
)
[weatherStations],
);
const toggleLayer = useCallback((layer: string) => {
setEnabledLayers((prev) => {
const next = new Set(prev)
const next = new Set(prev);
if (next.has(layer)) {
next.delete(layer)
next.delete(layer);
} else {
next.add(layer)
next.add(layer);
}
return next
})
}, [])
return next;
});
}, []);
const weatherData = selectedStation
? {
@ -301,7 +288,7 @@ export function WeatherView() {
alert: '풍랑주의보 예상 08:00~',
forecast: generateForecast(timeOffset),
}
: null
: null;
return (
<div className="flex flex-1 overflow-hidden">
@ -369,8 +356,11 @@ export function WeatherView() {
</Map>
{/* 레이어 컨트롤 */}
<div className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px' }}>
<div className="text-[9px] font-semibold text-fg mb-1.5 font-korean"> </div>
<div
className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
style={{ padding: '6px 10px' }}
>
<div className="text-caption font-semibold text-fg mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
@ -379,7 +369,7 @@ export function WeatherView() {
onChange={() => toggleLayer('windParticle')}
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
/>
<span className="text-[9px] text-fg-sub">🌬 </span>
<span className="text-caption text-fg-sub">🌬 </span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
@ -388,7 +378,7 @@ export function WeatherView() {
onChange={() => toggleLayer('wind')}
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
/>
<span className="text-[9px] text-fg-sub">🌬 </span>
<span className="text-caption text-fg-sub">🌬 </span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
@ -397,7 +387,7 @@ export function WeatherView() {
onChange={() => toggleLayer('waves')}
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
/>
<span className="text-[9px] text-fg-sub">🌊 </span>
<span className="text-caption text-fg-sub">🌊 </span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
@ -406,7 +396,7 @@ export function WeatherView() {
onChange={() => toggleLayer('temperature')}
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
/>
<span className="text-[9px] text-fg-sub">🌡 </span>
<span className="text-caption text-fg-sub">🌡 </span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
@ -415,7 +405,7 @@ export function WeatherView() {
onChange={() => toggleLayer('oceanCurrentParticle')}
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
/>
<span className="text-[9px] text-fg-sub">🌊 </span>
<span className="text-caption text-fg-sub">🌊 </span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
@ -424,18 +414,23 @@ export function WeatherView() {
onChange={() => toggleLayer('waterTemperature')}
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
/>
<span className="text-[9px] text-fg-sub">🌡 </span>
<span className="text-caption text-fg-sub">🌡 </span>
</label>
</div>
</div>
{/* 범례 */}
<div className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px', maxWidth: 180 }}>
<div className="text-[9px] font-semibold text-fg mb-1.5 font-korean"> </div>
<div
className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
style={{ padding: '6px 10px', maxWidth: 180 }}
>
<div className="text-caption font-semibold text-fg mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
{/* 바람 */}
<div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}> (m/s)</div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m/s)
</div>
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
@ -447,12 +442,20 @@ export function WeatherView() {
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
</div>
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
<span>3</span>
<span>5</span>
<span>7</span>
<span>10</span>
<span>13</span>
<span>16</span>
<span>20+</span>
</div>
</div>
{/* 해류 */}
<div className="pt-1 border-t border-stroke">
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}> (m/s)</div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m/s)
</div>
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
@ -460,12 +463,17 @@ export function WeatherView() {
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
</div>
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
<span>0.2</span><span>0.4</span><span>0.6</span><span>0.6+</span>
<span>0.2</span>
<span>0.4</span>
<span>0.6</span>
<span>0.6+</span>
</div>
</div>
{/* 파고 */}
<div className="pt-1 border-t border-stroke">
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}> (m)</div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m)
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-fg-disabled">&lt;1.5 </span>
@ -476,7 +484,10 @@ export function WeatherView() {
</div>
</div>
</div>
<div className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean" style={{ fontSize: 7 }}>
<div
className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean"
style={{ fontSize: 7 }}
>
💡
</div>
</div>
@ -486,5 +497,5 @@ export function WeatherView() {
{/* Right Panel */}
<WeatherRightPanel weatherData={weatherData} />
</div>
)
);
}

Some files were not shown because too many files have changed in this diff Show More