diff --git a/public/css/base.css b/public/css/base.css index 4bff293e..66e5627a 100644 --- a/public/css/base.css +++ b/public/css/base.css @@ -303,7 +303,7 @@ select{ appearance: none; -webkit-appearance: none; -moz-appearance: none; - background:var(--secondary2) url('/images/ico_select.svg')no-repeat right 1.2rem center / 1.2rem; + background:var(--secondary2) url('../images/ico_select.svg')no-repeat right 1.2rem center / 1.2rem; border: 1px solid var(--secondary4); width: 100%; height: 4rem; @@ -315,25 +315,25 @@ select{ text-overflow: ellipsis;white-space: nowrap; max-width: 100%; overflow: hidden; } -input, input:disabled{ +input:not([type="range"]), input:not([type="range"]):disabled{ -webkit-appearance: none; appearance: none; -webkit-text-fill-color: inherit; - opacity: 1; + opacity: 1; } -input{ - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - width: 100%; - height: 4rem; +input:not([type="range"]){ + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 100%; + height: 4rem; font-family: 'NanumSquare', sans-serif; background-color: var(--secondary2); border: 1px solid var(--secondary4); - font-size: var(--fs-m); + font-size: var(--fs-m); color: var(--white); font-weight: var(--fw-regular); - padding: 0 1.2rem; + padding: 0 1.2rem; } input::placeholder { color:rgba(var(--white-rgb),.3); } /* Chrome, Safari, Opera, Microsoft Edge */ @@ -403,8 +403,8 @@ input:disabled {pointer-events: none;} /* checkBox */ .checkbox {display:flex;align-items:center;position:relative;cursor:pointer;} -.checkbox input + span::after {content:"";position:absolute;top:50%;right:0;transform:translateY(-50%);width:3.2rem;height:3.2rem;background:url('/images/ico_check_off.svg') no-repeat center/contain;} -.checkbox input:checked + span::after {content:"";position:absolute;top:50%;right:0;transform:translateY(-50%);background:url('/images/ico_check_on2.svg') no-repeat center/contain;} +.checkbox input + span::after {content:"";position:absolute;top:50%;right:0;transform:translateY(-50%);width:3.2rem;height:3.2rem;background:url('../images/ico_check_off.svg') no-repeat center/contain;} +.checkbox input:checked + span::after {content:"";position:absolute;top:50%;right:0;transform:translateY(-50%);background:url('../images/ico_check_on2.svg') no-repeat center/contain;} .checkbox:focus-within input:focus-visible + span::after {outline: 2px solid var(--white);outline-offset: 2px;border-radius: .4rem;} .checkL {gap: .6rem;} @@ -430,8 +430,8 @@ input[type="radio"] { } /* radio - 라디오버튼 오른쪽 */ .radio {display:flex;align-items:center;position:relative;cursor:pointer;} -.radio input + span::after {content:"";position:absolute;top:50%;right:0;transform:translateY(-50%);width:2rem;height:2rem;background:url('/images/ico_radio_off.svg') no-repeat center/contain;} -.radio input:checked + span::after {content:"";position:absolute;top:50%;right:0;transform:translateY(-50%);background:url('/images/ico_radio_on.svg') no-repeat center/contain;} +.radio input + span::after {content:"";position:absolute;top:50%;right:0;transform:translateY(-50%);width:2rem;height:2rem;background:url('../images/ico_radio_off.svg') no-repeat center/contain;} +.radio input:checked + span::after {content:"";position:absolute;top:50%;right:0;transform:translateY(-50%);background:url('../images/ico_radio_on.svg') no-repeat center/contain;} .radio:focus-within input:focus-visible + span::after {outline: 2px solid var(--white);outline-offset: 2px;border-radius: .4rem;} /* radio - 라디오버튼 왼쪽 */ .radioL {flex-direction: row-reverse;} @@ -452,8 +452,8 @@ input[type="radio"] { .numInput input {padding-right: 2rem;} .numInput .spin {position: absolute; top: 50%; right: .5rem;transform: translateY(-50%); display: flex; flex-direction: column;} .numInput .spin button{width: 1.7rem; height: 1.1rem; cursor: pointer;} -.numInput .spin .spinUp {background: url(/images/ico_spinup.svg)no-repeat center / contain;} -.numInput .spin .spinDown {background: url(/images/ico_spindown.svg)no-repeat center / contain;} +.numInput .spin .spinUp {background: url(../images/ico_spinup.svg)no-repeat center / contain;} +.numInput .spin .spinDown {background: url(../images/ico_spindown.svg)no-repeat center / contain;} /* switch */ .switch { diff --git a/public/css/common.css b/public/css/common.css index 8cc23791..6f7b459c 100644 --- a/public/css/common.css +++ b/public/css/common.css @@ -16,10 +16,10 @@ .deep {background-color: var(--secondary1);} .btnToggle {display: inline-flex;gap: .6rem;} -.btnToggle::after {content: '';width: 1.8rem;height: 1.8rem;background: url('/images/ico_arrow_down_s.svg') no-repeat center / contain;flex-shrink: 0;transition: transform .2s ease;} +.btnToggle::after {content: '';width: 1.8rem;height: 1.8rem;background: url('../images/ico_arrow_down_s.svg') no-repeat center / contain;flex-shrink: 0;transition: transform .2s ease;} .btnToggle.is-open::after {transform: rotate(180deg);} .schBtn {width: 8rem; height: 3.5rem;display:flex;align-items:center; justify-content:center;font-size: var(--fs-ms);font-weight: var(--fw-bold); background-color: var(--primary1); color: var(--white);} -.schBtn::before {content: "";display: inline-block; width: 1.4rem; height: 1.4rem; margin-right: 1rem;background:url('/images/ico_btnsch.svg') no-repeat center center/ 1.4rem; } +.schBtn::before {content: "";display: inline-block; width: 1.4rem; height: 1.4rem; margin-right: 1rem;background:url('../images/ico_btnsch.svg') no-repeat center center/ 1.4rem; } .btnMap {width: 2.8rem; height: 2.8rem; background: url(../images/ico_btn_map.svg) no-repeat center / contain;} @@ -70,7 +70,7 @@ /* UI-input */ .schInput{height: 3.5rem; font-family: 'NanumSquare', sans-serif;font-size: var(--fs-m); color: var(--white);background-color: var(--tertiary1);padding: 0 1.2rem; border: 0;} .schInput::placeholder { color:rgba(var(--white-rgb),.3); } -.dateInput {background:url(/images/ico_input_cal.svg) no-repeat center right .5rem/2.4rem; padding-right: 3rem; cursor: pointer;} +.dateInput {background:url(../images/ico_input_cal.svg) no-repeat center right .5rem/2.4rem; padding-right: 3rem; cursor: pointer;} .dateInput::placeholder { color:var(--white); } /* ========================= @@ -122,7 +122,7 @@ .acdList.input > li.input label input {flex: 1;} .acdList.check > li.check {border-color: rgba(var(--secondary4-rgb),.3); } -.toggleListBtn {display: flex;align-items: center;width: 2.4rem;height: 2.4rem;background: url('/images/ico_arrow_toggle_down.svg') no-repeat center / contain;transition: transform 0.3s ease;} +.toggleListBtn {display: flex;align-items: center;width: 2.4rem;height: 2.4rem;background: url('../images/ico_arrow_toggle_down.svg') no-repeat center / contain;transition: transform 0.3s ease;} .toggleListBtn.open {transform: rotate(180deg);} /* 사이드패널 - 상세정보 박스 */ @@ -136,9 +136,9 @@ .detailBox > li.dbHeader .name {font-size: var(--fs-ml); font-weight:var(--fw-bold);} .detailBox > li.dbHeader .type {font-size: var(--fs-ml); font-weight:var(--fw-bold);color:var(--primary1);} .detailBox > li.dbHeader .num {font-size: var(--fs-m); font-weight:var(--fw-bold);} -.detailBox > li.dbHeader .icoArrow {width:2rem;height:2rem;background:url('/images/ico_arrow_right_rnd_off.svg') no-repeat center/contain; margin-left: .5rem;} +.detailBox > li.dbHeader .icoArrow {width:2rem;height:2rem;background:url('../images/ico_arrow_right_rnd_off.svg') no-repeat center/contain; margin-left: .5rem;} .detailBox:has(.icoArrow:hover) {border: solid 2px var(--primary1);} -.detailBox > li.dbHeader .icoArrow:hover {background:url('/images/ico_arrow_right_rnd_on.svg') no-repeat center/contain;} +.detailBox > li.dbHeader .icoArrow:hover {background:url('../images/ico_arrow_right_rnd_on.svg') no-repeat center/contain;} .detailBox > li > span {display: flex; color: var(--white); min-height: 2.1rem;} .detailBox > li > .tit { font-size: var(--fs-ml);font-weight: var(--fw-bold);} .detailBox > li > .label{min-width:9rem;flex-shrink: 0; font-weight:var(--fw-regular);} @@ -178,12 +178,12 @@ ========================= */ .toastContainer {position: fixed; bottom: 1.5rem; right: 5rem; display: flex; flex-direction: column; gap: .7rem;} .toast {display: flex; justify-content: space-between; width: 56rem; height: 5rem; padding: 1rem 1rem 1rem 4.5rem;border-radius: .4rem;} -.toastWarining {background: #8B6600 url(/images/ico_toast_warning.svg)no-repeat top 1rem left 1.4rem /2.4rem;border: 1px solid #FFBC02;} -.toastCaution {background: #750000 url(/images/ico_toast_caution.svg)no-repeat top 1rem left 1.4rem /2.4rem;border: 1px solid #FF0000;} +.toastWarining {background: #8B6600 url(../images/ico_toast_warning.svg)no-repeat top 1rem left 1.4rem /2.4rem;border: 1px solid #FFBC02;} +.toastCaution {background: #750000 url(../images/ico_toast_caution.svg)no-repeat top 1rem left 1.4rem /2.4rem;border: 1px solid #FF0000;} .toast .toastMsg {font-size: var(--fs-ml);color: var(--white); font-weight: var(--fw-bold); } .toast .toastR {display: flex; align-items: center;gap: 1rem;} .toast .toastR .toastAction {font-size: var(--fs-s);color: var(--white); font-weight: var(--fw-regular);text-decoration: underline;} -.toast .toastR .toastClose {width: 2.4rem;height: 2.4rem; background: url(/images/ico_toast_close.svg)no-repeat center /contain;} +.toast .toastR .toastClose {width: 2.4rem;height: 2.4rem; background: url(../images/ico_toast_close.svg)no-repeat center /contain;} /* */ .cicle {width: 1.6rem; height: 1.6rem; border-radius: 50%; } .default {background-color: var(--primary1);} @@ -196,19 +196,19 @@ .popupMap {display: flex;flex-direction: column;position: absolute;top: 40%; left: 70%;transform: translate(-50%, -50%); width: auto;height: auto; background-color: var(--gray-scale3); z-index: 80;} .popupMap > .pmHeader {display: flex;justify-content: space-between; align-items: center; height: 4rem; padding: .5rem 1rem;} .popupMap > .pmHeader > .rowL{ display: flex;align-items: center; gap: .5rem;} -.popupMap > .pmHeader > .rowL > .shipType {width: 2.4rem; height: 2.4rem; background:var(--gray-scale2) url(/images/ico_ship_type_fising.svg)no-repeat center / 2.4rem;} +.popupMap > .pmHeader > .rowL > .shipType {width: 2.4rem; height: 2.4rem; background:var(--gray-scale2) url(../images/ico_ship_type_fising.svg)no-repeat center / 2.4rem;} .popupMap > .pmBody {display: flex; flex-direction: column; padding: .8rem 1.2rem .8rem 1.2rem} .popupMap > .pmFooter {display: flex; flex-direction: column; align-items: flex-start; justify-content: center; height: 3.2rem; border-top: 1px solid var(--gray-scale5); padding: 0 1rem;font-size: var(--fs-xs); color: var(--gray-scaleB);} /* 선박정보팝업 */ .popupMap.shipInfo { width: 29rem;} .popupMap.shipInfo > .pmHeader > .rowL > .shipName {font-size: var(--fs-ml); font-weight:var(--fw-bold);color: var(--gray-scaleD);} .popupMap.shipInfo > .pmHeader > .rowL > .shipNum {font-size: var(--fs-m); font-weight:var(--fw-regular);color: var(--gray-scaleB);} -.popupMap.shipInfo > .pmHeader > .pmClose {width: 2.4rem;height: 2.4rem; background: url(/images/ico_ship_info_close.svg)no-repeat center /contain;} +.popupMap.shipInfo > .pmHeader > .pmClose {width: 2.4rem;height: 2.4rem; background: url(../images/ico_ship_info_close.svg)no-repeat center /contain;} /* 기상관측팝업 */ .popupMap.osbInfo { width: 22rem; left: 45%;} .popupMap.osbInfo > .pmHeader > .rowL > .title {font-size: var(--fs-l); font-weight:var(--fw-bold);} -.popupMap.osbInfo > .pmHeader > .pmClose {width: 2.4rem;height: 2.4rem; background: url(/images/ico_ship_info_close.svg)no-repeat center /contain;} +.popupMap.osbInfo > .pmHeader > .pmClose {width: 2.4rem;height: 2.4rem; background: url(../images/ico_ship_info_close.svg)no-repeat center /contain;} /* popup-기상관측상태 */ .osbStatus {display: flex; flex-direction: column; gap: .6rem;} .osbStatus li.date {border-radius: .5rem; background-color: #61666F; padding: 1rem; font-weight: var(--fw-bold);} @@ -221,14 +221,14 @@ .pmGallery .galleryView{ width: 100%; height: 100%;} .pmGallery .galleryView img{ width: 100%; height: 100%;object-fit: cover;} .pmGallery .navBtn { position: absolute; width: 3.4rem; height: 3.4rem; top: 0; bottom: 0; margin: auto 0; display: flex; align-items: center; justify-content: center;} -.pmGallery .prev {background: url(/images/ico_arrow_left_big.svg) no-repeat center / 3.4rem; left: 0;} -.pmGallery .next {background: url(/images/ico_arrow_right_big.svg) no-repeat center / 3.4rem; right: 0;} +.pmGallery .prev {background: url(../images/ico_arrow_left_big.svg) no-repeat center / 3.4rem; left: 0;} +.pmGallery .next {background: url(../images/ico_arrow_right_big.svg) no-repeat center / 3.4rem; right: 0;} /* popup-Action */ .shipAction {display: flex; align-items: center; justify-content: space-between;} .shipAction .detailBtn {position: relative; display: inline-flex; align-items: center; justify-content: center; width: 8rem; height: 2.8rem; background-color: var(--gray-scale6); color: var(--white); font-size: var(--fs-xs); font-weight: var(--fw-bold); padding-right: 2rem;} -.shipAction .detailBtn::after {content: "";position: absolute; width: 2rem; height: 2rem; top: 50%; right: 0.5rem;transform: translateY(-50%);background: url("/images/ico_cate.svg") no-repeat center / contain;} -.shipAction .favBtn {display: flex;align-items: center; justify-content: center; width: 2.8rem; height: 2.8rem; background: url(/images/ico_star.svg)no-repeat center / 2rem; background-color: var(--gray-scale5);} +.shipAction .detailBtn::after {content: "";position: absolute; width: 2rem; height: 2rem; top: 50%; right: 0.5rem;transform: translateY(-50%);background: url("../images/ico_cate.svg") no-repeat center / contain;} +.shipAction .favBtn {display: flex;align-items: center; justify-content: center; width: 2.8rem; height: 2.8rem; background: url(../images/ico_star.svg)no-repeat center / 2rem; background-color: var(--gray-scale5);} .shipAction .shipTypeIco {display: flex; align-items: center; gap: .2rem; padding: 0 1rem;} .shipAction .shipTypeIco li {display: flex; justify-content: center; align-items: center; width: 1.7rem; height: 1.7rem;border-radius: .2rem; background-color: var(--gray-scale5);color: var(--white); font-weight: var(--fw-heavy);font-size: var(--fs-xs);} @@ -257,8 +257,8 @@ .shipStatus li.schedule {justify-content:space-between; background-color: var(--gray-scale2); padding: .5rem 1rem; border-top: 1px solid var(--gray-scale5);border-bottom: 1px solid var(--gray-scale5);} .shipStatus .depart, .shipStatus .arrive {font-size: var(--fs-xs); color: var(--gray-scaleD);font-weight:var(--fw-bold);padding-left: 1.7rem;} -.shipStatus .depart {background: url(/images/ico_clock1.svg)no-repeat center left / 1.2rem;} -.shipStatus .arrive {background: url(/images/ico_clock2.svg)no-repeat center left / 1.2rem;} +.shipStatus .depart {background: url(../images/ico_clock1.svg)no-repeat center left / 1.2rem;} +.shipStatus .arrive {background: url(../images/ico_clock2.svg)no-repeat center left / 1.2rem;} .shipStatus .scheduleDate {font-size: var(--fs-xs); color: var(--gray-scaleB);} .shipStatus li.status { display: grid;grid-template-columns: 1fr 13rem 1fr;align-items: stretch;border-bottom: 1px solid var(--gray-scale5);} .shipStatus li.status .statusItem { display: flex; flex-direction: column; align-items: flex-start; position: relative; padding:.5rem 1rem;} @@ -296,7 +296,7 @@ .popupUtill {display: flex; flex-direction: column; width: 52.5rem; height:auto;max-height: 80vh;overflow: hidden; background-color: var(--secondary2); border: .1rem solid var(--secondary3); padding:2.5rem 3rem;} .popupUtill > .puHeader {display: flex; justify-content: space-between; align-items: center; padding-bottom: 2rem;} .popupUtill > .puHeader > .title {font-weight: var(--fw-bold); font-size: var(--fs-xl);} -.popupUtill > .puHeader > .puClose {width: 2.2rem;height: 2.2rem; background: url(/images/ico_pop_utill_close.svg)no-repeat center /contain;} +.popupUtill > .puHeader > .puClose {width: 2.2rem;height: 2.2rem; background: url(../images/ico_pop_utill_close.svg)no-repeat center /contain;} .popupUtill > .puBody {flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 1rem;} .popupUtill > .puFooter { margin-top: auto; padding-top: 1rem;} diff --git a/src/hooks/useShipLayer.js b/src/hooks/useShipLayer.js index da2679db..80c05716 100644 --- a/src/hooks/useShipLayer.js +++ b/src/hooks/useShipLayer.js @@ -14,6 +14,7 @@ import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer'; import useShipStore from '../stores/shipStore'; import { useTrackQueryStore } from '../tracking/stores/trackQueryStore'; import useTrackingModeStore from '../stores/trackingModeStore'; +import { useMapStore } from '../stores/mapStore'; import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils'; import { getReplayLayers } from '../replay/utils/replayLayerRegistry'; import { shipBatchRenderer } from '../map/ShipBatchRenderer'; @@ -58,6 +59,7 @@ export default function useShipLayer(map) { controller: false, layers: [], useDevicePixels: true, + pickingRadius: 12, onError: (error) => { console.error('[Deck.gl] Error:', error); }, @@ -292,6 +294,22 @@ export default function useShipLayer(map) { return () => unsubscribe(); }, [map]); + // === mapStore 테마(배경지도) 변경 시 선박 레이어 리렌더 === + // 테마 변경 시 선박명/속도벡터/선박크기 색상이 변경되므로 즉시 렌더링 + useEffect(() => { + const unsubscribe = useMapStore.subscribe( + (state) => state.baseMapType, + () => { + if (deckRef.current && map) { + clearClusterCache(); + shipBatchRenderer.immediateRender(); + } + } + ); + + return () => unsubscribe(); + }, [map]); + return { deckCanvas: canvasRef.current, deckRef, diff --git a/src/map/layers/baseLayer.js b/src/map/layers/baseLayer.js index b4a8aadf..5d90c50c 100644 --- a/src/map/layers/baseLayer.js +++ b/src/map/layers/baseLayer.js @@ -10,27 +10,78 @@ import WebGLTileLayer from 'ol/layer/WebGLTile'; const EPSG_3857 = 'EPSG:3857'; const EPSG_4326 = 'EPSG:4326'; +// 1x1 투명 PNG (타일 로드 실패 시 대체용) +const TRANSPARENT_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +/** + * 타일 로드 함수 (fetch 사용으로 콘솔 에러 완전 방지) + * - 404/네트워크 에러 시 투명 이미지로 대체 + * - 브라우저 네트워크 탭에는 표시되지만 콘솔 에러는 없음 + */ +function silentTileLoadFunction(imageTile, src) { + const img = imageTile.getImage(); + + fetch(src) + .then(response => { + if (!response.ok) { + throw new Error('Tile not found'); + } + return response.blob(); + }) + .then(blob => { + img.src = URL.createObjectURL(blob); + }) + .catch(() => { + img.src = TRANSPARENT_PIXEL; + }); +} + /** * 레이어 설정 */ export const mapLayerConfig = { - // 세계 지도 (줌 0-11) + // 일반지도 (줌 0-11) worldLayer: { source: new XYZ({ url: '/MAPS/WORLD_webp/{z}/{x}/{y}.webp', minZoom: 0, maxZoom: 11, attributions: 'ⓒ OpenStreetMap', + tileLoadFunction: silentTileLoadFunction, }), preload: Infinity, }, - // 동아시아 상세 (줌 12-15) + // 전자해도 (줌 0-11) - URL은 임시로 worldLayer와 동일 + encLayer: { + source: new XYZ({ + url: '/MAPS/WORLD_webp/{z}/{x}/{y}.webp', + minZoom: 0, + maxZoom: 11, + attributions: 'ⓒ OpenStreetMap', + tileLoadFunction: silentTileLoadFunction, + }), + preload: Infinity, + }, + + // 야간지도 (줌 5-15, 타일은 6-11레벨까지만 로드 → 12+ 확대 표시) + darkLayer: { + source: new XYZ({ + url: '/MAPS/SIMPLE_B_webp/{z}/{x}/{y}.webp', + minZoom: 6, + maxZoom: 11, // 타일은 11레벨까지만 로드 (12+ 는 11레벨 타일 확대) + tileLoadFunction: silentTileLoadFunction, + }), + preload: Infinity, + }, + + // 동아시아 상세 (줌 12-15, 타일은 12레벨까지만 로드 → 13+ 확대 표시) eastAsiaLayer: { source: new XYZ({ url: '/MAPS/EAST_ASIA_webp/{z}/{x}/{y}.webp', minZoom: 12, - maxZoom: 15, + maxZoom: 12, // 타일은 12레벨까지만 로드 (13+ 는 12레벨 타일 확대) + tileLoadFunction: silentTileLoadFunction, }), preload: 0, minZoom: 12, @@ -44,6 +95,7 @@ export const mapLayerConfig = { url: '/MAPS/KOR_webp/{z}/{x}/{y}.webp', minZoom: 16, maxZoom: 17, + tileLoadFunction: silentTileLoadFunction, }), preload: Infinity, minZoom: 16, @@ -54,14 +106,38 @@ export const mapLayerConfig = { /** * 베이스맵 레이어 생성 + * @param {string} baseMapType - 배경지도 타입 ('normal' | 'enc' | 'dark') */ -export const createBaseLayers = () => { - const worldMap = new WebGLTileLayer(mapLayerConfig.worldLayer); - const eastAsiaMap = new WebGLTileLayer(mapLayerConfig.eastAsiaLayer); - const korMap = new WebGLTileLayer(mapLayerConfig.korLayer); +export const createBaseLayers = (baseMapType = 'dark') => { + // 3가지 배경지도 레이어 생성 + const worldMap = new WebGLTileLayer({ + ...mapLayerConfig.worldLayer, + visible: baseMapType === 'normal', + }); + const encMap = new WebGLTileLayer({ + ...mapLayerConfig.encLayer, + visible: baseMapType === 'enc', + }); + const darkMap = new WebGLTileLayer({ + ...mapLayerConfig.darkLayer, + visible: baseMapType === 'dark', + }); + + // 상세 지도 레이어 (일반지도/전자해도 전용, 야간지도에서는 숨김) + const isDarkMode = baseMapType === 'dark'; + const eastAsiaMap = new WebGLTileLayer({ + ...mapLayerConfig.eastAsiaLayer, + visible: !isDarkMode, + }); + const korMap = new WebGLTileLayer({ + ...mapLayerConfig.korLayer, + visible: !isDarkMode, + }); return { worldMap, + encMap, + darkMap, eastAsiaMap, korMap, }; diff --git a/src/map/layers/shipLayer.js b/src/map/layers/shipLayer.js index a6aa40bc..d0fe0dae 100644 --- a/src/map/layers/shipLayer.js +++ b/src/map/layers/shipLayer.js @@ -15,10 +15,21 @@ import { } from '../../types/constants'; import useShipStore from '../../stores/shipStore'; import useTrackingModeStore from '../../stores/trackingModeStore'; +import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; // 아이콘 아틀라스 이미지 import atlasImg from '../../assets/img/icon/atlas.png'; +/** + * 현재 테마 색상 가져오기 + * @returns {Object} 테마 색상 객체 + */ +function getCurrentThemeColors() { + const { getTheme } = useMapStore.getState(); + const theme = getTheme(); + return THEME_COLORS[theme] || THEME_COLORS[THEME_TYPES.LIGHT]; +} + // 추적 선박 아이콘 (인라인 SVG data URL) const TRACKED_SHIP_SVG = ` @@ -56,6 +67,9 @@ const signalClusterCache = { lastRenderTrigger: 0, clusteredData: [], positionHash: '', + // 밀도 제한된 ships 배열 변경 감지용 + lastShipsLength: 0, + lastShipsHash: '', }; // 클러스터 갱신 주기 (N회 렌더링마다 재계산) @@ -208,6 +222,8 @@ export function clearClusterCache() { signalClusterCache.lastRenderTrigger = 0; signalClusterCache.positionHash = ''; signalClusterCache.clusteredData = []; + signalClusterCache.lastShipsLength = 0; + signalClusterCache.lastShipsHash = ''; } /** @@ -231,6 +247,24 @@ function canGenerateSignalSVG(ship, isIntegrate) { } } +/** + * ships 배열의 featureId 해시 생성 (배열 변경 감지용) + * @param {Array} ships - 선박 배열 + * @returns {string} featureId 기반 해시 + */ +function computeShipsHash(ships) { + if (ships.length === 0) return ''; + // 처음, 1/4, 중간, 3/4, 마지막 선박의 featureId를 샘플링 + const indices = [ + 0, + Math.floor(ships.length / 4), + Math.floor(ships.length / 2), + Math.floor(ships.length * 3 / 4), + ships.length - 1 + ]; + return indices.map(i => ships[Math.min(i, ships.length - 1)]?.featureId || '').join('|'); +} + /** * 신호상태용 클러스터링 결과 가져오기 * 실제로 SVG가 생성 가능한 선박만 대상으로 클러스터링 @@ -244,22 +278,27 @@ function canGenerateSignalSVG(ship, isIntegrate) { function getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger = 0) { const zoomInt = Math.floor(zoom); + // ships 배열 변경 감지용 해시 (밀도 제한 결과가 달라지면 캐시 무효화) + const shipsHash = computeShipsHash(ships); + const shipsLengthMatch = signalClusterCache.lastShipsLength === ships.length; + const shipsHashMatch = signalClusterCache.lastShipsHash === shipsHash; + // 실제로 SVG 생성 가능한 선박만 필터링 const shipsWithSignal = ships.filter(ship => canGenerateSignalSVG(ship, isIntegrate)); const positionHash = computePositionHash(shipsWithSignal); - // 캐시 유효 조건 + // 캐시 유효 조건 (ships 배열 변경도 감지) const zoomMatch = signalClusterCache.lastZoom === zoomInt; const integrateMatch = signalClusterCache.lastIsIntegrate === isIntegrate; - const sizeMatch = Math.abs(signalClusterCache.lastDataLength - shipsWithSignal.length) < shipsWithSignal.length * 0.1; + const sizeMatch = Math.abs(signalClusterCache.lastDataLength - shipsWithSignal.length) < Math.max(1, shipsWithSignal.length * 0.1); const positionMatch = signalClusterCache.positionHash === positionHash; // 렌더 트리거 주기 체크 const triggerDiff = renderTrigger - signalClusterCache.lastRenderTrigger; const needsPeriodicRefresh = triggerDiff >= CLUSTER_REFRESH_INTERVAL; - // 캐시 히트 - if (zoomMatch && integrateMatch && sizeMatch && positionMatch && !needsPeriodicRefresh) { + // 캐시 히트: 모든 조건 충족 시에만 (ships 배열 변경도 체크) + if (zoomMatch && integrateMatch && sizeMatch && positionMatch && shipsLengthMatch && shipsHashMatch && !needsPeriodicRefresh) { return signalClusterCache.clusteredData; } @@ -273,6 +312,8 @@ function getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger = 0) { signalClusterCache.lastRenderTrigger = renderTrigger; signalClusterCache.positionHash = positionHash; signalClusterCache.clusteredData = clustered; + signalClusterCache.lastShipsLength = ships.length; + signalClusterCache.lastShipsHash = shipsHash; return clustered; } @@ -400,6 +441,10 @@ export function createShipIconLayer(ships, zoom = 10, darkSignalIds = null) { * @returns {Array} 라인 데이터 [{ sourcePosition, targetPosition, color }] */ function buildSpeedVectorData(ships) { + // 테마 기반 색상 + const themeColors = getCurrentThemeColors(); + const vectorColor = themeColors.speedVector; + return ships .filter((ship) => Number(ship.sog) > SPEED_THRESHOLD) // 항해중 선박만 (아이콘 기준과 동일) .map((ship) => { @@ -427,7 +472,7 @@ function buildSpeedVectorData(ships) { return { sourcePosition: toLonLat([projX, projY]), targetPosition: toLonLat([projX + xAdd, projY + yAdd]), - color: [0, 0, 0, 200], // 검정색 + color: vectorColor, }; }); } @@ -641,6 +686,9 @@ export function createShipDimLayer(ships, zoom) { return null; } + // 테마 기반 색상 + const themeColors = getCurrentThemeColors(); + return new PathLayer({ id: 'ship-dim-layer', data: dimData, @@ -649,7 +697,7 @@ export function createShipDimLayer(ships, zoom) { widthMinPixels: 1, widthMaxPixels: 3, getPath: (d) => d.path, - getColor: [0, 100, 200, 180], // 파란색 반투명 + getColor: themeColors.shipDim, getWidth: 2, jointRounded: true, capRounded: true, @@ -966,6 +1014,9 @@ export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: const fontSize = zoom < 11 ? 10 : zoom < 13 ? 12 : 14; + // 테마 기반 색상 + const themeColors = getCurrentThemeColors(); + return new TextLayer({ id: 'ship-label-layer', data: labelData, @@ -973,7 +1024,7 @@ export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: getPosition: (d) => [d.longitude, d.latitude], getText: (d) => d.labelText, getSize: fontSize, - getColor: [30, 30, 30, 255], + getColor: themeColors.shipLabel, getAngle: 0, getTextAnchor: 'start', getAlignmentBaseline: 'center', @@ -982,7 +1033,7 @@ export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: fontWeight: 'bold', characterSet: 'auto', outlineWidth: 2, - outlineColor: [255, 255, 255, 255], + outlineColor: themeColors.shipLabelOutline, billboard: true, }); } diff --git a/src/stores/mapStore.js b/src/stores/mapStore.js index e8eaffa1..20cc6f04 100644 --- a/src/stores/mapStore.js +++ b/src/stores/mapStore.js @@ -1,13 +1,74 @@ import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +/** + * 배경지도 타입 + * - normal: 일반지도 + * - enc: 전자해도 + * - dark: 야간지도 + */ +export const BASE_MAP_TYPES = { + NORMAL: 'normal', + ENC: 'enc', + DARK: 'dark', +}; + +/** + * 테마 타입 (배경지도에 연동) + * - light: 일반지도, 전자해도 + * - dark: 야간지도 + */ +export const THEME_TYPES = { + LIGHT: 'light', + DARK: 'dark', +}; + +/** + * 배경지도 -> 테마 매핑 + */ +const BASE_MAP_TO_THEME = { + [BASE_MAP_TYPES.NORMAL]: THEME_TYPES.LIGHT, + [BASE_MAP_TYPES.ENC]: THEME_TYPES.LIGHT, + [BASE_MAP_TYPES.DARK]: THEME_TYPES.DARK, +}; + +/** + * 테마별 색상 정의 + * - 선박 레이어에서 사용 + */ +export const THEME_COLORS = { + [THEME_TYPES.LIGHT]: { + shipLabel: [30, 30, 30, 255], + shipLabelOutline: [255, 255, 255, 255], + speedVector: [0, 0, 0, 200], + shipDim: [0, 100, 200, 180], + }, + [THEME_TYPES.DARK]: { + shipLabel: [255, 255, 255, 255], + shipLabelOutline: [30, 30, 30, 255], + speedVector: [255, 255, 255, 200], + shipDim: [100, 200, 255, 180], + }, +}; /** * 지도 상태 관리 스토어 */ -export const useMapStore = create((set, get) => ({ +export const useMapStore = create(subscribeWithSelector((set, get) => ({ // 지도 인스턴스 map: null, setMap: (map) => set({ map }), + // 배경지도 타입 (기본: 야간지도) + baseMapType: BASE_MAP_TYPES.DARK, + setBaseMapType: (type) => set({ baseMapType: type }), + + // 현재 테마 (배경지도에 연동) + getTheme: () => BASE_MAP_TO_THEME[get().baseMapType] || THEME_TYPES.LIGHT, + + // 현재 테마 색상 가져오기 + getThemeColors: () => THEME_COLORS[get().getTheme()], + // 줌 레벨 zoom: 7, setZoom: (zoom) => set({ zoom }), @@ -35,8 +96,8 @@ export const useMapStore = create((set, get) => ({ setCenter: (center) => set({ center }), // 측정 도구 - activeMeasureTool: null, // 'distance' | 'area' | 'rangeRing' | null - areaShape: null, // 'Polygon' | 'Box' | 'Circle' | null + activeMeasureTool: null, + areaShape: null, setMeasureTool: (tool) => set((state) => ({ activeMeasureTool: state.activeMeasureTool === tool ? null : tool, @@ -58,4 +119,4 @@ export const useMapStore = create((set, get) => ({ [layerName]: !state.layerVisibility[layerName], }, })), -})); +}))); diff --git a/src/types/constants.js b/src/types/constants.js index 37e02d2e..d33f4087 100644 --- a/src/types/constants.js +++ b/src/types/constants.js @@ -14,6 +14,32 @@ export const SIGNAL_SOURCE_CODE_VTS_AIS = '000004'; export const SIGNAL_SOURCE_CODE_RADAR = '000005'; export const SIGNAL_SOURCE_CODE_D_MF_HF = '000016'; +// ===================== +// 신호원 우선순위 (동적 대표 선정용, 숫자가 작을수록 높은 우선순위) +// 참조: mda-react-front/docs/dynamic-priority.md §1 +// ===================== +export const SOURCE_PRIORITY_RANK = { + '000005': 0, // VTS-Radar + '000004': 1, // VTS-AIS + '000001': 2, // AIS + '000003': 3, // V-Pass + '000002': 4, // E-Nav + '000016': 5, // D-MF/HF +}; + +// ===================== +// 신호원 코드 → ship 객체의 is_active 프로퍼티 키 매핑 +// 참조: mda-react-front/docs/dynamic-priority.md §3 +// ===================== +export const SOURCE_TO_ACTIVE_KEY = { + '000001': 'ais', + '000003': 'vpass', + '000002': 'enav', + '000004': 'vtsAis', + '000016': 'dMfHf', + '000005': 'vtsRadar', +}; + // ===================== // 선박 종류 코드 (Ship Kind Code) // ===================== @@ -298,3 +324,10 @@ export const SIGNAL_SOURCE_LIST = [ { code: SIGNAL_SOURCE_CODE_D_MF_HF, label: 'D MF/HF' }, { code: SIGNAL_SOURCE_CODE_RADAR, label: 'RADAR' }, ]; + +// ===================== +// 항적/리플레이 조회기간 설정 +// (향후 로그인 세션별 계정 권한에 따라 커스텀 가능) +// ===================== +export const TRACK_QUERY_MAX_DAYS = 7; // 최대 조회기간 (일) +export const TRACK_QUERY_DEFAULT_DAYS = 3; // 기본 조회기간 (일)