feat: 배경지도 전환 및 테마 시스템 구현
- 배경지도 타입 전환 (일반/전자해도/야간) - 테마 연동 색상 시스템 (선박 라벨, 속도벡터 등) - mapStore에 subscribeWithSelector 적용 - 신호원 우선순위/항적 조회기간 상수 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
부모
a5131306c4
커밋
c068f55077
@ -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,13 +315,13 @@ 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;
|
||||
}
|
||||
input{
|
||||
input:not([type="range"]){
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
@ -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 {
|
||||
|
||||
@ -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;}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<circle cx="16" cy="16" r="14" stroke="#00D4FF" stroke-width="2" fill="none" stroke-dasharray="4 2"/>
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
})));
|
||||
|
||||
@ -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; // 기본 조회기간 (일)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user