feat(announcement): 공지 팝업 모듈 + Ocean 기본값 수정 #43

병합
htlee feature/announcement-popup 에서 develop 로 2 commits 를 머지했습니다 2026-02-21 00:13:20 +09:00
13개의 변경된 파일386개의 추가작업 그리고 54개의 파일을 삭제

파일 보기

@ -18,3 +18,4 @@
@import "./styles/components/auth.css";
@import "./styles/components/weather.css";
@import "./styles/components/weather-overlay.css";
@import "./styles/components/announcement.css";

파일 보기

@ -0,0 +1,129 @@
/* ── Announcement modal ─────────────────────────────────────────── */
.announcement-modal {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
}
.announcement-modal__content {
position: relative;
width: min(88vw, 480px);
max-height: min(85vh, 560px);
display: flex;
flex-direction: column;
background: var(--wing-glass-dense);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.announcement-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--wing-subtle);
}
.announcement-modal__title {
font-size: 14px;
font-weight: 700;
color: var(--text);
}
.announcement-modal__close {
background: none;
border: none;
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
}
.announcement-modal__close:hover {
color: var(--text);
}
.announcement-modal__body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.announcement-modal__section {
margin-bottom: 16px;
}
.announcement-modal__section:last-child {
margin-bottom: 0;
}
.announcement-modal__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.announcement-modal__section-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.announcement-modal__date {
font-size: 10px;
color: var(--muted);
}
.announcement-modal__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.announcement-modal__item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 8px 10px;
background: var(--wing-card-alpha);
border: 1px solid var(--wing-subtle);
border-radius: 8px;
}
.announcement-modal__icon {
font-size: 18px;
flex-shrink: 0;
line-height: 1.2;
}
.announcement-modal__item-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
margin-bottom: 2px;
}
.announcement-modal__item-desc {
font-size: 10px;
color: var(--muted);
line-height: 1.5;
}
.announcement-modal__footer {
display: flex;
justify-content: center;
padding: 12px 16px;
border-top: 1px solid var(--wing-subtle);
}

파일 보기

@ -0,0 +1,34 @@
import type { Announcement } from '../model/types';
/**
* .
* , API로 api/ .
* id는 ( id + 1).
*/
export const ANNOUNCEMENTS: Announcement[] = [
{
id: 1,
title: 'Wing Fleet Dashboard 업데이트',
date: '2026-02-20',
items: [
{
icon: '🌊',
title: 'Ocean 전용 지도',
description: 'MapTiler Ocean 스타일 기반의 해양 특화 지도가 추가되었습니다. 수심 색상, 등심선, 해저 지형명을 설정 패널에서 세부 조정할 수 있습니다.',
},
{
icon: '🎥',
title: '자유시점 모드',
description: '지도 표시 설정의 "자유 시점" 토글로 카메라 회전/틸트를 허용할 수 있습니다. 평면지도와 3D지도에서 각각 독립적으로 설정됩니다.',
},
{
icon: '📷',
title: '선박 사진 조회',
description: '지도 위 선박을 클릭하면 사진이 있는 선박의 이미지를 갤러리로 확인할 수 있습니다.',
},
],
},
];
/** 현재 최신 공지 ID */
export const LATEST_ANNOUNCEMENT_ID = ANNOUNCEMENTS[ANNOUNCEMENTS.length - 1]?.id ?? 0;

파일 보기

@ -0,0 +1,33 @@
import { useCallback, useMemo } from 'react';
import { usePersistedState } from '../../../shared/hooks';
import { ANNOUNCEMENTS, LATEST_ANNOUNCEMENT_ID } from '../data/announcements';
/**
* .
*
* localStorage : `wing:${userId}:lastSeenAnnouncementId`
* (LATEST_ANNOUNCEMENT_ID) .
*/
export function useAnnouncementPopup(userId: number | null) {
const [lastSeenId, setLastSeenId] = usePersistedState<number>(
userId,
'lastSeenAnnouncementId',
0,
);
/** 미확인 공지가 있는지 여부 */
const hasUnread = LATEST_ANNOUNCEMENT_ID > lastSeenId;
/** 미확인 공지 목록 */
const unreadAnnouncements = useMemo(
() => ANNOUNCEMENTS.filter((a) => a.id > lastSeenId),
[lastSeenId],
);
/** "확인" 버튼 클릭 시 호출 — 최신 공지 ID를 저장 */
const acknowledge = useCallback(() => {
setLastSeenId(LATEST_ANNOUNCEMENT_ID);
}, [setLastSeenId]);
return { hasUnread, unreadAnnouncements, acknowledge };
}

파일 보기

@ -0,0 +1,4 @@
export type { Announcement, AnnouncementItem } from './model/types';
export { ANNOUNCEMENTS, LATEST_ANNOUNCEMENT_ID } from './data/announcements';
export { useAnnouncementPopup } from './hooks/useAnnouncementPopup';
export { AnnouncementModal } from './ui/AnnouncementModal';

