+
+ Radius Tokens
+
+
+
+ {/* 헤더 */}
+
+ {(['이름', '값', 'Preview'] as const).map((col) => (
+
+
+ {col}
+
+
+ ))}
+
+
+ {/* 데이터 행 */}
+ {RADIUS_TOKENS.map((token, rowIdx) => (
+
+ {/* 이름 */}
+
+
+ {token.name}
+
+ {token.isCustom && (
+
+ custom
+
+ )}
+
+
+ {/* 값 */}
+
+
+ {token.value}
+
+
+
+ {/* Preview */}
+
+
= 9999 ? '9999px' : `${token.px}px`,
+ backgroundColor: isDark ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.12)',
+ border: `1.5px solid ${isDark ? 'rgba(6,182,212,0.40)' : 'rgba(6,182,212,0.50)'}`,
+ }}
+ />
+
= 9999 ? '9999px' : `${token.px}px`,
+ backgroundColor: isDark ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.12)',
+ border: `1.5px solid ${isDark ? 'rgba(6,182,212,0.40)' : 'rgba(6,182,212,0.50)'}`,
+ }}
+ />
+
+
+ ))}
+
+
+
+ {/* ── 섹션 3: 컴포넌트 매핑 ── */}
+
+
+
+ 컴포넌트 매핑
+
+
+ wing.css 컴포넌트 클래스에 적용된 Radius 토큰입니다.
+
+
+
+
+ {COMPONENT_RADIUS.map((item) => (
+
+ {/* 미리보기 박스 */}
+
+
+ {/* 정보 */}
+
+
+ {item.className}
+
+
+ {item.components.map((comp) => (
+
+ {comp}
+
+ ))}
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default RadiusContent;
diff --git a/frontend/src/pages/design/TypographyContent.tsx b/frontend/src/pages/design/TypographyContent.tsx
new file mode 100644
index 0000000..7e69fff
--- /dev/null
+++ b/frontend/src/pages/design/TypographyContent.tsx
@@ -0,0 +1,462 @@
+// TypographyContent.tsx — WING-OPS Typography 콘텐츠 (다크/라이트 테마 지원)
+
+import type { DesignTheme } from './designTheme';
+
+// ---------- 데이터 타입 ----------
+
+interface FontFamily {
+ name: string;
+ className: string;
+ stack: string;
+ usage: string;
+ sampleText: string;
+}
+
+interface TypographyToken {
+ className: string;
+ size: string;
+ font: string;
+ weight: string;
+ usage: string;
+ sampleText: string;
+ sampleStyle: React.CSSProperties;
+}
+
+// ---------- Font Family 데이터 ----------
+
+const FONT_FAMILIES: FontFamily[] = [
+ {
+ name: 'Noto Sans KR',
+ className: 'font-korean',
+ stack: "'Noto Sans KR', sans-serif",
+ usage: '기본 UI 텍스트, 레이블, 설명 등 한국어 콘텐츠 전반에 사용됩니다. 프로젝트에서 가장 많이 사용되는 폰트입니다.',
+ sampleText: '해양 방제 운영 지원 시스템 WING-OPS',
+ },
+ {
+ name: 'JetBrains Mono',
+ className: 'font-mono',
+ stack: "'JetBrains Mono', monospace",
+ usage: '좌표, 수치 데이터, 코드, 토큰 이름 등 고정폭이 필요한 콘텐츠에 사용됩니다.',
+ sampleText: '126.978° E, 37.566° N — #0a0e1a',
+ },
+ {
+ name: 'Outfit',
+ className: 'font-sans',
+ stack: "'Outfit', 'Noto Sans KR', sans-serif",
+ usage: '영문 헤딩과 브랜드 타이틀에 사용됩니다. body 기본 폰트 스택에 포함되어 있습니다.',
+ sampleText: 'WING-OPS Design System v1.0',
+ },
+];
+
+// ---------- Typography Token 데이터 ----------
+
+const TYPOGRAPHY_TOKENS: TypographyToken[] = [
+ {
+ className: '.wing-title',
+ size: '15px',
+ font: 'font-korean',
+ weight: 'Bold (700)',
+ usage: '패널 제목',
+ sampleText: '확산 예측 시뮬레이션',
+ sampleStyle: { fontSize: '15px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+ {
+ className: '.wing-section-header',
+ size: '13px',
+ font: 'font-korean',
+ weight: 'Bold (700)',
+ usage: '섹션 헤더',
+ sampleText: '기본 정보 입력',
+ sampleStyle: { fontSize: '13px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+ {
+ className: '.wing-label',
+ size: '11px',
+ font: 'font-korean',
+ weight: 'Semibold (600)',
+ usage: '필드 레이블',
+ sampleText: '유출량 (kL)',
+ sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+ {
+ className: '.wing-btn',
+ size: '11px',
+ font: 'font-korean',
+ weight: 'Semibold (600)',
+ usage: '버튼 텍스트',
+ sampleText: '시뮬레이션 실행',
+ sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+ {
+ className: '.wing-value',
+ size: '11px',
+ font: 'font-mono',
+ weight: 'Semibold (600)',
+ usage: '수치 / 데이터 값',
+ sampleText: '35.1284° N, 129.0598° E',
+ sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'JetBrains Mono', monospace" },
+ },
+ {
+ className: '.wing-input',
+ size: '11px',
+ font: 'font-korean',
+ weight: 'Normal (400)',
+ usage: '입력 필드',
+ sampleText: '서해 대산항 인근 해역',
+ sampleStyle: { fontSize: '11px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+ {
+ className: '.wing-section-desc',
+ size: '10px',
+ font: 'font-korean',
+ weight: 'Normal (400)',
+ usage: '섹션 설명',
+ sampleText: '예측 결과는 기상 조건에 따라 달라질 수 있습니다.',
+ sampleStyle: { fontSize: '10px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+ {
+ className: '.wing-subtitle',
+ size: '10px',
+ font: 'font-korean',
+ weight: 'Normal (400)',
+ usage: '보조 설명',
+ sampleText: '최근 업데이트: 2026-03-24 09:00 KST',
+ sampleStyle: { fontSize: '10px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+ {
+ className: '.wing-meta',
+ size: '9px',
+ font: 'font-korean',
+ weight: 'Normal (400)',
+ usage: '메타 정보',
+ sampleText: 'v2.1 | 해양환경공단',
+ sampleStyle: { fontSize: '9px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+ {
+ className: '.wing-badge',
+ size: '9px',
+ font: 'font-korean',
+ weight: 'Bold (700)',
+ usage: '뱃지 / 태그',
+ sampleText: '진행중',
+ sampleStyle: { fontSize: '9px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
+ },
+];
+
+// ---------- Props ----------
+
+interface TypographyContentProps {
+ theme: DesignTheme;
+}
+
+// ---------- 컴포넌트 ----------
+
+export const TypographyContent = ({ theme }: TypographyContentProps) => {
+ const t = theme;
+ const isDark = t.mode === 'dark';
+
+ return (
+
+
+ {/* ── 섹션 1: 헤더 + 개요 ── */}
+
+
+
+ Typography
+
+
+ WING-OPS 인터페이스에서 사용되는 타이포그래피 체계입니다. 폰트 패밀리, 크기, 두께를 토큰과 컴포넌트 클래스로 정의하여 시각적 계층 구조와 일관성을 유지합니다.
+
+
+
+
+
+ 개요
+
+
+ - 폰트 크기, 폰트 두께, 폰트 패밀리를 각각 토큰으로 정의합니다.
+ - 컴포넌트 클래스(
.wing-*)로 조합하여 일관된 텍스트 스타일을 적용합니다.
+ - 시스템 폰트를 기반으로 다양한 환경에서 일관된 사용자 경험을 보장합니다.
+
+
+
+
+ {/* ── 섹션 2: 글꼴 (Font Family) ── */}
+
+
+
+ 글꼴
+
+
+ 사용자의 디바이스 환경을 고려하여, 시스템 폰트와 웹 폰트를 조합하여 사용합니다. 한국어 UI에 최적화된 폰트 스택으로 다양한 기기에서 일관된 가독성을 보장합니다.
+
+
+
+ {/* body 기본 폰트 스택 코드 블록 */}
+
+
+ font-family
+ {`: 'Outfit', 'Noto Sans KR', sans-serif;`}
+
+
+
+ {/* 폰트 카드 3종 */}
+
+ {FONT_FAMILIES.map((font) => (
+
+ {/* 카드 헤더 */}
+
+
+ {font.name}
+
+
+ {font.className}
+
+
+
+ {/* 카드 본문 */}
+
+ {/* 폰트 스택 */}
+
+ {font.stack}
+
+
+ {/* 용도 설명 */}
+
+ {font.usage}
+
+
+ {/* 샘플 렌더 */}
+
+ {/* Regular */}
+
+
+ Regular
+
+
+ {font.sampleText}
+
+
+ {/* Bold */}
+
+
+ Bold
+
+
+ {font.sampleText}
+
+
+
+
+
+ ))}
+
+
+
+ {/* ── 섹션 3: 타이포그래피 토큰 ── */}
+
+
+
+ 타이포그래피 토큰
+
+
+ - Tailwind @apply 기반 컴포넌트 클래스로 정의됩니다 (
wing.css).
+ - 크기는 접근성을 위해 px 단위로 명시적으로 지정합니다.
+ - 실제 UI에서는 클래스명을 직접 사용하거나, 동일한 속성 조합으로 적용합니다.
+
+
+
+ {/* 토큰 테이블 */}
+
+ {/* 헤더 */}
+
+ {(['Class', 'Size', 'Font', 'Weight', '용도', 'Sample'] as const).map((col) => (
+
+
+ {col}
+
+
+ ))}
+
+
+ {/* 데이터 행 */}
+ {TYPOGRAPHY_TOKENS.map((token, rowIdx) => (
+
+ {/* Class */}
+
+
+ {token.className}
+
+
+
+ {/* Size */}
+
+
+ {token.size}
+
+
+
+ {/* Font */}
+
+
+ {token.font}
+
+
+
+ {/* Weight */}
+
+
+ {token.weight}
+
+
+
+ {/* 용도 */}
+
+
+ {token.usage}
+
+
+
+ {/* Sample */}
+
+
+ {token.sampleText}
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default TypographyContent;
diff --git a/frontend/src/pages/design/components/ButtonCatalogSection.tsx b/frontend/src/pages/design/components/ButtonCatalogSection.tsx
new file mode 100644
index 0000000..5398fa3
--- /dev/null
+++ b/frontend/src/pages/design/components/ButtonCatalogSection.tsx
@@ -0,0 +1,242 @@
+import pdfFileIcon from '../../../assets/icons/wing-pdf-file.svg';
+import pdfFileDisabledIcon from '../../../assets/icons/wing-pdf-file-disabled.svg';
+
+interface ButtonRow {
+ label: string;
+ defaultBtn: React.ReactNode;
+ hoverBtn: React.ReactNode;
+ disabledBtn: React.ReactNode;
+}
+
+const buttonRows: ButtonRow[] = [
+ {
+ label: '프라이머리 (그라디언트)',
+ defaultBtn: (
+
+ ),
+ hoverBtn: (
+
+ ),
+ disabledBtn: (
+
+ ),
+ },
+ {
+ label: '세컨더리 (솔리드)',
+ defaultBtn: (
+
+ ),
+ hoverBtn: (
+
+ ),
+ disabledBtn: (
+
+ ),
+ },
+ {
+ label: '아웃라인 (고스트)',
+ defaultBtn: (
+
+ ),
+ hoverBtn: (
+
+ ),
+ disabledBtn: (
+
+ ),
+ },
+ {
+ label: 'PDF 액션',
+ defaultBtn: (
+
+

+
+ PDF 다운로드
+
+
+ ),
+ hoverBtn: (
+
+

+
+ PDF 다운로드
+
+
+ ),
+ disabledBtn: (
+
+

+
+ PDF 다운로드
+
+
+ ),
+ },
+ {
+ label: '경고 (삭제)',
+ defaultBtn: (
+
+ ),
+ hoverBtn: (
+
+ ),
+ disabledBtn: (
+
+ ),
+ },
+];
+
+export const ButtonCatalogSection = () => {
+ return (
+
+ {/* 카드 헤더 */}
+
+
+ {/* 테이블 본문 */}
+
+
+ {/* 헤더 행 */}
+
+ {['버튼 유형', '기본 상태', '호버 상태', '비활성 상태'].map((header) => (
+
+ ))}
+
+
+ {/* 데이터 행 */}
+
+ {buttonRows.map((row, index) => (
+
+ {/* 버튼 유형 레이블 */}
+
+
+ {/* 기본 상태 */}
+
+ {row.defaultBtn}
+
+
+ {/* 호버 상태 */}
+
+ {row.hoverBtn}
+
+
+ {/* 비활성 상태 */}
+
+ {row.disabledBtn}
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/design/components/CardSection.tsx b/frontend/src/pages/design/components/CardSection.tsx
new file mode 100644
index 0000000..5864b6d
--- /dev/null
+++ b/frontend/src/pages/design/components/CardSection.tsx
@@ -0,0 +1,157 @@
+import wingAnchorIcon from '../../../assets/icons/wing-anchor.svg';
+import wingCargoIcon from '../../../assets/icons/wing-cargo.svg';
+import wingAlertTriangleIcon from '../../../assets/icons/wing-alert-triangle.svg';
+import wingChartBarIcon from '../../../assets/icons/wing-chart-bar.svg';
+import wingWaveGraph from '../../../assets/icons/wing-wave-graph.svg';
+
+interface LogisticsItem {
+ icon: string;
+ label: string;
+ progress: string;
+}
+
+const logisticsItems: LogisticsItem[] = [
+ { icon: wingAnchorIcon, label: '화물 통관', progress: '진행률: 84%' },
+ { icon: wingCargoIcon, label: '화물 통관', progress: '진행률: 84%' },
+ { icon: wingAlertTriangleIcon, label: '화물 통관', progress: '진행률: 84%' },
+];
+
+export const CardSection = () => {
+ return (
+
+ {/* col 3: 활성 물류 현황 카드 */}
+
+ {/* 카드 헤더 */}
+
+
+ {/* 물류 아이템 목록 */}
+
+ {logisticsItems.map((item, index) => (
+
+
+

+
+
+
+ ))}
+
+
+ {/* 대응팀 배치 버튼 */}
+
+
+
+ {/* col 1-2 span: 실시간 텔레메트리 카드 */}
+
+ {/* 배경 파형 (opacity 0.3) */}
+
+

+
+
+ {/* 상단 콘텐츠 */}
+
+ {/* 제목 영역 */}
+
+
+
+ 실시간 텔레메트리
+
+
+ 선박 속도 오버레이
+
+
+

+
+
+ {/* 속도 수치 */}
+
+
+ 24.8
+
+
+ 노트 (knots)
+
+
+
+
+ {/* 하단 뱃지 + 버튼 */}
+
+ {/* 정상 가동중 뱃지 */}
+
+
+ {/* 대응팀 배치 아웃라인 버튼 */}
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/design/components/IconBadgeSection.tsx b/frontend/src/pages/design/components/IconBadgeSection.tsx
new file mode 100644
index 0000000..7a2bdbc
--- /dev/null
+++ b/frontend/src/pages/design/components/IconBadgeSection.tsx
@@ -0,0 +1,193 @@
+import wingCompGearIcon from '../../../assets/icons/wing-comp-gear.svg';
+import wingCompSearchIcon from '../../../assets/icons/wing-comp-search.svg';
+import wingCompCloseIcon from '../../../assets/icons/wing-comp-close.svg';
+import wingCompMenuIcon from '../../../assets/icons/wing-comp-menu.svg';
+
+interface IconButtonItem {
+ icon: string;
+ label: string;
+}
+
+interface StatusBadge {
+ label: string;
+ color: string;
+ bg: string;
+}
+
+interface DataTag {
+ label: string;
+ color: string;
+ dotColor: string;
+ bg: string;
+}
+
+const iconButtons: IconButtonItem[] = [
+ { icon: wingCompGearIcon, label: 'Settings' },
+ { icon: wingCompSearchIcon, label: 'Search' },
+ { icon: wingCompCloseIcon, label: 'Close' },
+ { icon: wingCompMenuIcon, label: 'Menu' },
+];
+
+const statusBadges: StatusBadge[] = [
+ { label: '정상', color: '#22c55e', bg: 'rgba(34,197,94,0.10)' },
+ { label: '주의', color: '#eab308', bg: 'rgba(234,179,8,0.10)' },
+ { label: '위험', color: '#ef4444', bg: 'rgba(239,68,68,0.10)' },
+ { label: '진행중', color: '#3b82f6', bg: 'rgba(59,130,246,0.10)' },
+ { label: '완료', color: '#8690a6', bg: 'rgba(134,144,166,0.10)' },
+];
+
+const dataTags: DataTag[] = [
+ { label: 'VESSEL_A', color: '#22c55e', dotColor: '#22c55e', bg: 'rgba(34,197,94,0.10)' },
+ { label: 'PRIORITY_H', color: '#eab308', dotColor: '#eab308', bg: 'rgba(234,179,8,0.10)' },
+ { label: 'CRITICAL_ERR', color: '#ef4444', dotColor: '#ef4444', bg: 'rgba(239,68,68,0.10)' },
+ { label: 'ACTIVE_SYNC', color: '#3b82f6', dotColor: '#3b82f6', bg: 'rgba(59,130,246,0.10)' },
+];
+
+export const IconBadgeSection = () => {
+ return (
+
+ {/* 좌측 카드: 제어 인터페이스 — 아이콘 버튼 */}
+
+ {/* 카드 헤더 */}
+
+
+ {/* 아이콘 버튼 목록 */}
+
+ {iconButtons.map((btn) => (
+
+
+

+
+
+
+ ))}
+
+
+ {/* 카드 푸터 */}
+
+
+ Standard dimensions: 36x36px with radius-md (6px)
+
+
+
+
+ {/* 우측 카드: 마이크로 컨트롤 — 뱃지 & 태그 */}
+
+ {/* 카드 헤더 */}
+
+
+ {/* 카드 바디 */}
+
+
+ {/* Operational Status 섹션 */}
+
+
+
+ Operational Status
+
+
+
+ {statusBadges.map((badge) => (
+
+ ))}
+
+
+
+ {/* Data Classification 섹션 */}
+
+
+
+ Data Classification
+
+
+
+ {dataTags.map((tag) => (
+
+ ))}
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/design/designTheme.ts b/frontend/src/pages/design/designTheme.ts
new file mode 100644
index 0000000..2328c1a
--- /dev/null
+++ b/frontend/src/pages/design/designTheme.ts
@@ -0,0 +1,380 @@
+// designTheme.ts — 디자인 시스템 페이지 다크/라이트 테마 정의
+
+export type ThemeMode = 'dark' | 'light';
+
+// ---------- 토큰 인터페이스 ----------
+export interface BgToken {
+ bg: string;
+ token: string;
+ hex: string;
+ desc: string;
+ isHover?: boolean;
+}
+
+export interface AccentToken {
+ color: string;
+ name: string;
+ token: string;
+ badge: string;
+ glow: string;
+ badgeBg: string;
+ badgeBorder: string;
+ badgeText: string;
+}
+
+export interface StatusToken {
+ color: string;
+ bg: string;
+ border: string;
+ label: string;
+ hex: string;
+ glow?: string;
+}
+
+export interface BorderToken {
+ token: string;
+ hex: string;
+ border: string;
+ barBg: string;
+}
+
+export interface TextTokenItem {
+ token: string;
+ sampleText: string;
+ sampleClass: string;
+ desc: string;
+ descColor: string;
+}
+
+// ---------- 테마 인터페이스 ----------
+export interface DesignTheme {
+ mode: ThemeMode;
+
+ // 레이아웃
+ pageBg: string;
+ sidebarBg: string;
+ sidebarBorder: string;
+ sidebarShadow: string;
+ headerBg: string;
+ headerBorder: string;
+
+ // 텍스트
+ textPrimary: string;
+ textSecondary: string;
+ textMuted: string;
+ textAccent: string;
+
+ // 카드
+ cardBg: string;
+ cardBorder: string;
+ cardBorderHover: string;
+ cardShadow: string;
+
+ // 섹션
+ sectionTitle: string;
+ sectionSub: string;
+ sectionSubSpacing: string;
+
+ // 테이블
+ tableContainerBg: string;
+ tableHeaderBg: string;
+ tableRowBorder: string;
+ tableDataRowBg: string;
+
+ // 뱃지
+ badgeRadius: string;
+ statusBadgeBg: string;
+ statusBadgeBorder: string;
+ statusBadgeDot: string;
+ statusBadgeText: string;
+ systemActiveBg: string;
+ systemActiveBorder: string;
+ systemActiveShadow: string;
+
+ // 폰트 뱃지 (06 타이포그래피)
+ fontBadgePrimaryBg: string;
+ fontBadgePrimaryText: string;
+ fontBadgeSecondaryBorder: string;
+ fontBadgeSecondaryText: string;
+
+ // 타이포 샘플 텍스트
+ typoSampleText: string;
+ typoSizeText: string;
+ typoPropertiesText: string;
+ typoActionBg: string;
+ typoActionBorder: string;
+ typoActionText: string;
+ typoDataText: string;
+ typoCoordText: string;
+
+ // 02 테두리 색상
+ borderCardBg: string;
+ borderCardShadow: string;
+
+ // 03 텍스트 색상
+ textSectionBg: string;
+ textSectionBorder: string;
+
+ // 07 radius
+ radiusSmLabel: string;
+ radiusMdLabel: string;
+ radiusCardBg: string;
+ radiusCardBorder: string;
+ radiusCardShadow: string;
+ radiusDescText: string;
+
+ // 푸터
+ footerBorder: string;
+ footerText: string;
+ footerAccent: string;
+
+ // 01 배경색 카드 스와치 border
+ swatchBorder: string;
+ swatchBorderHover: string;
+
+ // 데이터 토큰
+ bgTokens: BgToken[];
+ accentTokens: AccentToken[];
+ statusTokens: StatusToken[];
+ borderTokens: BorderToken[];
+ textTokens: TextTokenItem[];
+}
+
+// ---------- DARK 테마 ----------
+export const DARK_THEME: DesignTheme = {
+ mode: 'dark',
+
+ pageBg: '#0a0e1a',
+ sidebarBg: '#171b28',
+ sidebarBorder: 'rgba(255,255,255,0.05)',
+ sidebarShadow: 'rgba(0,0,0,0.4)',
+ headerBg: '#0a0e1a',
+ headerBorder: 'rgba(255,255,255,0.05)',
+
+ textPrimary: '#dfe2f3',
+ textSecondary: '#c2c6d6',
+ textMuted: '#8c909f',
+ textAccent: '#4cd7f6',
+
+ cardBg: '#171b28',
+ cardBorder: 'rgba(66,71,84,0.10)',
+ cardBorderHover: 'rgba(66,71,84,0.20)',
+ cardShadow: 'none',
+
+ sectionTitle: '#adc6ff',
+ sectionSub: '#8c909f',
+ sectionSubSpacing: '1px',
+
+ tableContainerBg: '#171b28',
+ tableHeaderBg: '#1b1f2c',
+ tableRowBorder: 'rgba(66,71,84,0.10)',
+ tableDataRowBg: 'rgba(10,14,26,0.50)',
+
+ badgeRadius: 'rounded-xl',
+ statusBadgeBg: 'transparent',
+ statusBadgeBorder: 'transparent',
+ statusBadgeDot: 'transparent',
+ statusBadgeText: 'transparent',
+ systemActiveBg: '#171b28',
+ systemActiveBorder: 'rgba(66,71,84,0.10)',
+ systemActiveShadow: '0px 0px 8px 0px rgba(76, 215, 246, 0.5)',
+
+ fontBadgePrimaryBg: '#edf0f7',
+ fontBadgePrimaryText: '#0a0e1a',
+ fontBadgeSecondaryBorder: 'rgba(66,71,84,0.30)',
+ fontBadgeSecondaryText: '#8c909f',
+
+ typoSampleText: '#c2c6d6',
+ typoSizeText: '#8c909f',
+ typoPropertiesText: '#c2c6d6',
+ typoActionBg: 'rgba(76,215,246,0.10)',
+ typoActionBorder: 'rgba(76,215,246,0.20)',
+ typoActionText: '#4cd7f6',
+ typoDataText: '#4cd7f6',
+ typoCoordText: '#8c909f',
+
+ borderCardBg: 'rgba(15,21,36,0.50)',
+ borderCardShadow: 'none',
+
+ textSectionBg: '#0a0e1a',
+ textSectionBorder: 'rgba(66,71,84,0.10)',
+
+ radiusSmLabel: 'radius-sm (6px)',
+ radiusMdLabel: 'radius-md (10px)',
+ radiusCardBg: '#171b28',
+ radiusCardBorder: 'rgba(66,71,84,0.20)',
+ radiusCardShadow: 'none',
+ radiusDescText: '#c2c6d6',
+
+ footerBorder: 'rgba(66,71,84,0.10)',
+ footerText: '#8c909f',
+ footerAccent: '#4cd7f6',
+
+ swatchBorder: 'rgba(255,255,255,0.05)',
+ swatchBorderHover: 'rgba(76,215,246,0.20)',
+
+ bgTokens: [
+ { bg: '#0a0e1a', token: 'bg-0', hex: '#0a0e1a', desc: 'Primary page canvas, deepest immersion layer.' },
+ { bg: '#0f1524', token: 'bg-1', hex: '#0f1524', desc: 'Surface Level 1: Sidebar containers and utility panels.' },
+ { bg: '#121929', token: 'bg-2', hex: '#121929', desc: 'Surface Level 2: Table headers and subtle sectional shifts.' },
+ { bg: '#1a2236', token: 'bg-3', hex: '#1a2236', desc: 'Surface Level 3: Elevated cards and floating elements.' },
+ { bg: '#1e2844', token: 'bg-hover', hex: '#1e2844', desc: 'Interactive states, list item highlighting.', isHover: true },
+ ],
+
+ accentTokens: [
+ {
+ color: '#06b6d4', name: 'Cyan Accent', token: 'primary', badge: 'Primary Action',
+ glow: '0px 0px 15px 0px rgba(6,182,212,0.4)',
+ badgeBg: 'rgba(6,182,212,0.10)', badgeBorder: 'rgba(6,182,212,0.25)', badgeText: '#06b6d4',
+ },
+ {
+ color: '#3b82f6', name: 'Blue Accent', token: 'secondary', badge: 'Information',
+ glow: '0px 0px 15px 0px rgba(59,130,246,0.3)',
+ badgeBg: 'rgba(59,130,246,0.10)', badgeBorder: 'rgba(59,130,246,0.25)', badgeText: '#3b82f6',
+ },
+ {
+ color: '#a855f7', name: 'Purple Accent', token: 'tertiary', badge: 'Operations',
+ glow: '0px 0px 15px 0px rgba(168,85,247,0.3)',
+ badgeBg: 'rgba(168,85,247,0.10)', badgeBorder: 'rgba(168,85,247,0.25)', badgeText: '#a855f7',
+ },
+ ],
+
+ statusTokens: [
+ { color: '#ef4444', bg: 'rgba(239,68,68,0.05)', border: 'rgba(239,68,68,0.20)', label: '위험 Critical', hex: '#ef4444', glow: '0px 0px 8px 0px rgba(239, 68, 68, 0.6)' },
+ { color: '#f97316', bg: 'rgba(249,115,22,0.05)', border: 'rgba(249,115,22,0.20)', label: '주의 Warning', hex: '#f97316' },
+ { color: '#eab308', bg: 'rgba(234,179,8,0.05)', border: 'rgba(234,179,8,0.20)', label: '경고 Caution', hex: '#eab308' },
+ { color: '#22c55e', bg: 'rgba(34,197,94,0.05)', border: 'rgba(34,197,94,0.20)', label: '정상 Normal', hex: '#22c55e' },
+ ],
+
+ borderTokens: [
+ { token: 'border', hex: '#1e2a42', border: '#1e2a42', barBg: '#1e2a42' },
+ { token: 'border-light', hex: '#2a3a5c', border: '#2a3a5c', barBg: '#2a3a5c' },
+ ],
+
+ textTokens: [
+ { token: 'text-1', sampleText: '주요 텍스트 Primary Text', sampleClass: 'text-[#edf0f7] font-korean text-[15px] font-bold', desc: 'Headings, active values, and primary labels.', descColor: 'rgba(237,240,247,0.60)' },
+ { token: 'text-2', sampleText: '보조 텍스트 Secondary Text', sampleClass: 'text-[#b0b8cc] font-korean text-[15px] font-medium', desc: 'Supporting labels and secondary information.', descColor: 'rgba(176,184,204,0.60)' },
+ { token: 'text-3', sampleText: '비활성 텍스트 Muted Text', sampleClass: 'text-[#8690a6] font-korean text-[15px]', desc: 'Disabled states, placeholders, and captions.', descColor: 'rgba(134,144,166,0.60)' },
+ ],
+};
+
+// ---------- LIGHT 테마 ----------
+export const LIGHT_THEME: DesignTheme = {
+ mode: 'light',
+
+ pageBg: '#f8fafc',
+ sidebarBg: '#ffffff',
+ sidebarBorder: '#e2e8f0',
+ sidebarShadow: 'rgba(0,0,0,0.05)',
+ headerBg: '#ffffff',
+ headerBorder: '#e2e8f0',
+
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ textAccent: '#06b6d4',
+
+ cardBg: '#ffffff',
+ cardBorder: '#e2e8f0',
+ cardBorderHover: 'rgba(6,182,212,0.20)',
+ cardShadow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
+
+ sectionTitle: '#1e293b',
+ sectionSub: '#94a3b8',
+ sectionSubSpacing: '-0.5px',
+
+ tableContainerBg: '#ffffff',
+ tableHeaderBg: '#f8fafc',
+ tableRowBorder: '#f1f5f9',
+ tableDataRowBg: 'rgba(248,250,252,0.50)',
+
+ badgeRadius: 'rounded-full',
+ statusBadgeBg: 'transparent',
+ statusBadgeBorder: 'transparent',
+ statusBadgeDot: 'transparent',
+ statusBadgeText: 'transparent',
+ systemActiveBg: '#ffffff',
+ systemActiveBorder: '#e2e8f0',
+ systemActiveShadow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
+
+ fontBadgePrimaryBg: '#0f172a',
+ fontBadgePrimaryText: '#ffffff',
+ fontBadgeSecondaryBorder: '#cbd5e1',
+ fontBadgeSecondaryText: '#64748b',
+
+ typoSampleText: '#64748b',
+ typoSizeText: '#0f172a',
+ typoPropertiesText: '#94a3b8',
+ typoActionBg: 'rgba(6,182,212,0.10)',
+ typoActionBorder: 'rgba(6,182,212,0.20)',
+ typoActionText: '#06b6d4',
+ typoDataText: '#06b6d4',
+ typoCoordText: '#94a3b8',
+
+ borderCardBg: '#ffffff',
+ borderCardShadow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
+
+ textSectionBg: '#ffffff',
+ textSectionBorder: '#e2e8f0',
+
+ radiusSmLabel: 'radius-sm (4px)',
+ radiusMdLabel: 'radius-md (8px)',
+ radiusCardBg: '#ffffff',
+ radiusCardBorder: '#e2e8f0',
+ radiusCardShadow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
+ radiusDescText: '#475569',
+
+ footerBorder: '#e2e8f0',
+ footerText: '#94a3b8',
+ footerAccent: '#06b6d4',
+
+ swatchBorder: '#e2e8f0',
+ swatchBorderHover: 'rgba(6,182,212,0.20)',
+
+ bgTokens: [
+ { bg: '#f8fafc', token: 'bg-0', hex: '#f8fafc', desc: 'Primary page canvas, lightest foundation layer.' },
+ { bg: '#ffffff', token: 'bg-1', hex: '#ffffff', desc: 'Surface Level 1: Sidebar containers and utility panels.' },
+ { bg: '#f1f5f9', token: 'bg-2', hex: '#f1f5f9', desc: 'Surface Level 2: Table headers and subtle sectional shifts.' },
+ { bg: '#e2e8f0', token: 'bg-3', hex: '#e2e8f0', desc: 'Surface Level 3: Elevated cards and floating elements.' },
+ { bg: '#cbd5e1', token: 'bg-hover', hex: '#cbd5e1', desc: 'Interactive states, list item highlighting.', isHover: true },
+ ],
+
+ accentTokens: [
+ {
+ color: '#06b6d4', name: 'Cyan Accent', token: 'primary', badge: 'Primary Action',
+ glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
+ badgeBg: 'rgba(6,182,212,0.10)', badgeBorder: 'rgba(6,182,212,0.25)', badgeText: '#06b6d4',
+ },
+ {
+ color: '#0891b2', name: 'Teal Accent', token: 'secondary', badge: 'Information',
+ glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
+ badgeBg: 'rgba(8,145,178,0.10)', badgeBorder: 'rgba(8,145,178,0.25)', badgeText: '#0891b2',
+ },
+ {
+ color: '#6366f1', name: 'Indigo Accent', token: 'tertiary', badge: 'Operations',
+ glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
+ badgeBg: 'rgba(99,102,241,0.10)', badgeBorder: 'rgba(99,102,241,0.25)', badgeText: '#6366f1',
+ },
+ ],
+
+ statusTokens: [
+ { color: '#dc2626', bg: '#fef2f2', border: '#fecaca', label: '위험 Critical', hex: '#f87171' },
+ { color: '#c2410c', bg: '#fff7ed', border: '#fed7aa', label: '주의 Warning', hex: '#fb923c' },
+ { color: '#b45309', bg: '#fffbeb', border: '#fde68a', label: '경고 Caution', hex: '#fbbf24' },
+ { color: '#047857', bg: '#ecfdf5', border: '#a7f3d0', label: '정상 Normal', hex: '#34d399' },
+ ],
+
+ borderTokens: [
+ { token: 'border', hex: '#cbd5e1', border: '#cbd5e1', barBg: '#cbd5e1' },
+ { token: 'border-light', hex: '#e2e8f0', border: '#e2e8f0', barBg: '#e2e8f0' },
+ ],
+
+ textTokens: [
+ { token: 'text-1', sampleText: '주요 텍스트 Primary Text', sampleClass: 'text-[#0f172a] font-korean text-[15px] 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', desc: 'Supporting labels and secondary information.', descColor: '#64748b' },
+ { token: 'text-3', sampleText: '비활성 텍스트 Muted Text', sampleClass: 'text-[#94a3b8] font-korean text-[15px]', desc: 'Disabled states, placeholders, and captions.', descColor: '#94a3b8' },
+ ],
+};
+
+export const getTheme = (mode: ThemeMode): DesignTheme =>
+ mode === 'dark' ? DARK_THEME : LIGHT_THEME;
diff --git a/frontend/src/tabs/scat/components/ScatMap.tsx b/frontend/src/tabs/scat/components/ScatMap.tsx
index 81eeb37..1693a21 100644
--- a/frontend/src/tabs/scat/components/ScatMap.tsx
+++ b/frontend/src/tabs/scat/components/ScatMap.tsx
@@ -6,7 +6,7 @@ import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { ScatSegment } from './scatTypes'
import type { ApiZoneItem } from '../services/scatApi'
-import { esiColor, jejuCoastCoords } from './scatConstants'
+import { esiColor } from './scatConstants'
import { hexToRgba } from '@common/components/map/mapUtils'
const BASE_STYLE: StyleSpecification = {
@@ -87,12 +87,17 @@ function getZoomScale(zoom: number) {
}
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
-function buildSegCoords(seg: ScatSegment, halfLenScale: number): [number, number][] {
- const coastIdx = seg.id % (jejuCoastCoords.length - 1)
- const [clat1, clng1] = jejuCoastCoords[coastIdx]
- const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
- const dlat = clat2 - clat1
- const dlng = clng2 - clng1
+// 인접 구간 좌표로 해안선 방향을 동적 계산
+function buildSegCoords(
+ seg: ScatSegment,
+ 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
@@ -126,21 +131,21 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
const zs = useMemo(() => getZoomScale(zoom), [zoom])
- // 제주도 해안선 레퍼런스 라인
- const coastlineLayer = useMemo(
- () =>
- new PathLayer({
- id: 'jeju-coastline',
- data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
- getPath: (d: { path: [number, number][] }) => d.path,
- getColor: [6, 182, 212, 46],
- getWidth: 1.5,
- getDashArray: [8, 6],
- dashJustified: true,
- widthMinPixels: 1,
- }),
- [],
- )
+ // 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
+ // const coastlineLayer = useMemo(
+ // () =>
+ // new PathLayer({
+ // id: 'jeju-coastline',
+ // data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
+ // getPath: (d: { path: [number, number][] }) => d.path,
+ // getColor: [6, 182, 212, 46],
+ // getWidth: 1.5,
+ // getDashArray: [8, 6],
+ // dashJustified: true,
+ // widthMinPixels: 1,
+ // }),
+ // [],
+ // )
// 선택된 구간 글로우 레이어
const glowLayer = useMemo(
@@ -148,7 +153,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
new PathLayer({
id: 'scat-glow',
data: [selectedSeg],
- getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
+ getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
getColor: [34, 197, 94, 38],
getWidth: zs.glowWidth,
capRounded: true,
@@ -159,7 +164,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
getWidth: [zs.glowWidth],
},
}),
- [selectedSeg, zs.glowWidth, zs.halfLenScale],
+ [selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
)
// ESI 색상 세그먼트 폴리라인
@@ -168,7 +173,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
new PathLayer({
id: 'scat-segments',
data: segments,
- getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
+ getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
getColor: (d: ScatSegment) => {
const isSelected = selectedSeg.id === d.id
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
@@ -234,10 +239,10 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
// 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[] = [coastlineLayer, glowLayer, segPathLayer]
+ const layers: any[] = [glowLayer, segPathLayer]
if (markerLayer) layers.push(markerLayer)
return layers
- }, [coastlineLayer, glowLayer, segPathLayer, markerLayer])
+ }, [glowLayer, segPathLayer, markerLayer])
const doneCount = segments.filter(s => s.status === '완료').length
const progCount = segments.filter(s => s.status === '진행중').length
diff --git a/frontend/src/tabs/scat/components/ScatPopup.tsx b/frontend/src/tabs/scat/components/ScatPopup.tsx
index 1677492..5405b07 100644
--- a/frontend/src/tabs/scat/components/ScatPopup.tsx
+++ b/frontend/src/tabs/scat/components/ScatPopup.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Map, useControl } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
-import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
+import { ScatterplotLayer } from '@deck.gl/layers'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { ScatDetail } from './scatTypes'
@@ -50,45 +50,22 @@ function PopupMap({
esi: string
onMapLoad?: () => void
}) {
- // 해안 구간 라인 (시뮬레이션) — [lng, lat] 순서
- const segLine: [number, number][] = [
- [lng - 0.004, lat - 0.002],
- [lng - 0.002, lat - 0.001],
- [lng, lat],
- [lng + 0.002, lat + 0.001],
- [lng + 0.004, lat + 0.002],
- ]
-
- // 조사 경로 라인
- const surveyRoute: [number, number][] = [
- [lng - 0.003, lat - 0.0015],
- [lng - 0.001, lat - 0.0005],
- [lng + 0.001, lat + 0.0005],
- [lng + 0.003, lat + 0.0015],
- ]
+ // 해안 구간 라인 / 조사 경로 — 하드코딩 방향이라 주석처리, 추후 실제 방향 데이터로 대체
+ // const segLine: [number, number][] = [
+ // [lng - 0.004, lat - 0.002],
+ // [lng - 0.002, lat - 0.001],
+ // [lng, lat],
+ // [lng + 0.002, lat + 0.001],
+ // [lng + 0.004, lat + 0.002],
+ // ]
+ // const surveyRoute: [number, number][] = [
+ // [lng - 0.003, lat - 0.0015],
+ // [lng - 0.001, lat - 0.0005],
+ // [lng + 0.001, lat + 0.0005],
+ // [lng + 0.003, lat + 0.0015],
+ // ]
const deckLayers = [
- // 조사 경로 (파란 점선)
- new PathLayer({
- id: 'survey-route',
- data: [{ path: surveyRoute }],
- getPath: (d: { path: [number, number][] }) => d.path,
- getColor: [59, 130, 246, 153],
- getWidth: 2,
- getDashArray: [6, 4],
- dashJustified: true,
- widthMinPixels: 1,
- }),
- // 해안 구간 라인 (ESI 색상)
- new PathLayer({
- id: 'seg-line',
- data: [{ path: segLine }],
- getPath: (d: { path: [number, number][] }) => d.path,
- getColor: hexToRgba(esiCol, 204),
- getWidth: 5,
- capRounded: true,
- widthMinPixels: 3,
- }),
// 접근 포인트 (노란 점)
new ScatterplotLayer({
id: 'access-point',
diff --git a/frontend/src/tabs/scat/components/ScatRightPanel.tsx b/frontend/src/tabs/scat/components/ScatRightPanel.tsx
index 967108a..a11e0c7 100644
--- a/frontend/src/tabs/scat/components/ScatRightPanel.tsx
+++ b/frontend/src/tabs/scat/components/ScatRightPanel.tsx
@@ -139,7 +139,7 @@ function DetailTab({ detail }: { detail: ScatDetail }) {
/* ═══ 탭 1: 현장 사진 ═══ */
function PhotoTab({ detail }: { detail: ScatDetail }) {
const [imgError, setImgError] = useState(false);
- const imgSrc = `/scat-img/${detail.code}-1.png`;
+ const imgSrc = `/scat/img/${detail.code}-1.png`;
if (imgError) {
return (
diff --git a/frontend/src/tabs/scat/components/scatConstants.ts b/frontend/src/tabs/scat/components/scatConstants.ts
index 142a9bc..07d41f1 100644
--- a/frontend/src/tabs/scat/components/scatConstants.ts
+++ b/frontend/src/tabs/scat/components/scatConstants.ts
@@ -26,9 +26,9 @@ export const statusColor: Record
= {
};
export const esiLevel = (n: number) => (n >= 8 ? 'h' : n >= 5 ? 'm' : 'l');
-// ═══ 제주도 해안선 좌표 (시계방향) ═══
+// ═══ 제주도 해안선 좌표 (시계방향) — 하드코딩 비활성화, 추후 DB 기반으로 대체 ═══
-export const jejuCoastCoords: [number, number][] = [
+/* export const jejuCoastCoords: [number, number][] = [
// 서부 (대정읍~한경면)
[33.28, 126.16],
[33.26, 126.18],
@@ -101,4 +101,4 @@ export const jejuCoastCoords: [number, number][] = [
[33.31, 126.19],
[33.3, 126.175],
[33.293, 126.162],
-];
+]; */
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
index 016b36c..0f9e9b7 100755
--- a/frontend/tsconfig.app.json
+++ b/frontend/tsconfig.app.json
@@ -28,7 +28,9 @@
"baseUrl": ".",
"paths": {
"@common/*": ["src/common/*"],
- "@tabs/*": ["src/tabs/*"]
+ "@tabs/*": ["src/tabs/*"],
+ "@pages/*": ["src/pages/*"],
+ "@/*": ["src/*"]
}
},
"include": ["src"]
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index db40627..d29e6f8 100755
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -38,6 +38,7 @@ export default defineConfig({
alias: {
'@common': path.resolve(__dirname, 'src/common'),
'@tabs': path.resolve(__dirname, 'src/tabs'),
+ '@': path.resolve(__dirname, 'src'),
},
},
build: {