파일 보기

@ -0,0 +1,21 @@
/** 공지 항목 하나 */
export interface AnnouncementItem {
/** 아이콘 (emoji) */
icon: string;
/** 항목 제목 */
title: string;
/** 항목 상세 설명 */
description?: string;
}
/** 공지 전체 구조 */
export interface Announcement {
/** 고유 식별자 — 단조 증가 (향후 서버 PK와 매핑) */
id: number;
/** 공지 제목 (모달 헤더) */
title: string;
/** 표시용 날짜 */
date: string;
/** 공지 항목 리스트 */
items: AnnouncementItem[];
}

파일 보기

@ -0,0 +1,66 @@
import { useEffect } from 'react';
import { Button } from '@wing/ui';
import type { Announcement } from '../model/types';
interface AnnouncementModalProps {
announcements: Announcement[];
onConfirm: () => void;
}
export function AnnouncementModal({ announcements, onConfirm }: AnnouncementModalProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onConfirm]);
return (
<div className="announcement-modal" onClick={onConfirm}>
<div className="announcement-modal__content" onClick={(e) => e.stopPropagation()}>
<div className="announcement-modal__header">
<span className="announcement-modal__title"> </span>
<button
className="announcement-modal__close"
onClick={onConfirm}
type="button"
aria-label="닫기"
>
</button>
</div>
<div className="announcement-modal__body">
{announcements.map((announcement) => (
<div key={announcement.id} className="announcement-modal__section">
<div className="announcement-modal__section-header">
<span className="announcement-modal__section-title">{announcement.title}</span>
<span className="announcement-modal__date">{announcement.date}</span>
</div>
<ul className="announcement-modal__list">
{announcement.items.map((item, idx) => (
<li key={idx} className="announcement-modal__item">
<span className="announcement-modal__icon">{item.icon}</span>
<div>
<div className="announcement-modal__item-title">{item.title}</div>
{item.description && (
<div className="announcement-modal__item-desc">{item.description}</div>
)}
</div>
</li>
))}
</ul>
</div>
))}
</div>
<div className="announcement-modal__footer">
<Button variant="primary" size="md" onClick={onConfirm}>
</Button>
</div>
</div>
</div>
);
}

파일 보기

@ -1,5 +1,5 @@
export type { OceanMapSettings, OceanDepthStop, OceanLabelLanguage, OceanDepthLabelSize } from './model/types';
export { DEFAULT_OCEAN_MAP_SETTINGS } from './model/types';
export { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from './model/types';
export { resolveOceanStyle } from './lib/resolveOceanStyle';
export { discoverOceanLayers } from './lib/oceanLayerIds';
export { useOceanMapSettings } from './hooks/useOceanMapSettings';

파일 보기

@ -38,40 +38,53 @@ export interface OceanMapSettings {
labelLanguage: OceanLabelLanguage;
}
/**
* Ocean (12).
* "커스텀" .
* Ocean .
*/
export const OCEAN_PRESET_DEPTH_STOPS: OceanDepthStop[] = [
{ depth: -11000, color: '#0a0e2a' },
{ depth: -8000, color: '#0c1836' },
{ depth: -6000, color: '#0e2444' },
{ depth: -4000, color: '#103252' },
{ depth: -2000, color: '#134060' },
{ depth: -1000, color: '#175070' },
{ depth: -200, color: '#1c6480' },
{ depth: -100, color: '#217890' },
{ depth: -50, color: '#288da0' },
{ depth: -20, color: '#30a2b0' },
{ depth: -10, color: '#3ab5be' },
{ depth: 0, color: '#48c8cc' },
];
/**
* Ocean .
* depthStops가 Ocean .
* depthStops에 .
*/
export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = {
depthStops: [
{ depth: -11000, color: '#000308' },
{ depth: -8000, color: '#010610' },
{ depth: -6000, color: '#020a18' },
{ depth: -4000, color: '#030c1c' },
{ depth: -2000, color: '#041022' },
{ depth: -1000, color: '#051529' },
{ depth: -500, color: '#061a30' },
{ depth: -200, color: '#071f36' },
{ depth: -100, color: '#08263d' },
{ depth: -50, color: '#0e3d5e' },
{ depth: -20, color: '#145578' },
{ depth: 0, color: '#2097a6' },
],
depthOpacity: 0.88,
// 빈 배열 = Ocean 스타일 네이티브 색상 사용 (커스텀 안 함)
depthStops: [],
depthOpacity: 1,
contourVisible: true,
contourColor: '#ffffff',
contourOpacity: 0.2,
contourWidth: 0.8,
contourColor: '#4a90c2',
contourOpacity: 0.15,
contourWidth: 0.6,
depthLabelsVisible: true,
depthLabelColor: '#e2e8f0',
depthLabelColor: '#a0c4e8',
depthLabelSize: 'medium',
hillshadeVisible: true,
hillshadeExaggeration: 0.5,
hillshadeColor: '#000020',
hillshadeColor: '#0a1628',
landformLabelsVisible: true,
landformLabelColor: '#94a3b8',
landformLabelColor: '#7ea8c8',
backgroundColor: '#010610',
backgroundColor: '#0b1a2e',
labelLanguage: 'ko',
};

파일 보기

@ -1,6 +1,6 @@
import { useState } from 'react';
import type { OceanMapSettings, OceanLabelLanguage, OceanDepthLabelSize, OceanDepthStop } from '../model/types';
import { DEFAULT_OCEAN_MAP_SETTINGS } from '../model/types';
import { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from '../model/types';
interface OceanMapSettingsPanelProps {
value: OceanMapSettings;
@ -114,33 +114,57 @@ export function OceanMapSettingsPanel({ value, onChange }: OceanMapSettingsPanel
<div className="ms-section">
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
onClick={toggleAutoGradient}
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
>
<span style={{ display: 'flex', gap: 4 }}>
{value.depthStops.length > 0 && (
<span
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
onClick={toggleAutoGradient}
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
>
</span>
)}
<span
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${value.depthStops.length > 0 ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
onClick={() => {
if (value.depthStops.length > 0) {
update('depthStops', []);
setAutoGradient(false);
} else {
update('depthStops', OCEAN_PRESET_DEPTH_STOPS);
}
}}
title={value.depthStops.length > 0 ? 'Ocean 스타일 네이티브 색상으로 복원' : '수심 구간별 색상을 직접 지정합니다'}
>
{value.depthStops.length > 0 ? '기본값' : '커스텀'}
</span>
</span>
</div>
{DISPLAY_DEPTH_INDICES.map((idx) => {
const stop = value.depthStops[idx];
if (!stop) return null;
const isEdge = idx === 0 || idx === value.depthStops.length - 1;
const dimmed = autoGradient && !isEdge;
return (
<div className="ms-row" key={stop.depth} style={dimmed ? { opacity: 0.5 } : undefined}>
<span className="ms-depth-label">{depthLabel(stop.depth)}</span>
<input
type="color"
className="ms-color-input"
value={stop.color}
onChange={(e) => updateDepthStop(idx, e.target.value)}
disabled={dimmed}
/>
<span className="ms-hex">{stop.color}</span>
</div>
);
})}
{value.depthStops.length === 0 ? (
<div className="ms-row" style={{ fontSize: 9, color: 'var(--muted)' }}>
</div>
) : (
DISPLAY_DEPTH_INDICES.map((idx) => {
const stop = value.depthStops[idx];
if (!stop) return null;
const isEdge = idx === 0 || idx === value.depthStops.length - 1;
const dimmed = autoGradient && !isEdge;
return (
<div className="ms-row" key={stop.depth} style={dimmed ? { opacity: 0.5 } : undefined}>
<span className="ms-depth-label">{depthLabel(stop.depth)}</span>
<input
type="color"
className="ms-color-input"
value={stop.color}
onChange={(e) => updateDepthStop(idx, e.target.value)}
disabled={dimmed}
/>
<span className="ms-hex">{stop.color}</span>
</div>
);
})
)}
</div>
{/* ── Depth opacity ─────────────────────────── */}

파일 보기

@ -29,6 +29,7 @@ import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
import {
buildLegacyHitMap,
computeCountsByType,
@ -62,6 +63,9 @@ export function DashboardPage() {
const uid = user?.id ?? null;
const isDevMode = user?.name?.includes('(DEV)') ?? false;
// ── Announcement popup ──
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
// ── Data fetching ──
const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels();
@ -435,6 +439,9 @@ export function DashboardPage() {
) : selectedTarget ? (
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} />
) : null}
{hasUnread && (
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
)}
{imageModal && (
<ShipImageModal
images={imageModal.images}

파일 보기

@ -579,9 +579,9 @@ export function useGlobeShipLayers(
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
'rgba(226,232,240,0.92)',
] as never,
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2,
'text-halo-blur': 0.8,
'text-halo-color': 'rgba(0,0,0,0.9)',
'text-halo-width': 0.8,
'text-halo-blur': 0.2,
},
} as unknown as LayerSpecification,
undefined,

파일 보기

@ -489,8 +489,8 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
characterSet: 'auto',
getPixelOffset: [0, 16],
getTextAnchor: 'middle',
outlineWidth: 2,
outlineColor: [2, 6, 23, 217],
outlineWidth: 1,
outlineColor: [0, 0, 0, 230],
}),
);
}