Merge pull request 'refactor: 데드코드 정리 + 대형 파일 분리 + FSD 위반 해소' (#19) from feature/refactor-cleanup into develop
Reviewed-on: #19
This commit is contained in:
커밋
c8c1b556d6
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
36
apps/web/src/app/styles/base.css
Normal file
36
apps/web/src/app/styles/base.css
Normal file
@ -0,0 +1,36 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap");
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #020617;
|
||||
--panel: #0f172a;
|
||||
--card: #1e293b;
|
||||
--border: #1e3a5f;
|
||||
--text: #e2e8f0;
|
||||
--muted: #64748b;
|
||||
--accent: #3b82f6;
|
||||
|
||||
--crit: #ef4444;
|
||||
--high: #f59e0b;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Noto Sans KR", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
159
apps/web/src/app/styles/components/ais-list.css
Normal file
159
apps/web/src/app/styles/components/ais-list.css
Normal file
@ -0,0 +1,159 @@
|
||||
/* AIS target list */
|
||||
.ais-q {
|
||||
flex: 1;
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(30, 41, 59, 0.75);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ais-q::placeholder {
|
||||
color: rgba(100, 116, 139, 0.9);
|
||||
}
|
||||
|
||||
.ais-mode {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ais-mode-btn {
|
||||
font-size: 9px;
|
||||
padding: 0 8px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ais-mode-btn.on {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ais-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ais-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.ais-row:hover {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border-color: rgba(30, 58, 95, 0.8);
|
||||
}
|
||||
|
||||
.ais-row.sel {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
}
|
||||
|
||||
.ais-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ais-nm {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ais-nm1 {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ais-nm2 {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ais-right {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ais-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ais-badge {
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2px;
|
||||
color: #fff;
|
||||
background: rgba(100, 116, 139, 0.22);
|
||||
}
|
||||
|
||||
.ais-badge.pn {
|
||||
color: var(--muted);
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
border-color: rgba(30, 58, 95, 0.9);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ais-badge.PT {
|
||||
background: rgba(30, 64, 175, 0.28);
|
||||
border-color: rgba(30, 64, 175, 0.7);
|
||||
}
|
||||
.ais-badge.PT-S {
|
||||
background: rgba(234, 88, 12, 0.22);
|
||||
border-color: rgba(234, 88, 12, 0.7);
|
||||
}
|
||||
.ais-badge.GN {
|
||||
background: rgba(16, 185, 129, 0.22);
|
||||
border-color: rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
.ais-badge.OT {
|
||||
background: rgba(139, 92, 246, 0.22);
|
||||
border-color: rgba(139, 92, 246, 0.7);
|
||||
}
|
||||
.ais-badge.PS {
|
||||
background: rgba(239, 68, 68, 0.22);
|
||||
border-color: rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
.ais-badge.FC {
|
||||
background: rgba(245, 158, 11, 0.22);
|
||||
border-color: rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.ais-sp {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ais-ts {
|
||||
font-size: 9px;
|
||||
color: rgba(100, 116, 139, 0.9);
|
||||
}
|
||||
95
apps/web/src/app/styles/components/alarms.css
Normal file
95
apps/web/src/app/styles/components/alarms.css
Normal file
@ -0,0 +1,95 @@
|
||||
/* Alarm */
|
||||
.ai {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 9px;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
|
||||
.ai.cr {
|
||||
border-color: var(--crit);
|
||||
background: rgba(239, 68, 68, 0.07);
|
||||
}
|
||||
|
||||
.ai.hi {
|
||||
border-color: var(--high);
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
}
|
||||
|
||||
.ai .at {
|
||||
color: var(--muted);
|
||||
font-size: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Alarm filter (dropdown) */
|
||||
.alarm-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alarm-filter__summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
color: var(--text);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alarm-filter__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alarm-filter__menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 22px;
|
||||
z-index: 2000;
|
||||
min-width: 170px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.alarm-filter__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.alarm-filter__row:hover {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.alarm-filter__row input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alarm-filter__cnt {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.alarm-filter__sep {
|
||||
height: 1px;
|
||||
background: rgba(30, 58, 95, 0.85);
|
||||
margin: 4px 0;
|
||||
}
|
||||
126
apps/web/src/app/styles/components/auth.css
Normal file
126
apps/web/src/app/styles/components/auth.css
Normal file
@ -0,0 +1,126 @@
|
||||
/* ── Auth pages ──────────────────────────────────────────────────── */
|
||||
|
||||
.auth-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #020617 0%, #0f172a 50%, #020617 100%);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
width: 360px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
font-size: 36px;
|
||||
font-weight: 900;
|
||||
color: var(--accent);
|
||||
letter-spacing: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
font-size: 11px;
|
||||
color: var(--crit);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth-google-btn {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-dev-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-dev-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.auth-status-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-message {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth-message b {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.auth-link-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-loading {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.auth-loading__spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(148, 163, 184, 0.28);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: map-loader-spin 0.7s linear infinite;
|
||||
}
|
||||
211
apps/web/src/app/styles/components/map-panels.css
Normal file
211
apps/web/src/app/styles/components/map-panels.css
Normal file
@ -0,0 +1,211 @@
|
||||
/* Map panels */
|
||||
.map-legend {
|
||||
position: absolute;
|
||||
/* Keep attribution visible (bottom-right) for licensing/compliance. */
|
||||
bottom: 44px;
|
||||
right: 12px;
|
||||
z-index: 800;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-size: 9px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.map-legend .lt {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.map-legend .li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.map-legend .ls {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 800;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
width: 270px;
|
||||
}
|
||||
|
||||
.map-info .ir {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.map-info .il {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.map-info .iv {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.map-loader-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 950;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(2, 6, 23, 0.42);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.map-loader-overlay__panel {
|
||||
width: min(72vw, 320px);
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.map-loader-overlay__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid rgba(148, 163, 184, 0.28);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: map-loader-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.map-loader-overlay__text {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.map-loader-overlay__bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-loader-overlay__fill {
|
||||
width: 28%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
animation: map-loader-fill 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content {
|
||||
color: #f8fafc !important;
|
||||
background: rgba(2, 6, 23, 0.98) !important;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4) !important;
|
||||
box-shadow: 0 8px 26px rgba(2, 6, 23, 0.55) !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.35 !important;
|
||||
padding: 7px 9px !important;
|
||||
color: #f8fafc !important;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-tip {
|
||||
border-top-color: rgba(2, 6, 23, 0.97) !important;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content {
|
||||
color: #f8fafc;
|
||||
font-family: Pretendard, Inter, ui-sans-serif, -apple-system, Segoe UI, sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content div,
|
||||
.maplibre-tooltip-popup__content span,
|
||||
.maplibre-tooltip-popup__content p {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content div {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content div,
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content span,
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content p {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-close-button {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
@keyframes map-loader-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes map-loader-fill {
|
||||
0% {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(220%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: rgba(15, 23, 42, 0.92) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button + button {
|
||||
border-top: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button span {
|
||||
filter: invert(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
font-size: 10px !important;
|
||||
background: rgba(15, 23, 42, 0.75) !important;
|
||||
color: var(--text) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
175
apps/web/src/app/styles/components/map-settings.css
Normal file
175
apps/web/src/app/styles/components/map-settings.css
Normal file
@ -0,0 +1,175 @@
|
||||
/* ── Map Settings Panel ──────────────────────────────────────────── */
|
||||
|
||||
.map-settings-gear {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
left: 10px;
|
||||
z-index: 850;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.map-settings-gear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.map-settings-gear.open {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.map-settings-panel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 48px;
|
||||
z-index: 850;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
width: 240px;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-color-input {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-color-input::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-hex {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-depth-label {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
min-width: 48px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.map-settings-panel select {
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-settings-panel select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-reset {
|
||||
width: 100%;
|
||||
font-size: 9px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-reset:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Depth Legend ──────────────────────────────────────────────────── */
|
||||
|
||||
.depth-legend {
|
||||
position: absolute;
|
||||
bottom: 44px;
|
||||
left: 10px;
|
||||
z-index: 800;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.depth-legend__bar {
|
||||
width: 14px;
|
||||
border-radius: 3px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.depth-legend__ticks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
font-family: monospace;
|
||||
padding: 1px 0;
|
||||
}
|
||||
77
apps/web/src/app/styles/components/panels.css
Normal file
77
apps/web/src/app/styles/components/panels.css
Normal file
@ -0,0 +1,77 @@
|
||||
.sb {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sb-t {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sb-t-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.relation-sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relation-sort__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.month-row {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.month-cell {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
95
apps/web/src/app/styles/components/relations.css
Normal file
95
apps/web/src/app/styles/components/relations.css
Normal file
@ -0,0 +1,95 @@
|
||||
/* Relation panel */
|
||||
.rel-panel {
|
||||
background: var(--card);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rel-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rel-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.rel-line .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rel-link {
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.rel-dist {
|
||||
font-size: 8px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Fleet network */
|
||||
.fleet-card {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fleet-card.hl,
|
||||
.fleet-card:hover {
|
||||
border-color: rgba(245, 158, 11, 0.75);
|
||||
background: rgba(251, 191, 36, 0.09);
|
||||
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.25) inset;
|
||||
}
|
||||
|
||||
.fleet-owner {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fleet-owner.hl {
|
||||
color: rgba(245, 158, 11, 1);
|
||||
}
|
||||
|
||||
.fleet-vessel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.fleet-vessel.hl {
|
||||
color: rgba(245, 158, 11, 1);
|
||||
}
|
||||
|
||||
.fleet-dot.hl {
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
33
apps/web/src/app/styles/components/speed.css
Normal file
33
apps/web/src/app/styles/components/speed.css
Normal file
@ -0,0 +1,33 @@
|
||||
/* Speed bar */
|
||||
.sbar {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: var(--bg);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sseg {
|
||||
position: absolute;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 7px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sseg.p {
|
||||
height: 24px;
|
||||
top: 0;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sseg:not(.p) {
|
||||
height: 16px;
|
||||
top: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
75
apps/web/src/app/styles/components/toggles.css
Normal file
75
apps/web/src/app/styles/components/toggles.css
Normal file
@ -0,0 +1,75 @@
|
||||
/* Type grid */
|
||||
.tg {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tb {
|
||||
background: var(--card);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tb:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.tb.on {
|
||||
border-color: var(--accent);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.tb .c {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tb .n {
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Toggles */
|
||||
.tog {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tog.tog-map {
|
||||
/* Keep "지도 표시 설정" buttons in a predictable 2-row layout (4 columns). */
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tog.tog-map .tog-btn {
|
||||
flex: 1 1 calc(25% - 4px);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tog-btn {
|
||||
font-size: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tog-btn.on {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
84
apps/web/src/app/styles/components/topbar.css
Normal file
84
apps/web/src/app/styles/components/topbar.css
Normal file
@ -0,0 +1,84 @@
|
||||
.topbar {
|
||||
grid-column: 1/-1;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
gap: 10px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.topbar .logo {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar .logo span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.topbar .stats {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.topbar .stat {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.topbar .stat b {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.topbar .time {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-user__name {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-user__logout {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-user__logout:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
61
apps/web/src/app/styles/components/vessel-list.css
Normal file
61
apps/web/src/app/styles/components/vessel-list.css
Normal file
@ -0,0 +1,61 @@
|
||||
/* Vessel list */
|
||||
.vlist {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: background 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.vi:hover {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.vi.sel {
|
||||
background: rgba(14, 234, 255, 0.16);
|
||||
border-color: rgba(14, 234, 255, 0.55);
|
||||
}
|
||||
|
||||
.vi.hl {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.vi.sel.hl {
|
||||
background: linear-gradient(90deg, rgba(14, 234, 255, 0.16), rgba(245, 158, 11, 0.16));
|
||||
border-color: rgba(14, 234, 255, 0.7);
|
||||
}
|
||||
|
||||
.vi .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vi .nm {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vi .sp {
|
||||
font-weight: 700;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.vi .st {
|
||||
font-size: 7px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
356
apps/web/src/app/styles/components/weather-overlay.css
Normal file
356
apps/web/src/app/styles/components/weather-overlay.css
Normal file
@ -0,0 +1,356 @@
|
||||
/* ── Weather Overlay Panel (MapTiler) ────────────────────────────── */
|
||||
|
||||
.wo-gear {
|
||||
position: absolute;
|
||||
top: 180px;
|
||||
left: 10px;
|
||||
z-index: 850;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wo-gear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-gear.open {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-gear.active {
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.wo-gear.active.open {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-gear-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wo-stack {
|
||||
position: absolute;
|
||||
top: 170px;
|
||||
left: 48px;
|
||||
z-index: 850;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 280px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wo-stack > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.wo-panel {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 240px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wo-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.wo-loading {
|
||||
font-size: 9px;
|
||||
color: var(--accent);
|
||||
animation: wo-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wo-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.wo-layers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wo-layer-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wo-layer-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.wo-layer-btn.on {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-layer-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wo-layer-name {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wo-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wo-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wo-val {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
.wo-offset {
|
||||
color: #4fc3f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wo-slider {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wo-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--panel);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wo-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--panel);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wo-slider:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wo-timeline {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wo-step-slider-wrap {
|
||||
position: relative;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.wo-time-slider {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.wo-step-ticks {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wo-step-tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--muted);
|
||||
opacity: 0.4;
|
||||
transform: translateX(-0.5px);
|
||||
}
|
||||
|
||||
.wo-step-tick.day {
|
||||
height: 8px;
|
||||
opacity: 0.8;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.wo-time-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wo-playback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wo-play-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wo-play-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.wo-play-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wo-speed-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wo-speed-btn {
|
||||
font-size: 8px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.wo-speed-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.wo-speed-btn.on {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-hint {
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Weather Legend ── */
|
||||
.wo-legend {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wo-legend-header {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.wo-legend-bar {
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
.wo-legend-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
185
apps/web/src/app/styles/components/weather.css
Normal file
185
apps/web/src/app/styles/components/weather.css
Normal file
@ -0,0 +1,185 @@
|
||||
/* ── Weather Panel (Open-Meteo) ───────────────────────────────────── */
|
||||
|
||||
.weather-gear {
|
||||
position: absolute;
|
||||
top: 140px;
|
||||
left: 10px;
|
||||
z-index: 850;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.weather-gear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.weather-gear.open {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.weather-panel {
|
||||
position: absolute;
|
||||
top: 130px;
|
||||
left: 48px;
|
||||
z-index: 850;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
width: 260px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wp-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.wp-loading {
|
||||
font-size: 9px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.wp-error {
|
||||
font-size: 9px;
|
||||
color: #f87171;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 6px;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wp-empty {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.wz-card {
|
||||
border-left: 3px solid var(--border);
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wz-card:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wz-card.wz-warn {
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
|
||||
.wz-name {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wz-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.wz-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wz-icon {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.wz-label {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wz-value {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wz-weather {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wp-time {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wp-refresh {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wp-refresh:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wp-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
30
apps/web/src/app/styles/layout.css
Normal file
30
apps/web/src/app/styles/layout.css
Normal file
@ -0,0 +1,30 @@
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 310px 1fr;
|
||||
grid-template-rows: 44px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-area {
|
||||
position: relative;
|
||||
background: #010610;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 44px 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import type { Vessel, VesselTypeCode } from "../model/types";
|
||||
|
||||
export function isTrawl(code: VesselTypeCode) {
|
||||
return code === "PT" || code === "PT-S";
|
||||
}
|
||||
|
||||
export function filterVesselsForMap(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
|
||||
if (!selectedType) return vessels;
|
||||
|
||||
return vessels.filter((v) => {
|
||||
if (v.code === selectedType) return true;
|
||||
|
||||
// PT and PT-S should be shown together
|
||||
if (selectedType === "PT" && v.code === "PT-S") return true;
|
||||
if (selectedType === "PT-S" && v.code === "PT") return true;
|
||||
|
||||
// FC interacts with trawl; show trawl when FC selected and FC when trawl selected
|
||||
if (selectedType === "FC" && (v.code === "PT" || v.code === "PT-S")) return true;
|
||||
if ((selectedType === "PT" || selectedType === "PT-S") && v.code === "FC") return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterVesselsForList(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
|
||||
if (!selectedType) return vessels;
|
||||
return vessels.filter((v) => {
|
||||
if (v.code === selectedType) return true;
|
||||
if (selectedType === "PT" && v.code === "PT-S") return true;
|
||||
if (selectedType === "PT-S" && v.code === "PT") return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,278 +0,0 @@
|
||||
import type { ZoneId } from "../../zone/model/meta";
|
||||
import { haversineNm } from "../../../shared/lib/geo/haversineNm";
|
||||
import { VESSEL_TYPES } from "./meta";
|
||||
import type { FleetOwner, FleetState, TrawlPair, Vessel, VesselTypeCode } from "./types";
|
||||
|
||||
const SURNAMES = ["张", "王", "李", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "林", "何", "高", "罗"];
|
||||
const REGIONS = ["荣成", "石岛", "烟台", "威海", "日照", "青岛", "连云港", "舟山", "象山", "大连"];
|
||||
|
||||
const ZONE_BOUNDS: Record<ZoneId, { lat: [number, number]; lon: [number, number] }> = {
|
||||
"1": { lon: [128.85, 131.70], lat: [36.16, 38.25] },
|
||||
"2": { lon: [126.00, 128.90], lat: [32.18, 34.35] },
|
||||
"3": { lon: [124.12, 126.06], lat: [33.13, 35.00] },
|
||||
"4": { lon: [124.33, 125.85], lat: [35.00, 37.00] },
|
||||
};
|
||||
|
||||
function rnd(a: number, b: number) {
|
||||
return a + Math.random() * (b - a);
|
||||
}
|
||||
|
||||
function pick<T>(arr: readonly T[]) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function randomPointInZone(zone: ZoneId) {
|
||||
const b = ZONE_BOUNDS[zone];
|
||||
// Small margin to avoid sitting exactly on the edge.
|
||||
const lat = rnd(b.lat[0] + 0.05, b.lat[1] - 0.05);
|
||||
const lon = rnd(b.lon[0] + 0.05, b.lon[1] - 0.05);
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
function makePermit(id: number, suffix: "A" | "B") {
|
||||
return `C21-${10000 + id}${suffix}`;
|
||||
}
|
||||
|
||||
export function createMockFleetState(): FleetState {
|
||||
const vessels: Vessel[] = [];
|
||||
const owners: FleetOwner[] = [];
|
||||
const ptPairs: TrawlPair[] = [];
|
||||
|
||||
let vid = 1;
|
||||
|
||||
// PT pairs: PT count == PT-S count, treat as "pair count".
|
||||
for (let i = 0; i < VESSEL_TYPES.PT.count; i++) {
|
||||
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "海产", "水产", "船务"])}${pick(["有限公司", "合作社", ""])}`;
|
||||
const region = pick(REGIONS);
|
||||
const zone = pick<ZoneId>(["2", "3"]);
|
||||
const { lat, lon } = randomPointInZone(zone);
|
||||
|
||||
const isFishing = Math.random() < 0.55;
|
||||
const sp = isFishing ? rnd(2.5, 4.5) : rnd(6, 11);
|
||||
const crs = rnd(0, 360);
|
||||
|
||||
const pairDist = isFishing ? rnd(0.2, 1.2) : rnd(0, 0.3); // NM (rough)
|
||||
const pairAngle = rnd(0, 360);
|
||||
const lat2 = lat + (pairDist / 60) * Math.cos((pairAngle * Math.PI) / 180);
|
||||
const lon2 = lon + ((pairDist / 60) * Math.sin((pairAngle * Math.PI) / 180)) / Math.cos((lat * Math.PI) / 180);
|
||||
|
||||
const permitBase = vid;
|
||||
const ptId = vid++;
|
||||
const ptsId = vid++;
|
||||
|
||||
const pt: Vessel = {
|
||||
id: ptId,
|
||||
permit: makePermit(permitBase, "A"),
|
||||
code: "PT",
|
||||
color: VESSEL_TYPES.PT.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(crs.toFixed(0)),
|
||||
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: Number(pairDist.toFixed(2)),
|
||||
nearVesselIds: [],
|
||||
};
|
||||
|
||||
const pts: Vessel = {
|
||||
id: ptsId,
|
||||
permit: makePermit(permitBase, "B"),
|
||||
code: "PT-S",
|
||||
color: VESSEL_TYPES["PT-S"].color,
|
||||
lat: Number(lat2.toFixed(4)),
|
||||
lon: Number(lon2.toFixed(4)),
|
||||
speed: Number((sp + rnd(-0.3, 0.3)).toFixed(1)),
|
||||
course: Number((crs + rnd(-10, 10)).toFixed(0)),
|
||||
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: pt.pairDistNm,
|
||||
nearVesselIds: [],
|
||||
};
|
||||
|
||||
pt.pairId = pts.id;
|
||||
pts.pairId = pt.id;
|
||||
|
||||
vessels.push(pt, pts);
|
||||
ptPairs.push({ mainId: pt.id, subId: pts.id, owner, region });
|
||||
owners.push({ name: owner, region, vessels: [pt.id, pts.id], type: "trawl" });
|
||||
}
|
||||
|
||||
// GN vessels
|
||||
for (let i = 0; i < VESSEL_TYPES.GN.count; i++) {
|
||||
const attachToOwner = Math.random() < 0.3 ? owners[Math.floor(Math.random() * owners.length)] : null;
|
||||
const owner = attachToOwner ? attachToOwner.name : `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "水产"])}有限公司`;
|
||||
const region = attachToOwner ? attachToOwner.region : pick(REGIONS);
|
||||
const zone = pick<ZoneId>(["2", "3", "4"]);
|
||||
const { lat, lon } = randomPointInZone(zone);
|
||||
|
||||
const isFishing = Math.random() < 0.5;
|
||||
const sp = isFishing ? rnd(0.5, 2) : rnd(5, 10);
|
||||
|
||||
const id = vid++;
|
||||
const v: Vessel = {
|
||||
id,
|
||||
permit: makePermit(id, "A"),
|
||||
code: "GN",
|
||||
color: VESSEL_TYPES.GN.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(rnd(0, 360).toFixed(0)),
|
||||
state: isFishing ? pick(["표류", "투망", "양망"]) : sp < 1 ? "정박" : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: null,
|
||||
nearVesselIds: [],
|
||||
};
|
||||
|
||||
vessels.push(v);
|
||||
if (attachToOwner) attachToOwner.vessels.push(v.id);
|
||||
else owners.push({ name: owner, region, vessels: [v.id], type: "gn" });
|
||||
}
|
||||
|
||||
// OT
|
||||
for (let i = 0; i < VESSEL_TYPES.OT.count; i++) {
|
||||
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}远洋渔业`;
|
||||
const region = pick(REGIONS);
|
||||
const zone = pick<ZoneId>(["2", "3"]);
|
||||
const { lat, lon } = randomPointInZone(zone);
|
||||
const isFishing = Math.random() < 0.5;
|
||||
const sp = isFishing ? rnd(2.5, 5) : rnd(5, 10);
|
||||
const id = vid++;
|
||||
const v: Vessel = {
|
||||
id,
|
||||
permit: makePermit(id, "A"),
|
||||
code: "OT",
|
||||
color: VESSEL_TYPES.OT.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(rnd(0, 360).toFixed(0)),
|
||||
state: isFishing ? "조업중" : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: null,
|
||||
nearVesselIds: [],
|
||||
};
|
||||
vessels.push(v);
|
||||
owners.push({ name: owner, region, vessels: [v.id], type: "ot" });
|
||||
}
|
||||
|
||||
// PS
|
||||
for (let i = 0; i < VESSEL_TYPES.PS.count; i++) {
|
||||
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}水产`;
|
||||
const region = pick(REGIONS);
|
||||
const zone = pick<ZoneId>(["1", "2", "3", "4"]);
|
||||
const { lat, lon } = randomPointInZone(zone);
|
||||
const isFishing = Math.random() < 0.5;
|
||||
const sp = isFishing ? rnd(0.3, 1.5) : rnd(5, 9);
|
||||
const id = vid++;
|
||||
const v: Vessel = {
|
||||
id,
|
||||
permit: makePermit(id, "A"),
|
||||
code: "PS",
|
||||
color: VESSEL_TYPES.PS.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(rnd(0, 360).toFixed(0)),
|
||||
state: isFishing ? pick(["위망", "채낚기"]) : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: null,
|
||||
nearVesselIds: [],
|
||||
};
|
||||
vessels.push(v);
|
||||
owners.push({ name: owner, region, vessels: [v.id], type: "ps" });
|
||||
}
|
||||
|
||||
// FC — assigned to trawl owners (positioned near PT)
|
||||
const trawlOwners = owners.filter((o) => o.type === "trawl");
|
||||
for (let i = 0; i < VESSEL_TYPES.FC.count; i++) {
|
||||
const oi = i < trawlOwners.length ? trawlOwners[i] : pick(trawlOwners);
|
||||
|
||||
const refId = oi.vessels.find((id) => vessels[id - 1]?.code === "PT") ?? oi.vessels[0];
|
||||
const ref = vessels[refId - 1];
|
||||
|
||||
const zone = pick<ZoneId>(["2", "3"]);
|
||||
const lat = ref.lat + rnd(-0.2, 0.2);
|
||||
const lon = ref.lon + rnd(-0.2, 0.2);
|
||||
|
||||
const isNear = Math.random() < 0.4;
|
||||
const sp = isNear ? rnd(0.5, 1.5) : rnd(5, 9);
|
||||
|
||||
const nearVesselIds = isNear ? oi.vessels.filter((id) => vessels[id - 1]?.code !== "FC").slice(0, 2) : [];
|
||||
|
||||
const v: Vessel = {
|
||||
id: vid,
|
||||
permit: makePermit(vid, "A"),
|
||||
code: "FC",
|
||||
color: VESSEL_TYPES.FC.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(rnd(0, 360).toFixed(0)),
|
||||
state: isNear ? "환적" : "항해중",
|
||||
zone,
|
||||
isFishing: isNear, // kept from prototype: treat "환적" as fishing-like activity
|
||||
owner: oi.name,
|
||||
region: oi.region,
|
||||
pairId: null,
|
||||
pairDistNm: null,
|
||||
nearVesselIds,
|
||||
};
|
||||
vid += 1;
|
||||
vessels.push(v);
|
||||
oi.vessels.push(v.id);
|
||||
}
|
||||
|
||||
// Ensure initial pair distances are consistent with actual coordinates.
|
||||
for (const p of ptPairs) {
|
||||
const a = vessels[p.mainId - 1];
|
||||
const b = vessels[p.subId - 1];
|
||||
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
|
||||
a.pairDistNm = d;
|
||||
b.pairDistNm = d;
|
||||
}
|
||||
|
||||
return { vessels, owners, ptPairs };
|
||||
}
|
||||
|
||||
export function tickMockFleetState(state: FleetState) {
|
||||
for (const v of state.vessels) {
|
||||
v.lat += (0.5 - Math.random()) * 0.003;
|
||||
v.lon += (0.5 - Math.random()) * 0.003;
|
||||
v.speed = Math.max(0, Number((v.speed + (0.5 - Math.random()) * 0.4).toFixed(1)));
|
||||
v.course = Number(((v.course + (0.5 - Math.random()) * 6 + 360) % 360).toFixed(0));
|
||||
}
|
||||
|
||||
for (const p of state.ptPairs) {
|
||||
const a = state.vessels[p.mainId - 1];
|
||||
const b = state.vessels[p.subId - 1];
|
||||
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
|
||||
a.pairDistNm = d;
|
||||
b.pairDistNm = d;
|
||||
}
|
||||
}
|
||||
|
||||
export function isVesselCode(code: string): code is VesselTypeCode {
|
||||
return code === "PT" || code === "PT-S" || code === "GN" || code === "OT" || code === "PS" || code === "FC";
|
||||
}
|
||||
@ -3,7 +3,7 @@ import type { Layer } from '@deck.gl/core';
|
||||
import type { ProcessedTrack } from '../model/track.types';
|
||||
import { getShipKindColor } from '../lib/adapters';
|
||||
import { TRACK_REPLAY_LAYER_IDS } from './trackLayers';
|
||||
import { DEPTH_DISABLED_PARAMS } from '../../../widgets/map3d/constants';
|
||||
import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants';
|
||||
|
||||
interface ReplayTrip {
|
||||
vesselId: string;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||
import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../widgets/map3d/constants';
|
||||
import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../shared/lib/map/mapConstants';
|
||||
import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache';
|
||||
import { getShipKindColor } from '../lib/adapters';
|
||||
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
RadarLayer,
|
||||
ColorRamp,
|
||||
} from '@maptiler/weather';
|
||||
import { getMapTilerKey } from '../../widgets/map3d/lib/mapCore';
|
||||
import { getMapTilerKey } from '../../shared/lib/map/mapTilerKey';
|
||||
|
||||
/** 6종 기상 레이어 ID */
|
||||
export type WeatherLayerId =
|
||||
|
||||
@ -1,37 +1,22 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAuth } from "../../shared/auth";
|
||||
import { usePersistedState } from "../../shared/hooks";
|
||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
||||
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||
import { MapToggles } from "../../features/mapToggles/MapToggles";
|
||||
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
|
||||
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
|
||||
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
||||
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
||||
import { useZones } from "../../entities/zone/api/useZones";
|
||||
import { useSubcables } from "../../entities/subcable/api/useSubcables";
|
||||
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
||||
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
|
||||
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
|
||||
import { MapLegend } from "../../widgets/legend/MapLegend";
|
||||
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
|
||||
import type { MapViewState } from "../../widgets/map3d/types";
|
||||
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
||||
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
||||
import { Map3D } from "../../widgets/map3d/Map3D";
|
||||
import { Topbar } from "../../widgets/topbar/Topbar";
|
||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
||||
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
||||
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
||||
import type { MapStyleSettings } from "../../features/mapSettings/types";
|
||||
import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime";
|
||||
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
|
||||
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
|
||||
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
|
||||
@ -39,6 +24,7 @@ import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolli
|
||||
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
||||
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
||||
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
||||
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||
import {
|
||||
buildLegacyHitMap,
|
||||
computeCountsByType,
|
||||
@ -49,18 +35,16 @@ import {
|
||||
deriveLegacyVessels,
|
||||
filterByShipCodes,
|
||||
} from "../../features/legacyDashboard/model/derive";
|
||||
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
||||
import { useDashboardState } from "./useDashboardState";
|
||||
import type { Bbox } from "./useDashboardState";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
|
||||
const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
|
||||
const AIS_CENTER = {
|
||||
lon: 126.95,
|
||||
lat: 35.95,
|
||||
radiusMeters: 2_000_000,
|
||||
};
|
||||
|
||||
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
|
||||
type FleetRelationSortMode = "count" | "range";
|
||||
|
||||
function inBbox(lon: number, lat: number, bbox: Bbox) {
|
||||
const [lonMin, latMin, lonMax, latMax] = bbox;
|
||||
if (lat < latMin || lat > latMax) return false;
|
||||
@ -68,34 +52,56 @@ function inBbox(lon: number, lat: number, bbox: Bbox) {
|
||||
return lon >= lonMin || lon <= lonMax;
|
||||
}
|
||||
|
||||
function fmtBbox(b: Bbox | null) {
|
||||
if (!b) return "-";
|
||||
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
|
||||
}
|
||||
|
||||
function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | null {
|
||||
function useLegacyIndex(data: LegacyVesselDataset | null) {
|
||||
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const uid = user?.id ?? null;
|
||||
|
||||
// ── Data fetching ──
|
||||
const { data: zones, error: zonesError } = useZones();
|
||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||
const { data: subcableData } = useSubcables();
|
||||
const legacyIndex = useLegacyIndex(legacyData);
|
||||
|
||||
// ── UI state ──
|
||||
const state = useDashboardState(uid);
|
||||
const {
|
||||
mapInstance, handleMapReady,
|
||||
viewBbox, setViewBbox,
|
||||
useViewportFilter,
|
||||
useApiBbox, apiBbox,
|
||||
selectedMmsi, setSelectedMmsi,
|
||||
highlightedMmsiSet,
|
||||
hoveredMmsiSet, setHoveredMmsiSet,
|
||||
hoveredFleetMmsiSet, setHoveredFleetMmsiSet,
|
||||
hoveredPairMmsiSet, setHoveredPairMmsiSet,
|
||||
hoveredFleetOwnerKey, setHoveredFleetOwnerKey,
|
||||
typeEnabled,
|
||||
showTargets, showOthers,
|
||||
baseMap, projection,
|
||||
mapStyleSettings, setMapStyleSettings,
|
||||
overlays, settings,
|
||||
mapView, setMapView,
|
||||
fleetFocus, setFleetFocus,
|
||||
hoveredCableId, setHoveredCableId,
|
||||
selectedCableId, setSelectedCableId,
|
||||
trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu,
|
||||
handleProjectionLoadingChange,
|
||||
setIsGlobeShipsReady,
|
||||
showMapLoader,
|
||||
clock, adminMode, onLogoClick,
|
||||
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
||||
alarmKindEnabled,
|
||||
} = state;
|
||||
|
||||
// ── Weather ──
|
||||
const weather = useWeatherPolling(zones);
|
||||
const [mapInstance, setMapInstance] = useState<import("maplibre-gl").Map | null>(null);
|
||||
const weatherOverlay = useWeatherOverlay(mapInstance);
|
||||
const handleMapReady = useCallback((map: import("maplibre-gl").Map) => {
|
||||
setMapInstance(map);
|
||||
}, []);
|
||||
|
||||
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
||||
const [useViewportFilter, setUseViewportFilter] = useState(false);
|
||||
const [useApiBbox, setUseApiBbox] = useState(false);
|
||||
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
|
||||
|
||||
// ── AIS polling ──
|
||||
const { targets, snapshot } = useAisTargetPolling({
|
||||
chnprmshipMinutes: 120,
|
||||
incrementalMinutes: 2,
|
||||
@ -107,48 +113,7 @@ export function DashboardPage() {
|
||||
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
|
||||
});
|
||||
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
||||
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||
const uid = user?.id ?? null;
|
||||
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
|
||||
uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true },
|
||||
);
|
||||
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
|
||||
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false);
|
||||
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
|
||||
// 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환
|
||||
const [projection, setProjection] = useState<MapProjectionId>('mercator');
|
||||
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
||||
|
||||
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
|
||||
pairLines: true, pairRange: true, fcLines: true, zones: true,
|
||||
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
|
||||
});
|
||||
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', "count");
|
||||
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
|
||||
uid, 'alarmKindEnabled',
|
||||
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
|
||||
);
|
||||
|
||||
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
|
||||
|
||||
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||
|
||||
// 항적 (vessel track)
|
||||
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
|
||||
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => {
|
||||
setTrackContextMenu(info);
|
||||
}, []);
|
||||
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
||||
// ── Track request ──
|
||||
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
||||
const trackStore = useTrackQueryStore.getState();
|
||||
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
||||
@ -172,40 +137,7 @@ export function DashboardPage() {
|
||||
}
|
||||
}, [targets]);
|
||||
|
||||
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
||||
showShips: true, showDensity: false, showSeamark: false,
|
||||
});
|
||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||
|
||||
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||
// 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화
|
||||
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
|
||||
const handleProjectionLoadingChange = useCallback((loading: boolean) => {
|
||||
setIsProjectionLoading(loading);
|
||||
}, []);
|
||||
const showMapLoader = isProjectionLoading;
|
||||
// globe 레이어 미준비 또는 전환 중일 때 토글 비활성화
|
||||
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
|
||||
|
||||
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Secret admin toggle: 7 clicks within 900ms on the logo.
|
||||
const [adminMode, setAdminMode] = useState(false);
|
||||
const clicksRef = useRef<number[]>([]);
|
||||
const onLogoClick = () => {
|
||||
const now = Date.now();
|
||||
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
|
||||
clicksRef.current.push(now);
|
||||
if (clicksRef.current.length >= 7) {
|
||||
clicksRef.current = [];
|
||||
setAdminMode((v) => !v);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Derived data ──
|
||||
const targetsInScope = useMemo(() => {
|
||||
if (!useViewportFilter || !viewBbox) return targets;
|
||||
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
||||
@ -244,17 +176,14 @@ export function DashboardPage() {
|
||||
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
||||
|
||||
const alarmKindCounts = useMemo(() => {
|
||||
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<LegacyAlarmKind, number>;
|
||||
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<typeof LEGACY_ALARM_KINDS[number], number>;
|
||||
for (const a of alarms) {
|
||||
base[a.kind] = (base[a.kind] ?? 0) + 1;
|
||||
}
|
||||
return base;
|
||||
}, [alarms]);
|
||||
|
||||
const enabledAlarmKinds = useMemo(() => {
|
||||
return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]);
|
||||
}, [alarmKindEnabled]);
|
||||
|
||||
const enabledAlarmKinds = useMemo(() => LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]), [alarmKindEnabled]);
|
||||
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
||||
|
||||
const filteredAlarms = useMemo(() => {
|
||||
@ -291,13 +220,12 @@ export function DashboardPage() {
|
||||
[highlightedMmsiSet, availableTargetMmsiSet],
|
||||
);
|
||||
|
||||
const setUniqueSorted = (items: number[]) =>
|
||||
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
|
||||
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
||||
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
|
||||
|
||||
const setSortedIfChanged = (next: number[]) => {
|
||||
const sorted = setUniqueSorted(next);
|
||||
return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted);
|
||||
};
|
||||
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKinds.length}/${LEGACY_ALARM_KINDS.length}`;
|
||||
|
||||
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
||||
if (!mmsis.length) return;
|
||||
@ -312,30 +240,10 @@ export function DashboardPage() {
|
||||
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
|
||||
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
|
||||
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
|
||||
setFleetFocus({
|
||||
id: `${ownerKey}-${Date.now()}`,
|
||||
center,
|
||||
zoom: 9,
|
||||
});
|
||||
setFleetFocus({ id: `${ownerKey}-${Date.now()}`, center, zoom: 9 });
|
||||
};
|
||||
|
||||
const toggleHighlightedMmsi = (mmsi: number) => {
|
||||
setHighlightedMmsiSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mmsi)) next.delete(mmsi);
|
||||
else next.add(mmsi);
|
||||
return Array.from(next).sort((a, b) => a - b);
|
||||
});
|
||||
};
|
||||
|
||||
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
||||
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
|
||||
|
||||
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||
const enabledAlarmKindCount = enabledAlarmKinds.length;
|
||||
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`;
|
||||
|
||||
// ── Render ──
|
||||
return (
|
||||
<div className="app">
|
||||
<Topbar
|
||||
@ -353,370 +261,29 @@ export function DashboardPage() {
|
||||
onLogout={logout}
|
||||
/>
|
||||
|
||||
<div className="sidebar">
|
||||
<div className="sb">
|
||||
<div className="sb-t">업종 필터</div>
|
||||
<div className="tog">
|
||||
<div
|
||||
className={`tog-btn ${showTargets ? "on" : ""}`}
|
||||
onClick={() => {
|
||||
setShowTargets((v) => {
|
||||
const next = !v;
|
||||
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
title="레거시(CN permit) 대상 선박 표시"
|
||||
>
|
||||
대상 선박
|
||||
</div>
|
||||
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||
기타 AIS
|
||||
</div>
|
||||
</div>
|
||||
<TypeFilterGrid
|
||||
enabled={typeEnabled}
|
||||
totalCount={legacyVesselsAll.length}
|
||||
countsByType={legacyCounts}
|
||||
onToggle={(code) => {
|
||||
// When hiding the currently selected legacy vessel's type, clear selection.
|
||||
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
|
||||
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
|
||||
}}
|
||||
onToggleAll={() => {
|
||||
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
|
||||
const nextVal = !allOn; // any-off -> true, all-on -> false
|
||||
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
|
||||
setTypeEnabled({
|
||||
PT: nextVal,
|
||||
"PT-S": nextVal,
|
||||
GN: nextVal,
|
||||
OT: nextVal,
|
||||
PS: nextVal,
|
||||
FC: nextVal,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t" style={{ display: "flex", alignItems: "center" }}>
|
||||
지도 표시 설정
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`}
|
||||
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
||||
title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"}
|
||||
style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
3D
|
||||
</div>
|
||||
</div>
|
||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
{/* 베이스맵 선택 — 현재 enhanced 단일 맵 사용, 레거시는 비활성
|
||||
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center", marginTop: 8 }}>
|
||||
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
|
||||
기본
|
||||
</div>
|
||||
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
||||
레거시
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">속도 프로파일</div>
|
||||
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 260, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div className="sb-t sb-t-row">
|
||||
<div>
|
||||
선단 연관관계{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relation-sort">
|
||||
<label className="relation-sort__option">
|
||||
<input
|
||||
type="radio"
|
||||
name="fleet-relation-sort"
|
||||
checked={fleetRelationSortMode === "count"}
|
||||
onChange={() => setFleetRelationSortMode("count")}
|
||||
/>
|
||||
척수
|
||||
</label>
|
||||
<label className="relation-sort__option">
|
||||
<input
|
||||
type="radio"
|
||||
name="fleet-relation-sort"
|
||||
checked={fleetRelationSortMode === "range"}
|
||||
onChange={() => setFleetRelationSortMode("range")}
|
||||
/>
|
||||
범위
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ overflowY: "auto", minHeight: 0 }}>
|
||||
<RelationsPanel
|
||||
selectedVessel={selectedLegacyVessel}
|
||||
vessels={legacyVesselsAll}
|
||||
fleetVessels={legacyVesselsFiltered}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
||||
onHoverFleet={(ownerKey, fleetMmsis) => {
|
||||
setHoveredFleetOwnerKey(ownerKey);
|
||||
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
||||
}}
|
||||
onClearFleetHover={() => {
|
||||
setHoveredFleetOwnerKey(null);
|
||||
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
}}
|
||||
fleetSortMode={fleetRelationSortMode}
|
||||
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
|
||||
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
||||
onContextMenuFleet={handleFleetContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div className="sb-t">
|
||||
선박 목록{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
({legacyVesselsFiltered.length}척)
|
||||
</span>
|
||||
</div>
|
||||
<VesselList
|
||||
vessels={legacyVesselsFiltered}
|
||||
selectedMmsi={selectedMmsi}
|
||||
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, display: "flex", flexDirection: "column", overflow: "visible" }}>
|
||||
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
실시간 경고{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
({filteredAlarms.length}/{alarms.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} style={{ display: "inline-flex", gap: 4, alignItems: "center", cursor: "pointer", userSelect: "none" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
<span style={{ fontSize: 8, color: "var(--muted)", whiteSpace: "nowrap" }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<details className="alarm-filter">
|
||||
<summary className="alarm-filter__summary" title="경고 종류 필터">
|
||||
{alarmFilterSummary}
|
||||
</summary>
|
||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||
<label className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAlarmKindsEnabled}
|
||||
onChange={() =>
|
||||
setAlarmKindEnabled((prev) => {
|
||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||
const nextVal = allOn ? false : true;
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||
})
|
||||
}
|
||||
/>
|
||||
전체
|
||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
||||
</label>
|
||||
<div className="alarm-filter__sep" />
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: "auto", minHeight: 0, flex: 1 }}>
|
||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adminMode ? (
|
||||
<>
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · AIS Target Polling</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: "var(--muted)", fontSize: 10 }}>엔드포인트</div>
|
||||
<div style={{ wordBreak: "break-all" }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>상태</div>
|
||||
<div>
|
||||
<b style={{ color: snapshot.status === "ready" ? "#22C55E" : snapshot.status === "error" ? "#EF4444" : "#F59E0B" }}>
|
||||
{snapshot.status.toUpperCase()}
|
||||
</b>
|
||||
{snapshot.error ? <span style={{ marginLeft: 6, color: "#EF4444" }}>{snapshot.error}</span> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>최근 fetch</div>
|
||||
<div>
|
||||
{fmtIsoFull(snapshot.lastFetchAt)}{" "}
|
||||
<span style={{ color: "var(--muted)", fontSize: 10 }}>
|
||||
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>메시지</div>
|
||||
<div style={{ color: "var(--text)", fontSize: 10 }}>{snapshot.lastMessage ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
|
||||
{legacyError ? (
|
||||
<div style={{ fontSize: 11, color: "#EF4444" }}>legacy load error: {legacyError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: "var(--muted)", fontSize: 10 }}>데이터셋</div>
|
||||
<div style={{ wordBreak: "break-all", fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>매칭(현재 scope)</div>
|
||||
<div>
|
||||
<b style={{ color: "#F59E0B" }}>{legacyVesselsAll.length}</b>{" "}
|
||||
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>생성시각</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Viewport / BBox</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: "var(--muted)", fontSize: 10 }}>현재 View BBox</div>
|
||||
<div style={{ wordBreak: "break-all", fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={() => setUseViewportFilter((v) => !v)}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
background: useViewportFilter ? "rgba(59,130,246,.18)" : "var(--card)",
|
||||
color: "var(--text)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
|
||||
>
|
||||
Viewport filter {useViewportFilter ? "ON" : "OFF"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setUseApiBbox((v) => {
|
||||
const next = !v;
|
||||
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
|
||||
if (!next) setApiBbox(undefined);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
background: useApiBbox ? "rgba(245,158,11,.14)" : "var(--card)",
|
||||
color: viewBbox ? "var(--text)" : "var(--muted)",
|
||||
cursor: viewBbox ? "pointer" : "not-allowed",
|
||||
}}
|
||||
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
|
||||
disabled={!viewBbox}
|
||||
>
|
||||
API bbox {useApiBbox ? "ON" : "OFF"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setApiBbox(fmtBbox(viewBbox));
|
||||
setUseApiBbox(true);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
color: viewBbox ? "var(--text)" : "var(--muted)",
|
||||
cursor: viewBbox ? "pointer" : "not-allowed",
|
||||
}}
|
||||
disabled={!viewBbox}
|
||||
title="현재 view bbox로 API bbox를 갱신"
|
||||
>
|
||||
bbox=viewport
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: "var(--muted)", fontSize: 10 }}>
|
||||
표시 선박: <b style={{ color: "var(--text)" }}>{targetsInScope.length}</b> / 스토어:{" "}
|
||||
<b style={{ color: "var(--text)" }}>{snapshot.total}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Map (Extras)</div>
|
||||
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div className="sb-t">ADMIN · AIS Targets (All)</div>
|
||||
<AisTargetList
|
||||
targets={targetsInScope}
|
||||
selectedMmsi={selectedMmsi}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
legacyIndex={legacyIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
||||
<div className="sb-t">ADMIN · 수역 데이터</div>
|
||||
{zonesError ? (
|
||||
<div style={{ fontSize: 11, color: "#EF4444" }}>zones load error: {zonesError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>
|
||||
{zones ? `loaded (${zones.features.length} features)` : "loading..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<DashboardSidebar
|
||||
state={state}
|
||||
legacyVesselsAll={legacyVesselsAll}
|
||||
legacyVesselsFiltered={legacyVesselsFiltered}
|
||||
legacyCounts={legacyCounts}
|
||||
selectedLegacyVessel={selectedLegacyVessel}
|
||||
activeHighlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
legacyHits={legacyHits}
|
||||
filteredAlarms={filteredAlarms}
|
||||
alarms={alarms}
|
||||
alarmKindCounts={alarmKindCounts}
|
||||
allAlarmKindsEnabled={allAlarmKindsEnabled}
|
||||
alarmFilterSummary={alarmFilterSummary}
|
||||
speedPanelType={speedPanelType}
|
||||
onFleetContextMenu={handleFleetContextMenu}
|
||||
snapshot={snapshot}
|
||||
legacyError={legacyError}
|
||||
legacyData={legacyData}
|
||||
targetsInScope={targetsInScope}
|
||||
zonesError={zonesError}
|
||||
zones={zones}
|
||||
legacyIndex={legacyIndex}
|
||||
/>
|
||||
|
||||
<div className="map-area">
|
||||
{showMapLoader ? (
|
||||
|
||||
430
apps/web/src/pages/dashboard/DashboardSidebar.tsx
Normal file
430
apps/web/src/pages/dashboard/DashboardSidebar.tsx
Normal file
@ -0,0 +1,430 @@
|
||||
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
|
||||
import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
||||
import type { VesselTypeCode } from '../../entities/vessel/model/types';
|
||||
import { VESSEL_TYPE_ORDER } from '../../entities/vessel/model/meta';
|
||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||
import type { AisPollingSnapshot } from '../../features/aisPolling/useAisTargetPolling';
|
||||
import { Map3DSettingsToggles } from '../../features/map3dSettings/Map3DSettingsToggles';
|
||||
import type { DerivedLegacyVessel, LegacyAlarm, LegacyAlarmKind } from '../../features/legacyDashboard/model/types';
|
||||
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types';
|
||||
import { MapToggles } from '../../features/mapToggles/MapToggles';
|
||||
import { TypeFilterGrid } from '../../features/typeFilter/TypeFilterGrid';
|
||||
import { AisTargetList } from '../../widgets/aisTargetList/AisTargetList';
|
||||
import { AlarmsPanel } from '../../widgets/alarms/AlarmsPanel';
|
||||
import { RelationsPanel } from '../../widgets/relations/RelationsPanel';
|
||||
import { SpeedProfilePanel } from '../../widgets/speed/SpeedProfilePanel';
|
||||
import { VesselList } from '../../widgets/vesselList/VesselList';
|
||||
import { fmtIsoFull } from '../../shared/lib/datetime';
|
||||
import type { useDashboardState } from './useDashboardState';
|
||||
import type { Bbox } from './useDashboardState';
|
||||
|
||||
const AIS_API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
|
||||
|
||||
function fmtBbox(b: Bbox | null) {
|
||||
if (!b) return '-';
|
||||
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
|
||||
}
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
state: ReturnType<typeof useDashboardState>;
|
||||
// Derived data
|
||||
legacyVesselsAll: DerivedLegacyVessel[];
|
||||
legacyVesselsFiltered: DerivedLegacyVessel[];
|
||||
legacyCounts: Record<VesselTypeCode, number>;
|
||||
selectedLegacyVessel: DerivedLegacyVessel | null;
|
||||
activeHighlightedMmsiSet: number[];
|
||||
legacyHits: Map<number, LegacyVesselInfo>;
|
||||
filteredAlarms: LegacyAlarm[];
|
||||
alarms: LegacyAlarm[];
|
||||
alarmKindCounts: Record<LegacyAlarmKind, number>;
|
||||
allAlarmKindsEnabled: boolean;
|
||||
alarmFilterSummary: string;
|
||||
speedPanelType: VesselTypeCode;
|
||||
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
|
||||
// Data fetching (admin panels)
|
||||
snapshot: AisPollingSnapshot;
|
||||
legacyError: string | null;
|
||||
legacyData: LegacyVesselDataset | null;
|
||||
targetsInScope: AisTarget[];
|
||||
zonesError: string | null;
|
||||
zones: ZonesGeoJson | null;
|
||||
legacyIndex: LegacyVesselIndex | null;
|
||||
}
|
||||
|
||||
export function DashboardSidebar({
|
||||
state,
|
||||
legacyVesselsAll,
|
||||
legacyVesselsFiltered,
|
||||
legacyCounts,
|
||||
selectedLegacyVessel,
|
||||
activeHighlightedMmsiSet,
|
||||
legacyHits,
|
||||
filteredAlarms,
|
||||
alarms,
|
||||
alarmKindCounts,
|
||||
allAlarmKindsEnabled,
|
||||
alarmFilterSummary,
|
||||
speedPanelType,
|
||||
onFleetContextMenu,
|
||||
snapshot,
|
||||
legacyError,
|
||||
legacyData,
|
||||
targetsInScope,
|
||||
zonesError,
|
||||
zones,
|
||||
legacyIndex,
|
||||
}: DashboardSidebarProps) {
|
||||
const {
|
||||
showTargets, setShowTargets, showOthers, setShowOthers,
|
||||
typeEnabled, setTypeEnabled,
|
||||
overlays, setOverlays,
|
||||
projection, setProjection, isProjectionToggleDisabled,
|
||||
selectedMmsi, setSelectedMmsi,
|
||||
fleetRelationSortMode, setFleetRelationSortMode,
|
||||
hoveredFleetOwnerKey, hoveredFleetMmsiSet,
|
||||
setHoveredMmsiSet, setHoveredPairMmsiSet,
|
||||
setHoveredFleetOwnerKey, setHoveredFleetMmsiSet,
|
||||
alarmKindEnabled, setAlarmKindEnabled,
|
||||
adminMode,
|
||||
viewBbox, useViewportFilter, setUseViewportFilter,
|
||||
useApiBbox, setUseApiBbox, setApiBbox,
|
||||
settings, setSettings,
|
||||
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
||||
} = state;
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sb">
|
||||
<div className="sb-t">업종 필터</div>
|
||||
<div className="tog">
|
||||
<div
|
||||
className={`tog-btn ${showTargets ? 'on' : ''}`}
|
||||
onClick={() => {
|
||||
setShowTargets((v) => {
|
||||
const next = !v;
|
||||
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
title="레거시(CN permit) 대상 선박 표시"
|
||||
>
|
||||
대상 선박
|
||||
</div>
|
||||
<div className={`tog-btn ${showOthers ? 'on' : ''}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||
기타 AIS
|
||||
</div>
|
||||
</div>
|
||||
<TypeFilterGrid
|
||||
enabled={typeEnabled}
|
||||
totalCount={legacyVesselsAll.length}
|
||||
countsByType={legacyCounts}
|
||||
onToggle={(code) => {
|
||||
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
|
||||
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
|
||||
}}
|
||||
onToggleAll={() => {
|
||||
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
|
||||
const nextVal = !allOn;
|
||||
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
|
||||
setTypeEnabled({
|
||||
PT: nextVal, 'PT-S': nextVal, GN: nextVal, OT: nextVal, PS: nextVal, FC: nextVal,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
지도 표시 설정
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
className={`tog-btn ${projection === 'globe' ? 'on' : ''}${isProjectionToggleDisabled ? ' disabled' : ''}`}
|
||||
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
||||
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영: 드래그로 회전, 휠로 확대/축소'}
|
||||
style={{ fontSize: 9, padding: '2px 8px', opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
3D
|
||||
</div>
|
||||
</div>
|
||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">속도 프로파일</div>
|
||||
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 260, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div className="sb-t sb-t-row">
|
||||
<div>
|
||||
선단 연관관계{' '}
|
||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relation-sort">
|
||||
<label className="relation-sort__option">
|
||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'count'} onChange={() => setFleetRelationSortMode('count')} />
|
||||
척수
|
||||
</label>
|
||||
<label className="relation-sort__option">
|
||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'range'} onChange={() => setFleetRelationSortMode('range')} />
|
||||
범위
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', minHeight: 0 }}>
|
||||
<RelationsPanel
|
||||
selectedVessel={selectedLegacyVessel}
|
||||
vessels={legacyVesselsAll}
|
||||
fleetVessels={legacyVesselsFiltered}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
||||
onHoverFleet={(ownerKey, fleetMmsis) => {
|
||||
setHoveredFleetOwnerKey(ownerKey);
|
||||
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
||||
}}
|
||||
onClearFleetHover={() => {
|
||||
setHoveredFleetOwnerKey(null);
|
||||
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
}}
|
||||
fleetSortMode={fleetRelationSortMode}
|
||||
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
|
||||
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
||||
onContextMenuFleet={onFleetContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="sb-t">
|
||||
선박 목록{' '}
|
||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
||||
({legacyVesselsFiltered.length}척)
|
||||
</span>
|
||||
</div>
|
||||
<VesselList
|
||||
vessels={legacyVesselsFiltered}
|
||||
selectedMmsi={selectedMmsi}
|
||||
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, display: 'flex', flexDirection: 'column', overflow: 'visible' }}>
|
||||
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
실시간 경고{' '}
|
||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
||||
({filteredAlarms.length}/{alarms.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} style={{ display: 'inline-flex', gap: 4, alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
<span style={{ fontSize: 8, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<details className="alarm-filter">
|
||||
<summary className="alarm-filter__summary" title="경고 종류 필터">
|
||||
{alarmFilterSummary}
|
||||
</summary>
|
||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||
<label className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAlarmKindsEnabled}
|
||||
onChange={() =>
|
||||
setAlarmKindEnabled((prev) => {
|
||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||
const nextVal = allOn ? false : true;
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||
})
|
||||
}
|
||||
/>
|
||||
전체
|
||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
||||
</label>
|
||||
<div className="alarm-filter__sep" />
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: 'auto', minHeight: 0, flex: 1 }}>
|
||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adminMode ? (
|
||||
<>
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · AIS Target Polling</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>엔드포인트</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>상태</div>
|
||||
<div>
|
||||
<b style={{ color: snapshot.status === 'ready' ? '#22C55E' : snapshot.status === 'error' ? '#EF4444' : '#F59E0B' }}>
|
||||
{snapshot.status.toUpperCase()}
|
||||
</b>
|
||||
{snapshot.error ? <span style={{ marginLeft: 6, color: '#EF4444' }}>{snapshot.error}</span> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>최근 fetch</div>
|
||||
<div>
|
||||
{fmtIsoFull(snapshot.lastFetchAt)}{' '}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>
|
||||
({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>메시지</div>
|
||||
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
|
||||
{legacyError ? (
|
||||
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>데이터셋</div>
|
||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>매칭(현재 scope)</div>
|
||||
<div>
|
||||
<b style={{ color: '#F59E0B' }}>{legacyVesselsAll.length}</b>{' '}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>생성시각</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Viewport / BBox</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>현재 View BBox</div>
|
||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => setUseViewportFilter((v) => !v)}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: useViewportFilter ? 'rgba(59,130,246,.18)' : 'var(--card)',
|
||||
color: 'var(--text)', cursor: 'pointer',
|
||||
}}
|
||||
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
|
||||
>
|
||||
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setUseApiBbox((v) => {
|
||||
const next = !v;
|
||||
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
|
||||
if (!next) setApiBbox(undefined);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: useApiBbox ? 'rgba(245,158,11,.14)' : 'var(--card)',
|
||||
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
||||
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
|
||||
disabled={!viewBbox}
|
||||
>
|
||||
API bbox {useApiBbox ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setApiBbox(fmtBbox(viewBbox));
|
||||
setUseApiBbox(true);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--card)',
|
||||
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
||||
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
disabled={!viewBbox}
|
||||
title="현재 view bbox로 API bbox를 갱신"
|
||||
>
|
||||
bbox=viewport
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: 'var(--muted)', fontSize: 10 }}>
|
||||
표시 선박: <b style={{ color: 'var(--text)' }}>{targetsInScope.length}</b> / 스토어:{' '}
|
||||
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Map (Extras)</div>
|
||||
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="sb-t">ADMIN · AIS Targets (All)</div>
|
||||
<AisTargetList
|
||||
targets={targetsInScope}
|
||||
selectedMmsi={selectedMmsi}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
legacyIndex={legacyIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, overflowY: 'auto' }}>
|
||||
<div className="sb-t">ADMIN · 수역 데이터</div>
|
||||
{zonesError ? (
|
||||
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||
{zones ? `loaded (${zones.features.length} features)` : 'loading...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
apps/web/src/pages/dashboard/useDashboardState.ts
Normal file
147
apps/web/src/pages/dashboard/useDashboardState.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePersistedState } from '../../shared/hooks';
|
||||
import type { VesselTypeCode } from '../../entities/vessel/model/types';
|
||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||
import type { LegacyAlarmKind } from '../../features/legacyDashboard/model/types';
|
||||
import { LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types';
|
||||
import type { BaseMapId, Map3DSettings, MapProjectionId } from '../../widgets/map3d/Map3D';
|
||||
import type { MapViewState } from '../../widgets/map3d/types';
|
||||
import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types';
|
||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||
import { fmtDateTimeFull } from '../../shared/lib/datetime';
|
||||
|
||||
export type Bbox = [number, number, number, number];
|
||||
export type FleetRelationSortMode = 'count' | 'range';
|
||||
|
||||
export function useDashboardState(uid: number | null) {
|
||||
// ── Map instance ──
|
||||
const [mapInstance, setMapInstance] = useState<import('maplibre-gl').Map | null>(null);
|
||||
const handleMapReady = useCallback((map: import('maplibre-gl').Map) => setMapInstance(map), []);
|
||||
|
||||
// ── Viewport / API BBox ──
|
||||
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
||||
const [useViewportFilter, setUseViewportFilter] = useState(false);
|
||||
const [useApiBbox, setUseApiBbox] = useState(false);
|
||||
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
|
||||
|
||||
// ── Selection & hover ──
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
||||
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||
|
||||
// ── Filters (persisted) ──
|
||||
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
|
||||
uid, 'typeEnabled', { PT: true, 'PT-S': true, GN: true, OT: true, PS: true, FC: true },
|
||||
);
|
||||
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
|
||||
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false);
|
||||
|
||||
// ── Map settings (persisted) ──
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [baseMap, _setBaseMap] = useState<BaseMapId>('enhanced');
|
||||
const [projection, setProjection] = useState<MapProjectionId>('mercator');
|
||||
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
||||
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
|
||||
pairLines: true, pairRange: true, fcLines: true, zones: true,
|
||||
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
|
||||
});
|
||||
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
||||
showShips: true, showDensity: false, showSeamark: false,
|
||||
});
|
||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||
|
||||
// ── Sort & alarm filters (persisted) ──
|
||||
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
|
||||
uid, 'alarmKindEnabled',
|
||||
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
|
||||
);
|
||||
|
||||
// ── Fleet focus ──
|
||||
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
|
||||
|
||||
// ── Cable ──
|
||||
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||
|
||||
// ── Track context menu ──
|
||||
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
|
||||
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => setTrackContextMenu(info), []);
|
||||
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
||||
|
||||
// ── Projection loading ──
|
||||
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
|
||||
const handleProjectionLoadingChange = useCallback((loading: boolean) => setIsProjectionLoading(loading), []);
|
||||
const showMapLoader = isProjectionLoading;
|
||||
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
|
||||
|
||||
// ── Clock ──
|
||||
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── Admin mode (7 clicks within 900ms) ──
|
||||
const [adminMode, setAdminMode] = useState(false);
|
||||
const clicksRef = useRef<number[]>([]);
|
||||
const onLogoClick = () => {
|
||||
const now = Date.now();
|
||||
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
|
||||
clicksRef.current.push(now);
|
||||
if (clicksRef.current.length >= 7) {
|
||||
clicksRef.current = [];
|
||||
setAdminMode((v) => !v);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Helpers ──
|
||||
const setUniqueSorted = (items: number[]) =>
|
||||
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
|
||||
|
||||
const setSortedIfChanged = (next: number[]) => {
|
||||
const sorted = setUniqueSorted(next);
|
||||
return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted);
|
||||
};
|
||||
|
||||
const toggleHighlightedMmsi = (mmsi: number) => {
|
||||
setHighlightedMmsiSet((prev) => {
|
||||
const s = new Set(prev);
|
||||
if (s.has(mmsi)) s.delete(mmsi);
|
||||
else s.add(mmsi);
|
||||
return Array.from(s).sort((a, b) => a - b);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
mapInstance, handleMapReady,
|
||||
viewBbox, setViewBbox, useViewportFilter, setUseViewportFilter,
|
||||
useApiBbox, setUseApiBbox, apiBbox, setApiBbox,
|
||||
selectedMmsi, setSelectedMmsi,
|
||||
highlightedMmsiSet,
|
||||
hoveredMmsiSet, setHoveredMmsiSet,
|
||||
hoveredFleetMmsiSet, setHoveredFleetMmsiSet,
|
||||
hoveredPairMmsiSet, setHoveredPairMmsiSet,
|
||||
hoveredFleetOwnerKey, setHoveredFleetOwnerKey,
|
||||
typeEnabled, setTypeEnabled, showTargets, setShowTargets, showOthers, setShowOthers,
|
||||
baseMap, projection, setProjection,
|
||||
mapStyleSettings, setMapStyleSettings,
|
||||
overlays, setOverlays, settings, setSettings,
|
||||
mapView, setMapView,
|
||||
fleetRelationSortMode, setFleetRelationSortMode,
|
||||
alarmKindEnabled, setAlarmKindEnabled,
|
||||
fleetFocus, setFleetFocus,
|
||||
hoveredCableId, setHoveredCableId, selectedCableId, setSelectedCableId,
|
||||
trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu,
|
||||
handleProjectionLoadingChange,
|
||||
isGlobeShipsReady, setIsGlobeShipsReady,
|
||||
showMapLoader, isProjectionToggleDisabled,
|
||||
clock, adminMode, onLogoClick,
|
||||
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
||||
};
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
export function hexToRgb(hex: string): [number, number, number] {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||
if (!m) return [255, 255, 255];
|
||||
const n = parseInt(m[1], 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
20
apps/web/src/shared/lib/map/mapConstants.ts
Normal file
20
apps/web/src/shared/lib/map/mapConstants.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// ── Shared map constants ──
|
||||
// Moved from widgets/map3d/constants.ts to resolve FSD layer violation
|
||||
// (features/ must not import from widgets/).
|
||||
|
||||
export const SHIP_ICON_MAPPING = {
|
||||
ship: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
anchorX: 64,
|
||||
anchorY: 64,
|
||||
mask: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const DEPTH_DISABLED_PARAMS = {
|
||||
depthCompare: 'always',
|
||||
depthWriteEnabled: false,
|
||||
} as const;
|
||||
9
apps/web/src/shared/lib/map/mapTilerKey.ts
Normal file
9
apps/web/src/shared/lib/map/mapTilerKey.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// Moved from widgets/map3d/lib/mapCore.ts to resolve FSD layer violation
|
||||
// (features/ must not import from widgets/).
|
||||
|
||||
export function getMapTilerKey(): string | null {
|
||||
const k = import.meta.env.VITE_MAPTILER_KEY;
|
||||
if (typeof k !== 'string') return null;
|
||||
const v = k.trim();
|
||||
return v ? v : null;
|
||||
}
|
||||
@ -15,18 +15,9 @@ const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
||||
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||
|
||||
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
||||
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
||||
|
||||
export const SHIP_ICON_MAPPING = {
|
||||
ship: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
anchorX: 64,
|
||||
anchorY: 64,
|
||||
mask: true,
|
||||
},
|
||||
} as const;
|
||||
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
|
||||
|
||||
// ── Ship constants ──
|
||||
|
||||
@ -70,10 +61,8 @@ export const DECK_VIEW_ID = 'mapbox';
|
||||
|
||||
// ── Depth params ──
|
||||
|
||||
export const DEPTH_DISABLED_PARAMS = {
|
||||
depthCompare: 'always',
|
||||
depthWriteEnabled: false,
|
||||
} as const;
|
||||
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
||||
export { DEPTH_DISABLED_PARAMS } from '../../shared/lib/map/mapConstants';
|
||||
|
||||
export const GLOBE_OVERLAY_PARAMS = {
|
||||
depthCompare: 'less-equal',
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
||||
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
||||
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { type PickingInfo } from '@deck.gl/core';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
@ -9,39 +7,7 @@ import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDash
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
import {
|
||||
SHIP_ICON_MAPPING,
|
||||
FLAT_SHIP_ICON_SIZE,
|
||||
FLAT_SHIP_ICON_SIZE_SELECTED,
|
||||
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
||||
FLAT_LEGACY_HALO_RADIUS,
|
||||
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
||||
EMPTY_MMSI_SET,
|
||||
DEPTH_DISABLED_PARAMS,
|
||||
GLOBE_OVERLAY_PARAMS,
|
||||
HALO_OUTLINE_COLOR,
|
||||
HALO_OUTLINE_COLOR_SELECTED,
|
||||
HALO_OUTLINE_COLOR_HIGHLIGHTED,
|
||||
PAIR_RANGE_NORMAL_DECK,
|
||||
PAIR_RANGE_WARN_DECK,
|
||||
PAIR_LINE_NORMAL_DECK,
|
||||
PAIR_LINE_WARN_DECK,
|
||||
FC_LINE_NORMAL_DECK,
|
||||
FC_LINE_SUSPICIOUS_DECK,
|
||||
FLEET_RANGE_LINE_DECK,
|
||||
FLEET_RANGE_FILL_DECK,
|
||||
PAIR_RANGE_NORMAL_DECK_HL,
|
||||
PAIR_RANGE_WARN_DECK_HL,
|
||||
PAIR_LINE_NORMAL_DECK_HL,
|
||||
PAIR_LINE_WARN_DECK_HL,
|
||||
FC_LINE_NORMAL_DECK_HL,
|
||||
FC_LINE_SUSPICIOUS_DECK_HL,
|
||||
FLEET_RANGE_LINE_DECK_HL,
|
||||
FLEET_RANGE_FILL_DECK_HL,
|
||||
} from '../constants';
|
||||
import { toSafeNumber } from '../lib/setUtils';
|
||||
import { getDisplayHeading, getShipColor } from '../lib/shipUtils';
|
||||
import {
|
||||
getShipTooltipHtml,
|
||||
getPairLinkTooltipHtml,
|
||||
@ -50,7 +16,7 @@ import {
|
||||
getFleetCircleTooltipHtml,
|
||||
} from '../lib/tooltips';
|
||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||
import { getCachedShipIcon } from '../lib/shipIconCache';
|
||||
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
||||
|
||||
// NOTE:
|
||||
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
||||
@ -151,343 +117,37 @@ export function useDeckLayers(
|
||||
const deckTarget = ensureMercatorOverlay();
|
||||
if (!deckTarget) return;
|
||||
|
||||
const layers: unknown[] = [];
|
||||
const overlayParams = DEPTH_DISABLED_PARAMS;
|
||||
const clearDeckHover = () => {
|
||||
touchDeckHoverState(false);
|
||||
};
|
||||
const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false);
|
||||
const shipOtherData: AisTarget[] = [];
|
||||
const shipTargetData: AisTarget[] = [];
|
||||
for (const t of shipLayerData) {
|
||||
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||
else shipOtherData.push(t);
|
||||
}
|
||||
const shipOverlayOtherData: AisTarget[] = [];
|
||||
const shipOverlayTargetData: AisTarget[] = [];
|
||||
for (const t of shipOverlayLayerData) {
|
||||
if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t);
|
||||
else shipOverlayOtherData.push(t);
|
||||
}
|
||||
|
||||
if (settings.showDensity) {
|
||||
layers.push(
|
||||
new HexagonLayer<AisTarget>({
|
||||
id: 'density',
|
||||
data: shipLayerData,
|
||||
pickable: true,
|
||||
extruded: true,
|
||||
radius: 2500,
|
||||
elevationScale: 35,
|
||||
coverage: 0.92,
|
||||
opacity: 0.35,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (overlays.pairRange && pairRanges.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<PairRangeCircle>({
|
||||
id: 'pair-range',
|
||||
data: pairRanges,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
radiusMinPixels: 10,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 1,
|
||||
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
||||
getPosition: (d) => d.center,
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const p = info.object as PairRangeCircle;
|
||||
setDeckHoverPairs([p.aMmsi, p.bMmsi]);
|
||||
setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) { onSelectMmsi(null); return; }
|
||||
const obj = info.object as PairRangeCircle;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
onToggleHighlightMmsi?.(obj.aMmsi);
|
||||
onToggleHighlightMmsi?.(obj.bMmsi);
|
||||
return;
|
||||
}
|
||||
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||
layers.push(
|
||||
new LineLayer<PairLink>({
|
||||
id: 'pair-lines',
|
||||
data: pairLinks,
|
||||
pickable: true,
|
||||
parameters: overlayParams,
|
||||
getSourcePosition: (d) => d.from,
|
||||
getTargetPosition: (d) => d.to,
|
||||
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
|
||||
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
||||
widthUnits: 'pixels',
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as PairLink;
|
||||
setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
|
||||
setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as PairLink;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
onToggleHighlightMmsi?.(obj.aMmsi);
|
||||
onToggleHighlightMmsi?.(obj.bMmsi);
|
||||
return;
|
||||
}
|
||||
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (overlays.fcLines && fcDashed.length > 0) {
|
||||
layers.push(
|
||||
new LineLayer<DashSeg>({
|
||||
id: 'fc-lines',
|
||||
data: fcDashed,
|
||||
pickable: true,
|
||||
parameters: overlayParams,
|
||||
getSourcePosition: (d) => d.from,
|
||||
getTargetPosition: (d) => d.to,
|
||||
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
||||
getWidth: () => 1.3,
|
||||
widthUnits: 'pixels',
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as DashSeg;
|
||||
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
|
||||
setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
|
||||
setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as DashSeg;
|
||||
if (obj.fromMmsi == null || obj.toMmsi == null) return;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
onToggleHighlightMmsi?.(obj.fromMmsi);
|
||||
onToggleHighlightMmsi?.(obj.toMmsi);
|
||||
return;
|
||||
}
|
||||
onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<FleetCircle>({
|
||||
id: 'fleet-circles',
|
||||
data: fleetCircles,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 1.1,
|
||||
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
||||
getPosition: (d) => d.center,
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||
setMapFleetHoverState(obj.ownerKey || null, list);
|
||||
setDeckHoverMmsi(list);
|
||||
clearDeckHoverPairs();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
for (const mmsi of list) onToggleHighlightMmsi?.(mmsi);
|
||||
return;
|
||||
}
|
||||
const first = list[0];
|
||||
if (first != null) onDeckSelectOrHighlight({ mmsi: first });
|
||||
},
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
new ScatterplotLayer<FleetCircle>({
|
||||
id: 'fleet-circles-fill',
|
||||
data: fleetCircles,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: true,
|
||||
stroked: false,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
getFillColor: () => FLEET_RANGE_FILL_DECK,
|
||||
getPosition: (d) => d.center,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.showShips) {
|
||||
const shipOnHover = (info: PickingInfo) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as AisTarget;
|
||||
setDeckHoverMmsi([obj.mmsi]);
|
||||
clearDeckHoverPairs();
|
||||
clearMapFleetHoverState();
|
||||
};
|
||||
const shipOnClick = (info: PickingInfo) => {
|
||||
if (!info.object) { onSelectMmsi(null); return; }
|
||||
onDeckSelectOrHighlight(
|
||||
{
|
||||
mmsi: (info.object as AisTarget).mmsi,
|
||||
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
if (shipOtherData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-other',
|
||||
data: shipOtherData,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: () => FLAT_SHIP_ICON_SIZE,
|
||||
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
|
||||
onHover: shipOnHover,
|
||||
onClick: shipOnClick,
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (shipOverlayOtherData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-overlay-other',
|
||||
data: shipOverlayOtherData,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: (d) => {
|
||||
if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
|
||||
if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
|
||||
return 0;
|
||||
},
|
||||
getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet),
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (legacyTargetsOrdered.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<AisTarget>({
|
||||
id: 'legacy-halo',
|
||||
data: legacyTargetsOrdered,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'pixels',
|
||||
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 2,
|
||||
getLineColor: () => HALO_OUTLINE_COLOR,
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (shipTargetData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-target',
|
||||
data: shipTargetData,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: () => FLAT_SHIP_ICON_SIZE,
|
||||
getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
||||
onHover: shipOnHover,
|
||||
onClick: shipOnClick,
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (pairRangesInteractive.length > 0) {
|
||||
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
||||
}
|
||||
if (pairLinksInteractive.length > 0) {
|
||||
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
|
||||
}
|
||||
if (fcLinesInteractive.length > 0) {
|
||||
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
|
||||
}
|
||||
if (fleetCirclesInteractive.length > 0) {
|
||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
||||
}
|
||||
|
||||
if (settings.showShips && legacyOverlayTargets.length > 0) {
|
||||
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||
}
|
||||
|
||||
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
|
||||
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
|
||||
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
|
||||
}
|
||||
const layers = buildMercatorDeckLayers({
|
||||
shipLayerData,
|
||||
shipOverlayLayerData,
|
||||
legacyTargetsOrdered,
|
||||
legacyOverlayTargets,
|
||||
legacyHits,
|
||||
pairLinks,
|
||||
fcDashed,
|
||||
fleetCircles,
|
||||
pairRanges,
|
||||
pairLinksInteractive,
|
||||
pairRangesInteractive,
|
||||
fcLinesInteractive,
|
||||
fleetCirclesInteractive,
|
||||
overlays,
|
||||
showDensity: settings.showDensity,
|
||||
showShips: settings.showShips,
|
||||
selectedMmsi,
|
||||
shipHighlightSet,
|
||||
touchDeckHoverState,
|
||||
setDeckHoverPairs,
|
||||
setDeckHoverMmsi,
|
||||
clearDeckHoverPairs,
|
||||
clearMapFleetHoverState,
|
||||
setMapFleetHoverState,
|
||||
toFleetMmsiList,
|
||||
hasAuxiliarySelectModifier,
|
||||
onSelectMmsi,
|
||||
onToggleHighlightMmsi,
|
||||
onDeckSelectOrHighlight,
|
||||
});
|
||||
|
||||
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
||||
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
||||
@ -610,32 +270,28 @@ export function useDeckLayers(
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const overlayParams = GLOBE_OVERLAY_PARAMS;
|
||||
const globeLayers: unknown[] = [];
|
||||
|
||||
if (overlays.pairRange && pairRanges.length > 0) {
|
||||
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||
const links = pairLinks || [];
|
||||
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (overlays.fcLines && fcDashed.length > 0) {
|
||||
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||
const circles = fleetCircles || [];
|
||||
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } }));
|
||||
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
|
||||
}
|
||||
|
||||
if (settings.showShips && legacyTargetsOrdered.length > 0) {
|
||||
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||
}
|
||||
const globeLayers = buildGlobeDeckLayers({
|
||||
pairRanges,
|
||||
pairLinks,
|
||||
fcDashed,
|
||||
fleetCircles,
|
||||
legacyTargetsOrdered,
|
||||
legacyHits,
|
||||
overlays,
|
||||
showShips: settings.showShips,
|
||||
selectedMmsi,
|
||||
isHighlightedFleet,
|
||||
isHighlightedPair,
|
||||
isHighlightedMmsi,
|
||||
touchDeckHoverState,
|
||||
setDeckHoverPairs,
|
||||
setDeckHoverMmsi,
|
||||
clearDeckHoverPairs,
|
||||
clearDeckHoverMmsi,
|
||||
clearMapFleetHoverState,
|
||||
setMapFleetHoverState,
|
||||
toFleetMmsiList,
|
||||
});
|
||||
|
||||
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
|
||||
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
|
||||
|
||||
356
apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
Normal file
356
apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
Normal file
@ -0,0 +1,356 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, MapProjectionId } from '../types';
|
||||
import {
|
||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||
FLEET_FILL_ML_HL,
|
||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||
} from '../constants';
|
||||
import { makeUniqueSorted } from '../lib/setUtils';
|
||||
import {
|
||||
makeFcSegmentFeatureId,
|
||||
makeFleetCircleFeatureId,
|
||||
} from '../lib/featureIds';
|
||||
import {
|
||||
makeMmsiAnyEndpointExpr,
|
||||
makeFleetOwnerMatchExpr,
|
||||
makeFleetMemberMatchExpr,
|
||||
} from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
import { dashifyLine } from '../lib/dashifyLine';
|
||||
|
||||
/** Globe FC lines + fleet circles 오버레이 */
|
||||
export function useGlobeFcFleetOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
fcLinks: FcLink[] | undefined;
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
projection: MapProjectionId;
|
||||
mapSyncEpoch: number;
|
||||
hoveredFleetMmsiList: number[];
|
||||
hoveredFleetOwnerKeyList: string[];
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
overlays, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||
} = opts;
|
||||
|
||||
// FC lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fc-lines-ml-src';
|
||||
const layerId = 'fc-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
const fcHoverActive = fleetAwarePairMmsiList.length > 0;
|
||||
if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const segs: DashSeg[] = [];
|
||||
for (const l of fcLinks || []) {
|
||||
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
||||
}
|
||||
if (segs.length === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: segs.map((s, idx) => ({
|
||||
type: 'Feature',
|
||||
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
|
||||
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
|
||||
properties: {
|
||||
type: 'fc',
|
||||
suspicious: s.suspicious,
|
||||
distanceNm: s.distanceNm,
|
||||
fcMmsi: s.fromMmsi ?? -1,
|
||||
otherMmsi: s.toMmsi ?? -1,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('FC lines source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'suspicious'], false],
|
||||
FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('FC lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fcLines,
|
||||
fcLinks,
|
||||
hoveredPairMmsiList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// Fleet circles
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fleet-circles-ml-src';
|
||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
||||
const layerId = 'fleet-circles-ml';
|
||||
const fillLayerId = 'fleet-circles-ml-fill';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
guardedSetVisibility(map, fillLayerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||
if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const circles = fleetCircles || [];
|
||||
const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) =>
|
||||
hoveredFleetOwnerKeyList.includes(ownerKey) ||
|
||||
(hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi)));
|
||||
|
||||
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: circles.map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(c.ownerKey),
|
||||
geometry: { type: 'LineString', coordinates: ring },
|
||||
properties: {
|
||||
type: 'fleet',
|
||||
ownerKey: c.ownerKey,
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||
type: 'FeatureCollection',
|
||||
features: circles
|
||||
.filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis))
|
||||
.map((c) => ({
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`),
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)],
|
||||
},
|
||||
properties: {
|
||||
ownerKey: c.ownerKey,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fcLine);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
|
||||
if (existingFill) existingFill.setData(fcFill);
|
||||
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill source setup failed:', e);
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(fillLayerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: fillSrcId,
|
||||
layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' },
|
||||
paint: {
|
||||
'fill-color': FLEET_FILL_ML_HL,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fleetCircles,
|
||||
fleetCircles,
|
||||
hoveredFleetOwnerKeyList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// FC + Fleet paint state updates
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updateFcFleetPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
|
||||
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
|
||||
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
|
||||
: false;
|
||||
|
||||
const fleetOwnerMatchExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
|
||||
const fleetMemberExpr =
|
||||
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
|
||||
const fleetHighlightExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
|
||||
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||
: false;
|
||||
|
||||
try {
|
||||
if (map.getLayer('fc-lines-ml')) {
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-color',
|
||||
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-width',
|
||||
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('fleet-circles-ml')) {
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updateFcFleetPaintStates);
|
||||
updateFcFleetPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]);
|
||||
}
|
||||
@ -1,35 +1,10 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types';
|
||||
import {
|
||||
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
|
||||
PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL,
|
||||
PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML,
|
||||
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||
FLEET_FILL_ML_HL,
|
||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||
} from '../constants';
|
||||
import { makeUniqueSorted } from '../lib/setUtils';
|
||||
import {
|
||||
makePairLinkFeatureId,
|
||||
makeFcSegmentFeatureId,
|
||||
makeFleetCircleFeatureId,
|
||||
} from '../lib/featureIds';
|
||||
import {
|
||||
makeMmsiPairHighlightExpr,
|
||||
makeMmsiAnyEndpointExpr,
|
||||
makeFleetOwnerMatchExpr,
|
||||
makeFleetMemberMatchExpr,
|
||||
} from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
import { dashifyLine } from '../lib/dashifyLine';
|
||||
import type { MapProjectionId } from '../types';
|
||||
import { useGlobePairOverlay } from './useGlobePairOverlay';
|
||||
import { useGlobeFcFleetOverlay } from './useGlobeFcFleetOverlay';
|
||||
|
||||
export function useGlobeOverlays(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -47,554 +22,24 @@ export function useGlobeOverlays(
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||
} = opts;
|
||||
// Pair lines + pair range
|
||||
useGlobePairOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||
overlays: opts.overlays,
|
||||
pairLinks: opts.pairLinks,
|
||||
projection: opts.projection,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
hoveredPairMmsiList: opts.hoveredPairMmsiList,
|
||||
});
|
||||
|
||||
// Pair lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'pair-lines-ml-src';
|
||||
const layerId = 'pair-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
||||
if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: (pairLinks || []).map((p) => ({
|
||||
type: 'Feature',
|
||||
id: makePairLinkFeatureId(p.aMmsi, p.bMmsi),
|
||||
geometry: { type: 'LineString', coordinates: [p.from, p.to] },
|
||||
properties: {
|
||||
type: 'pair',
|
||||
aMmsi: p.aMmsi,
|
||||
bMmsi: p.bMmsi,
|
||||
distanceNm: p.distanceNm,
|
||||
warn: p.warn,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Pair lines source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'warn'], false],
|
||||
PAIR_LINE_WARN_ML,
|
||||
PAIR_LINE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1], 2.8,
|
||||
['boolean', ['get', 'warn'], false], 2.2,
|
||||
1.4,
|
||||
] as never,
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Pair lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// FC lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fc-lines-ml-src';
|
||||
const layerId = 'fc-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
const fcHoverActive = fleetAwarePairMmsiList.length > 0;
|
||||
if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const segs: DashSeg[] = [];
|
||||
for (const l of fcLinks || []) {
|
||||
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
||||
}
|
||||
if (segs.length === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: segs.map((s, idx) => ({
|
||||
type: 'Feature',
|
||||
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
|
||||
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
|
||||
properties: {
|
||||
type: 'fc',
|
||||
suspicious: s.suspicious,
|
||||
distanceNm: s.distanceNm,
|
||||
fcMmsi: s.fromMmsi ?? -1,
|
||||
otherMmsi: s.toMmsi ?? -1,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('FC lines source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'suspicious'], false],
|
||||
FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('FC lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fcLines,
|
||||
fcLinks,
|
||||
hoveredPairMmsiList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// Fleet circles
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fleet-circles-ml-src';
|
||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
||||
const layerId = 'fleet-circles-ml';
|
||||
const fillLayerId = 'fleet-circles-ml-fill';
|
||||
|
||||
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
|
||||
// 라인만으로 fleet circle 시각화 충분
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
guardedSetVisibility(map, fillLayerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||
if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const circles = fleetCircles || [];
|
||||
const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) =>
|
||||
hoveredFleetOwnerKeyList.includes(ownerKey) ||
|
||||
(hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi)));
|
||||
|
||||
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: circles.map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(c.ownerKey),
|
||||
geometry: { type: 'LineString', coordinates: ring },
|
||||
properties: {
|
||||
type: 'fleet',
|
||||
ownerKey: c.ownerKey,
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||
type: 'FeatureCollection',
|
||||
features: circles
|
||||
.filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis))
|
||||
.map((c) => ({
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`),
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)],
|
||||
},
|
||||
properties: {
|
||||
ownerKey: c.ownerKey,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fcLine);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
|
||||
if (existingFill) existingFill.setData(fcFill);
|
||||
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill source setup failed:', e);
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(fillLayerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: fillSrcId,
|
||||
layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' },
|
||||
paint: {
|
||||
'fill-color': FLEET_FILL_ML_HL,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fleetCircles,
|
||||
fleetCircles,
|
||||
hoveredFleetOwnerKeyList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// Pair range
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'pair-range-ml-src';
|
||||
const layerId = 'pair-range-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
||||
if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const ranges: PairRangeCircle[] = [];
|
||||
for (const p of pairLinks || []) {
|
||||
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
|
||||
ranges.push({
|
||||
center,
|
||||
radiusNm: Math.max(0.05, p.distanceNm / 2),
|
||||
warn: p.warn,
|
||||
aMmsi: p.aMmsi,
|
||||
bMmsi: p.bMmsi,
|
||||
distanceNm: p.distanceNm,
|
||||
});
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: ranges.map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
|
||||
geometry: { type: 'LineString', coordinates: ring },
|
||||
properties: {
|
||||
type: 'pair-range',
|
||||
warn: c.warn,
|
||||
aMmsi: c.aMmsi,
|
||||
bMmsi: c.bMmsi,
|
||||
distanceNm: c.distanceNm,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Pair range source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'warn'], false],
|
||||
PAIR_RANGE_WARN_ML,
|
||||
PAIR_RANGE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Pair range layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Paint state updates for hover highlights
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updateGlobeOverlayPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
|
||||
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
||||
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||
: false;
|
||||
|
||||
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
|
||||
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
|
||||
: false;
|
||||
|
||||
const fleetOwnerMatchExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
|
||||
const fleetMemberExpr =
|
||||
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
|
||||
const fleetHighlightExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
|
||||
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||
: false;
|
||||
|
||||
try {
|
||||
if (map.getLayer('pair-lines-ml')) {
|
||||
map.setPaintProperty(
|
||||
'pair-lines-ml', 'line-color',
|
||||
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'pair-lines-ml', 'line-width',
|
||||
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('fc-lines-ml')) {
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-color',
|
||||
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-width',
|
||||
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('pair-range-ml')) {
|
||||
map.setPaintProperty(
|
||||
'pair-range-ml', 'line-color',
|
||||
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'pair-range-ml', 'line-width',
|
||||
['case', pairHighlightExpr, 1.6, 1.0] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
// fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인)
|
||||
if (map.getLayer('fleet-circles-ml')) {
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates);
|
||||
updateGlobeOverlayPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]);
|
||||
// FC lines + fleet circles
|
||||
useGlobeFcFleetOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||
overlays: opts.overlays,
|
||||
fcLinks: opts.fcLinks,
|
||||
fleetCircles: opts.fleetCircles,
|
||||
projection: opts.projection,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
hoveredFleetMmsiList: opts.hoveredFleetMmsiList,
|
||||
hoveredFleetOwnerKeyList: opts.hoveredFleetOwnerKeyList,
|
||||
hoveredPairMmsiList: opts.hoveredPairMmsiList,
|
||||
});
|
||||
}
|
||||
|
||||
284
apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts
Normal file
284
apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts
Normal file
@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { MapProjectionId, PairRangeCircle } from '../types';
|
||||
import {
|
||||
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
|
||||
PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL,
|
||||
PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML,
|
||||
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
||||
} from '../constants';
|
||||
import { makePairLinkFeatureId } from '../lib/featureIds';
|
||||
import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
/** Globe pair lines + pair range 오버레이 */
|
||||
export function useGlobePairOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
pairLinks: PairLink[] | undefined;
|
||||
projection: MapProjectionId;
|
||||
mapSyncEpoch: number;
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
|
||||
|
||||
// Pair lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'pair-lines-ml-src';
|
||||
const layerId = 'pair-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
||||
if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: (pairLinks || []).map((p) => ({
|
||||
type: 'Feature',
|
||||
id: makePairLinkFeatureId(p.aMmsi, p.bMmsi),
|
||||
geometry: { type: 'LineString', coordinates: [p.from, p.to] },
|
||||
properties: {
|
||||
type: 'pair',
|
||||
aMmsi: p.aMmsi,
|
||||
bMmsi: p.bMmsi,
|
||||
distanceNm: p.distanceNm,
|
||||
warn: p.warn,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Pair lines source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'warn'], false],
|
||||
PAIR_LINE_WARN_ML,
|
||||
PAIR_LINE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1], 2.8,
|
||||
['boolean', ['get', 'warn'], false], 2.2,
|
||||
1.4,
|
||||
] as never,
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Pair lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Pair range
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'pair-range-ml-src';
|
||||
const layerId = 'pair-range-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
||||
if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const ranges: PairRangeCircle[] = [];
|
||||
for (const p of pairLinks || []) {
|
||||
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
|
||||
ranges.push({
|
||||
center,
|
||||
radiusNm: Math.max(0.05, p.distanceNm / 2),
|
||||
warn: p.warn,
|
||||
aMmsi: p.aMmsi,
|
||||
bMmsi: p.bMmsi,
|
||||
distanceNm: p.distanceNm,
|
||||
});
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: ranges.map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
|
||||
geometry: { type: 'LineString', coordinates: ring },
|
||||
properties: {
|
||||
type: 'pair-range',
|
||||
warn: c.warn,
|
||||
aMmsi: c.aMmsi,
|
||||
bMmsi: c.bMmsi,
|
||||
distanceNm: c.distanceNm,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Pair range source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'warn'], false],
|
||||
PAIR_RANGE_WARN_ML,
|
||||
PAIR_RANGE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Pair range layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Pair paint state updates
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updatePairPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
||||
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||
: false;
|
||||
|
||||
try {
|
||||
if (map.getLayer('pair-lines-ml')) {
|
||||
map.setPaintProperty(
|
||||
'pair-lines-ml', 'line-color',
|
||||
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'pair-lines-ml', 'line-width',
|
||||
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('pair-range-ml')) {
|
||||
map.setPaintProperty(
|
||||
'pair-range-ml', 'line-color',
|
||||
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'pair-range-ml', 'line-width',
|
||||
['case', pairHighlightExpr, 1.6, 1.0] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredPairMmsiList]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updatePairPaintStates);
|
||||
updatePairPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
|
||||
}
|
||||
369
apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts
Normal file
369
apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts
Normal file
@ -0,0 +1,369 @@
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||
import {
|
||||
GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
DEG2RAD,
|
||||
} from '../constants';
|
||||
import { isFiniteNumber } from '../lib/setUtils';
|
||||
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils';
|
||||
import { ensureFallbackShipImage } from '../lib/globeShipIcon';
|
||||
import { clampNumber } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
/** Globe 호버 오버레이 + 클릭 선택 */
|
||||
export function useGlobeShipHover(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
shipLayerData: AisTarget[];
|
||||
shipHoverOverlaySet: Set<number>;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
selectedMmsi: number | null;
|
||||
mapSyncEpoch: number;
|
||||
onSelectMmsi: (mmsi: number | null) => void;
|
||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||
targets: AisTarget[];
|
||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipLayerData, shipHoverOverlaySet, legacyHits,
|
||||
selectedMmsi, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi,
|
||||
targets, hasAuxiliarySelectModifier,
|
||||
} = opts;
|
||||
|
||||
const epochRef = useRef(-1);
|
||||
const hoverSignatureRef = useRef('');
|
||||
|
||||
// Globe hover overlay ships
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const imgId = 'ship-globe-icon';
|
||||
const srcId = 'ships-globe-hover-src';
|
||||
const haloId = 'ships-globe-hover-halo';
|
||||
const outlineId = 'ships-globe-hover-outline';
|
||||
const symbolId = 'ships-globe-hover';
|
||||
|
||||
const hideHover = () => {
|
||||
for (const id of [symbolId, outlineId, haloId]) {
|
||||
guardedSetVisibility(map, id, 'none');
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
||||
hideHover();
|
||||
return;
|
||||
}
|
||||
|
||||
if (epochRef.current !== mapSyncEpoch) {
|
||||
epochRef.current = mapSyncEpoch;
|
||||
}
|
||||
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
if (!map.hasImage(imgId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
|
||||
if (hovered.length === 0) {
|
||||
hideHover();
|
||||
return;
|
||||
}
|
||||
const hoverSignature = hovered
|
||||
.map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`)
|
||||
.join('|');
|
||||
const hasHoverSource = map.getSource(srcId) != null;
|
||||
const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id));
|
||||
if (hoverSignature === hoverSignatureRef.current && hasHoverSource && hasHoverLayers) {
|
||||
return;
|
||||
}
|
||||
hoverSignatureRef.current = hoverSignature;
|
||||
const needReorder = !hasHoverSource || !hasHoverLayers;
|
||||
|
||||
const hoverGeojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
||||
type: 'FeatureCollection',
|
||||
features: hovered.map((t) => {
|
||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||
const heading = getDisplayHeading({
|
||||
cog: t.cog,
|
||||
heading: t.heading,
|
||||
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
});
|
||||
const hull = clampNumber(
|
||||
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||
50,
|
||||
420,
|
||||
);
|
||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||
const selected = t.mmsi === selectedMmsi;
|
||||
const scale = selected ? 1.16 : 1.1;
|
||||
return {
|
||||
type: 'Feature',
|
||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
name: t.name || '',
|
||||
cog: heading,
|
||||
heading,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||
shipColor: getGlobeBaseShipColor({
|
||||
legacy: legacy?.shipCode || null,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||
}),
|
||||
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
|
||||
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
|
||||
iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1),
|
||||
iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0),
|
||||
iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0),
|
||||
selected: selected ? 1 : 0,
|
||||
permitted: legacy ? 1 : 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(hoverGeojson);
|
||||
else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Ship hover source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const before = undefined;
|
||||
|
||||
if (!map.getLayer(haloId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: haloId,
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
layout: {
|
||||
visibility: 'visible',
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 120,
|
||||
['==', ['get', 'permitted'], 1], 115,
|
||||
110,
|
||||
] as never,
|
||||
},
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
|
||||
'rgba(245,158,11,1)',
|
||||
] as never,
|
||||
'circle-opacity': 0.42,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship hover halo layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(haloId, 'visibility', 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(outlineId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: outlineId,
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-stroke-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
'rgba(245,158,11,0.95)',
|
||||
] as never,
|
||||
'circle-stroke-width': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 3.8,
|
||||
2.2,
|
||||
] as never,
|
||||
'circle-stroke-opacity': 0.9,
|
||||
},
|
||||
layout: {
|
||||
visibility: 'visible',
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 121,
|
||||
['==', ['get', 'permitted'], 1], 116,
|
||||
111,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship hover outline layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(outlineId, 'visibility', 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(symbolId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: symbolId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
layout: {
|
||||
visibility: 'visible',
|
||||
'symbol-sort-key': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 122,
|
||||
['==', ['get', 'permitted'], 1], 117,
|
||||
112,
|
||||
] as never,
|
||||
'icon-image': imgId,
|
||||
'icon-size': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
3, ['to-number', ['get', 'iconSize3'], 0.35],
|
||||
7, ['to-number', ['get', 'iconSize7'], 0.45],
|
||||
10, ['to-number', ['get', 'iconSize10'], 0.58],
|
||||
14, ['to-number', ['get', 'iconSize14'], 0.85],
|
||||
18, ['to-number', ['get', 'iconSize18'], 2.5],
|
||||
] as unknown as number[],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'icon-anchor': 'center',
|
||||
'icon-rotate': ['to-number', ['get', 'heading'], 0],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-pitch-alignment': 'map',
|
||||
},
|
||||
paint: {
|
||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'icon-opacity': 1,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship hover symbol layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(symbolId, 'visibility', 'visible');
|
||||
}
|
||||
|
||||
if (needReorder) {
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
settings.showShips,
|
||||
shipLayerData,
|
||||
legacyHits,
|
||||
shipHoverOverlaySet,
|
||||
selectedMmsi,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// Globe ship click selection
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (projection !== 'globe' || !settings.showShips) return;
|
||||
|
||||
const symbolId = 'ships-globe';
|
||||
const symbolLiteId = 'ships-globe-lite';
|
||||
const haloId = 'ships-globe-halo';
|
||||
const outlineId = 'ships-globe-outline';
|
||||
const clickedRadiusDeg2 = Math.pow(0.08, 2);
|
||||
|
||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||
try {
|
||||
const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id));
|
||||
let feats: unknown[] = [];
|
||||
if (layerIds.length > 0) {
|
||||
try {
|
||||
feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[];
|
||||
} catch {
|
||||
feats = [];
|
||||
}
|
||||
}
|
||||
const f = feats?.[0];
|
||||
const props = ((f as { properties?: Record<string, unknown> } | undefined)?.properties || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const mmsi = Number(props.mmsi);
|
||||
if (Number.isFinite(mmsi)) {
|
||||
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
|
||||
onToggleHighlightMmsi?.(mmsi);
|
||||
return;
|
||||
}
|
||||
onSelectMmsi(mmsi);
|
||||
return;
|
||||
}
|
||||
|
||||
const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng };
|
||||
const cosLat = Math.cos(clicked.lat * DEG2RAD);
|
||||
let bestMmsi: number | null = null;
|
||||
let bestD2 = Number.POSITIVE_INFINITY;
|
||||
for (const t of targets) {
|
||||
if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue;
|
||||
const dLon = (clicked.lon - t.lon) * cosLat;
|
||||
const dLat = clicked.lat - t.lat;
|
||||
const d2 = dLon * dLon + dLat * dLat;
|
||||
if (d2 <= clickedRadiusDeg2 && d2 < bestD2) {
|
||||
bestD2 = d2;
|
||||
bestMmsi = t.mmsi;
|
||||
}
|
||||
}
|
||||
if (bestMmsi != null) {
|
||||
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
|
||||
onToggleHighlightMmsi?.(bestMmsi);
|
||||
return;
|
||||
}
|
||||
onSelectMmsi(bestMmsi);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onSelectMmsi(null);
|
||||
};
|
||||
|
||||
map.on('click', onClick);
|
||||
return () => {
|
||||
try {
|
||||
map.off('click', onClick);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]);
|
||||
}
|
||||
164
apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts
Normal file
164
apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { useEffect, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
|
||||
/** Mercator 모드 선명 라벨 (허가 선박 + 선택/하이라이트) */
|
||||
export function useGlobeShipLabels(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
shipData: AisTarget[];
|
||||
shipHighlightSet: Set<number>;
|
||||
overlays: MapToggleState;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
selectedMmsi: number | null;
|
||||
mapSyncEpoch: number;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, shipHighlightSet,
|
||||
overlays, legacyHits, selectedMmsi, mapSyncEpoch,
|
||||
} = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'ship-labels-src';
|
||||
const layerId = 'ship-labels';
|
||||
|
||||
const remove = () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getSource(srcId)) map.removeSource(srcId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (projection !== 'mercator' || !settings.showShips) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const visibility = overlays.shipLabels ? 'visible' : 'none';
|
||||
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
||||
for (const t of shipData) {
|
||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||
const isTarget = !!legacy;
|
||||
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
|
||||
const isPinnedHighlight = shipHighlightSet.has(t.mmsi);
|
||||
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
|
||||
|
||||
const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim();
|
||||
if (!labelName) continue;
|
||||
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: `ship-label-${t.mmsi}`,
|
||||
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
labelName,
|
||||
selected: isSelected ? 1 : 0,
|
||||
highlighted: isPinnedHighlight ? 1 : 0,
|
||||
permitted: isTarget ? 1 : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.Point> = { type: 'FeatureCollection', features };
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Ship label source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[];
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
minzoom: 7,
|
||||
filter: filter as never,
|
||||
layout: {
|
||||
visibility,
|
||||
'symbol-placement': 'point',
|
||||
'text-field': ['get', 'labelName'] as never,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
||||
'text-anchor': 'top',
|
||||
'text-offset': [0, 1.1],
|
||||
'text-padding': 2,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1],
|
||||
'rgba(14,234,255,0.95)',
|
||||
['==', ['get', '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,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship label layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
settings.showShips,
|
||||
overlays.shipLabels,
|
||||
shipData,
|
||||
legacyHits,
|
||||
selectedMmsi,
|
||||
shipHighlightSet,
|
||||
mapSyncEpoch,
|
||||
]);
|
||||
}
|
||||
501
apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts
Normal file
501
apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts
Normal file
@ -0,0 +1,501 @@
|
||||
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||
import {
|
||||
ANCHORED_SHIP_ICON_ID,
|
||||
GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
GLOBE_OUTLINE_PERMITTED,
|
||||
GLOBE_OUTLINE_OTHER,
|
||||
} from '../constants';
|
||||
import { isFiniteNumber } from '../lib/setUtils';
|
||||
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import {
|
||||
isAnchoredShip,
|
||||
getDisplayHeading,
|
||||
getGlobeBaseShipColor,
|
||||
} from '../lib/shipUtils';
|
||||
import {
|
||||
buildFallbackGlobeAnchoredShipIcon,
|
||||
ensureFallbackShipImage,
|
||||
} from '../lib/globeShipIcon';
|
||||
import { clampNumber } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label) */
|
||||
export function useGlobeShipLayers(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
shipData: AisTarget[];
|
||||
overlays: MapToggleState;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
selectedMmsi: number | null;
|
||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||
mapSyncEpoch: number;
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, overlays, legacyHits,
|
||||
selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
|
||||
} = opts;
|
||||
|
||||
const epochRef = useRef(-1);
|
||||
|
||||
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
||||
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
||||
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: shipData.map((t) => {
|
||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||
const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
||||
const heading = getDisplayHeading({
|
||||
cog: t.cog,
|
||||
heading: t.heading,
|
||||
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
});
|
||||
const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
|
||||
const shipHeading = isAnchored ? 0 : heading;
|
||||
const hull = clampNumber(
|
||||
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||
50, 420,
|
||||
);
|
||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||
const selected = t.mmsi === selectedMmsi;
|
||||
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
||||
const selectedScale = selected ? 1.08 : 1;
|
||||
const highlightScale = highlighted ? 1.06 : 1;
|
||||
const iconScale = selected ? selectedScale : highlightScale;
|
||||
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
|
||||
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
|
||||
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
|
||||
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
|
||||
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
name: t.name || '',
|
||||
labelName,
|
||||
cog: shipHeading,
|
||||
heading: shipHeading,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||
isAnchored: isAnchored ? 1 : 0,
|
||||
shipColor: getGlobeBaseShipColor({
|
||||
legacy: legacy?.shipCode || null,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||
}),
|
||||
iconSize3: iconSize3 * iconScale,
|
||||
iconSize7: iconSize7 * iconScale,
|
||||
iconSize10: iconSize10 * iconScale,
|
||||
iconSize14: iconSize14 * iconScale,
|
||||
iconSize18: iconSize18 * iconScale,
|
||||
sizeScale,
|
||||
selected: selected ? 1 : 0,
|
||||
highlighted: highlighted ? 1 : 0,
|
||||
permitted: legacy ? 1 : 0,
|
||||
code: legacy?.shipCode || '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
|
||||
|
||||
// Ships in globe mode
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const imgId = 'ship-globe-icon';
|
||||
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
|
||||
const srcId = 'ships-globe-src';
|
||||
const haloId = 'ships-globe-halo';
|
||||
const outlineId = 'ships-globe-outline';
|
||||
const symbolLiteId = 'ships-globe-lite';
|
||||
const symbolId = 'ships-globe';
|
||||
const labelId = 'ships-globe-label';
|
||||
|
||||
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||
const hide = () => {
|
||||
for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) {
|
||||
guardedSetVisibility(map, id, 'none');
|
||||
}
|
||||
};
|
||||
|
||||
const ensureImage = () => {
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
||||
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (!settings.showShips) {
|
||||
hide();
|
||||
onGlobeShipsReady?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 빠른 visibility 토글 — projectionBusy 중에도 실행
|
||||
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
|
||||
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
|
||||
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
|
||||
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
|
||||
if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) {
|
||||
const changed =
|
||||
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
|
||||
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
|
||||
if (changed) {
|
||||
for (const id of [haloId, outlineId, symbolLiteId, symbolId]) {
|
||||
guardedSetVisibility(map, id, visibility);
|
||||
}
|
||||
if (projection === 'globe') kickRepaint(map);
|
||||
}
|
||||
guardedSetVisibility(map, labelId, labelVisibility);
|
||||
}
|
||||
|
||||
// 데이터 업데이트는 projectionBusy 중에는 차단
|
||||
if (projectionBusyRef.current) {
|
||||
// 레이어가 이미 존재하면 ready 상태 유지
|
||||
if (map.getLayer(symbolId)) onGlobeShipsReady?.(true);
|
||||
return;
|
||||
}
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (epochRef.current !== mapSyncEpoch) {
|
||||
epochRef.current = mapSyncEpoch;
|
||||
}
|
||||
|
||||
try {
|
||||
ensureImage();
|
||||
} catch (e) {
|
||||
console.warn('Ship icon image setup failed:', e);
|
||||
}
|
||||
|
||||
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
||||
const geojson = globeShipGeoJson;
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(geojson);
|
||||
else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Ship source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const before = undefined;
|
||||
const priorityFilter = [
|
||||
'any',
|
||||
['==', ['to-number', ['get', 'permitted'], 0], 1],
|
||||
['==', ['to-number', ['get', 'selected'], 0], 1],
|
||||
['==', ['to-number', ['get', 'highlighted'], 0], 1],
|
||||
] as unknown as unknown[];
|
||||
const nonPriorityFilter = [
|
||||
'all',
|
||||
['==', ['to-number', ['get', 'permitted'], 0], 0],
|
||||
['==', ['to-number', ['get', 'selected'], 0], 0],
|
||||
['==', ['to-number', ['get', 'highlighted'], 0], 0],
|
||||
] as unknown as unknown[];
|
||||
|
||||
if (!map.getLayer(haloId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: haloId,
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
layout: {
|
||||
visibility,
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
|
||||
['==', ['get', 'permitted'], 1], 110,
|
||||
['==', ['get', 'selected'], 1], 60,
|
||||
['==', ['get', 'highlighted'], 1], 55,
|
||||
20,
|
||||
] as never,
|
||||
},
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'circle-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 0.38,
|
||||
['==', ['get', 'highlighted'], 1], 0.34,
|
||||
0.16,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship halo layer add failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!map.getLayer(outlineId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: outlineId,
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-stroke-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
||||
GLOBE_OUTLINE_OTHER,
|
||||
] as never,
|
||||
'circle-stroke-width': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 3.4,
|
||||
['==', ['get', 'highlighted'], 1], 2.7,
|
||||
['==', ['get', 'permitted'], 1], 1.8,
|
||||
0.7,
|
||||
] as never,
|
||||
'circle-stroke-opacity': 0.85,
|
||||
},
|
||||
layout: {
|
||||
visibility,
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
|
||||
['==', ['get', 'permitted'], 1], 120,
|
||||
['==', ['get', 'selected'], 1], 70,
|
||||
['==', ['get', 'highlighted'], 1], 65,
|
||||
30,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship outline layer add failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!map.getLayer(symbolLiteId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: symbolLiteId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
minzoom: 6.5,
|
||||
filter: nonPriorityFilter as never,
|
||||
layout: {
|
||||
visibility,
|
||||
'symbol-sort-key': 40 as never,
|
||||
'icon-image': [
|
||||
'case',
|
||||
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||
anchoredImgId,
|
||||
imgId,
|
||||
] as never,
|
||||
'icon-size': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
6.5,
|
||||
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45],
|
||||
8,
|
||||
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62],
|
||||
10,
|
||||
['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72],
|
||||
14,
|
||||
['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78],
|
||||
18,
|
||||
['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78],
|
||||
] as unknown as number[],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'icon-anchor': 'center',
|
||||
'icon-rotate': [
|
||||
'case',
|
||||
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||
0,
|
||||
['to-number', ['get', 'heading'], 0],
|
||||
] as never,
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-pitch-alignment': 'map',
|
||||
},
|
||||
paint: {
|
||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'icon-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
6.5,
|
||||
0.16,
|
||||
8,
|
||||
0.34,
|
||||
11,
|
||||
0.54,
|
||||
14,
|
||||
0.68,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship lite symbol layer add failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!map.getLayer(symbolId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: symbolId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
filter: priorityFilter as never,
|
||||
layout: {
|
||||
visibility,
|
||||
'symbol-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
|
||||
['==', ['get', 'permitted'], 1], 130,
|
||||
['==', ['get', 'selected'], 1], 80,
|
||||
['==', ['get', 'highlighted'], 1], 75,
|
||||
45,
|
||||
] as never,
|
||||
'icon-image': [
|
||||
'case',
|
||||
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||
anchoredImgId,
|
||||
imgId,
|
||||
] as never,
|
||||
'icon-size': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
3, ['to-number', ['get', 'iconSize3'], 0.35],
|
||||
7, ['to-number', ['get', 'iconSize7'], 0.45],
|
||||
10, ['to-number', ['get', 'iconSize10'], 0.58],
|
||||
14, ['to-number', ['get', 'iconSize14'], 0.85],
|
||||
18, ['to-number', ['get', 'iconSize18'], 2.5],
|
||||
] as unknown as number[],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'icon-anchor': 'center',
|
||||
'icon-rotate': [
|
||||
'case',
|
||||
['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0,
|
||||
['to-number', ['get', 'heading'], 0],
|
||||
] as never,
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-pitch-alignment': 'map',
|
||||
},
|
||||
paint: {
|
||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'icon-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 1,
|
||||
['==', ['get', 'highlighted'], 1], 0.95,
|
||||
['==', ['get', 'permitted'], 1], 0.93,
|
||||
0.9,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship symbol layer add failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const labelFilter = [
|
||||
'all',
|
||||
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
|
||||
[
|
||||
'any',
|
||||
['==', ['get', 'permitted'], 1],
|
||||
['==', ['get', 'selected'], 1],
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
],
|
||||
] as unknown as unknown[];
|
||||
|
||||
if (!map.getLayer(labelId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: labelId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
minzoom: 7,
|
||||
filter: labelFilter as never,
|
||||
layout: {
|
||||
visibility: labelVisibility,
|
||||
'symbol-placement': 'point',
|
||||
'text-field': ['get', 'labelName'] as never,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
||||
'text-anchor': 'top',
|
||||
'text-offset': [0, 1.1],
|
||||
'text-padding': 2,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
['==', ['get', '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,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship label layer add failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||
onGlobeShipsReady?.(true);
|
||||
if (projection === 'globe') {
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
settings.showShips,
|
||||
overlays.shipLabels,
|
||||
globeShipGeoJson,
|
||||
selectedMmsi,
|
||||
isBaseHighlightedMmsi,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
onGlobeShipsReady,
|
||||
]);
|
||||
}
|
||||
@ -1,31 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||
import {
|
||||
ANCHORED_SHIP_ICON_ID,
|
||||
GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
GLOBE_OUTLINE_PERMITTED,
|
||||
GLOBE_OUTLINE_OTHER,
|
||||
DEG2RAD,
|
||||
} from '../constants';
|
||||
import { isFiniteNumber } from '../lib/setUtils';
|
||||
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import {
|
||||
isAnchoredShip,
|
||||
getDisplayHeading,
|
||||
getGlobeBaseShipColor,
|
||||
} from '../lib/shipUtils';
|
||||
import {
|
||||
buildFallbackGlobeAnchoredShipIcon,
|
||||
ensureFallbackShipImage,
|
||||
} from '../lib/globeShipIcon';
|
||||
import { clampNumber } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
import { useGlobeShipLabels } from './useGlobeShipLabels';
|
||||
import { useGlobeShipLayers } from './useGlobeShipLayers';
|
||||
import { useGlobeShipHover } from './useGlobeShipHover';
|
||||
|
||||
export function useGlobeShips(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -52,926 +33,43 @@ export function useGlobeShips(
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
||||
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
|
||||
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||
onGlobeShipsReady,
|
||||
} = opts;
|
||||
// Mercator 모드 선명 라벨
|
||||
useGlobeShipLabels(mapRef, projectionBusyRef, {
|
||||
projection: opts.projection,
|
||||
settings: opts.settings,
|
||||
shipData: opts.shipData,
|
||||
shipHighlightSet: opts.shipHighlightSet,
|
||||
overlays: opts.overlays,
|
||||
legacyHits: opts.legacyHits,
|
||||
selectedMmsi: opts.selectedMmsi,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
});
|
||||
|
||||
const globeShipsEpochRef = useRef(-1);
|
||||
const globeHoverShipSignatureRef = useRef('');
|
||||
// Globe 모드 선박 아이콘 레이어
|
||||
useGlobeShipLayers(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||
projection: opts.projection,
|
||||
settings: opts.settings,
|
||||
shipData: opts.shipData,
|
||||
overlays: opts.overlays,
|
||||
legacyHits: opts.legacyHits,
|
||||
selectedMmsi: opts.selectedMmsi,
|
||||
isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
onGlobeShipsReady: opts.onGlobeShipsReady,
|
||||
});
|
||||
|
||||
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
||||
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
||||
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: shipData.map((t) => {
|
||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||
const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
||||
const heading = getDisplayHeading({
|
||||
cog: t.cog,
|
||||
heading: t.heading,
|
||||
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
});
|
||||
const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
|
||||
const shipHeading = isAnchored ? 0 : heading;
|
||||
const hull = clampNumber(
|
||||
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||
50, 420,
|
||||
);
|
||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||
const selected = t.mmsi === selectedMmsi;
|
||||
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
||||
const selectedScale = selected ? 1.08 : 1;
|
||||
const highlightScale = highlighted ? 1.06 : 1;
|
||||
const iconScale = selected ? selectedScale : highlightScale;
|
||||
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
|
||||
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
|
||||
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
|
||||
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
|
||||
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
name: t.name || '',
|
||||
labelName,
|
||||
cog: shipHeading,
|
||||
heading: shipHeading,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||
isAnchored: isAnchored ? 1 : 0,
|
||||
shipColor: getGlobeBaseShipColor({
|
||||
legacy: legacy?.shipCode || null,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||
}),
|
||||
iconSize3: iconSize3 * iconScale,
|
||||
iconSize7: iconSize7 * iconScale,
|
||||
iconSize10: iconSize10 * iconScale,
|
||||
iconSize14: iconSize14 * iconScale,
|
||||
iconSize18: iconSize18 * iconScale,
|
||||
sizeScale,
|
||||
selected: selected ? 1 : 0,
|
||||
highlighted: highlighted ? 1 : 0,
|
||||
permitted: legacy ? 1 : 0,
|
||||
code: legacy?.shipCode || '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
|
||||
|
||||
// Ship name labels in mercator
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'ship-labels-src';
|
||||
const layerId = 'ship-labels';
|
||||
|
||||
const remove = () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getSource(srcId)) map.removeSource(srcId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (projection !== 'mercator' || !settings.showShips) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const visibility = overlays.shipLabels ? 'visible' : 'none';
|
||||
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
||||
for (const t of shipData) {
|
||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||
const isTarget = !!legacy;
|
||||
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
|
||||
const isPinnedHighlight = shipHighlightSet.has(t.mmsi);
|
||||
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
|
||||
|
||||
const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim();
|
||||
if (!labelName) continue;
|
||||
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: `ship-label-${t.mmsi}`,
|
||||
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
labelName,
|
||||
selected: isSelected ? 1 : 0,
|
||||
highlighted: isPinnedHighlight ? 1 : 0,
|
||||
permitted: isTarget ? 1 : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.Point> = { type: 'FeatureCollection', features };
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Ship label source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[];
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
minzoom: 7,
|
||||
filter: filter as never,
|
||||
layout: {
|
||||
visibility,
|
||||
'symbol-placement': 'point',
|
||||
'text-field': ['get', 'labelName'] as never,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
||||
'text-anchor': 'top',
|
||||
'text-offset': [0, 1.1],
|
||||
'text-padding': 2,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1],
|
||||
'rgba(14,234,255,0.95)',
|
||||
['==', ['get', '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,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship label layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
settings.showShips,
|
||||
overlays.shipLabels,
|
||||
shipData,
|
||||
legacyHits,
|
||||
selectedMmsi,
|
||||
shipHighlightSet,
|
||||
mapSyncEpoch,
|
||||
]);
|
||||
|
||||
// Ships in globe mode
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const imgId = 'ship-globe-icon';
|
||||
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
|
||||
const srcId = 'ships-globe-src';
|
||||
const haloId = 'ships-globe-halo';
|
||||
const outlineId = 'ships-globe-outline';
|
||||
const symbolLiteId = 'ships-globe-lite';
|
||||
const symbolId = 'ships-globe';
|
||||
const labelId = 'ships-globe-label';
|
||||
|
||||
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||
const hide = () => {
|
||||
for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) {
|
||||
guardedSetVisibility(map, id, 'none');
|
||||
}
|
||||
};
|
||||
|
||||
const ensureImage = () => {
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
||||
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (!settings.showShips) {
|
||||
hide();
|
||||
onGlobeShipsReady?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 빠른 visibility 토글 — projectionBusy 중에도 실행
|
||||
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
|
||||
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
|
||||
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
|
||||
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
|
||||
if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) {
|
||||
const changed =
|
||||
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
|
||||
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
|
||||
if (changed) {
|
||||
for (const id of [haloId, outlineId, symbolLiteId, symbolId]) {
|
||||
guardedSetVisibility(map, id, visibility);
|
||||
}
|
||||
if (projection === 'globe') kickRepaint(map);
|
||||
}
|
||||
guardedSetVisibility(map, labelId, labelVisibility);
|
||||
}
|
||||
|
||||
// 데이터 업데이트는 projectionBusy 중에는 차단
|
||||
if (projectionBusyRef.current) {
|
||||
// 레이어가 이미 존재하면 ready 상태 유지
|
||||
if (map.getLayer(symbolId)) onGlobeShipsReady?.(true);
|
||||
return;
|
||||
}
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (globeShipsEpochRef.current !== mapSyncEpoch) {
|
||||
globeShipsEpochRef.current = mapSyncEpoch;
|
||||
}
|
||||
|
||||
try {
|
||||
ensureImage();
|
||||
} catch (e) {
|
||||
console.warn('Ship icon image setup failed:', e);
|
||||
}
|
||||
|
||||
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
||||
const geojson = globeShipGeoJson;
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(geojson);
|
||||
else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Ship source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const before = undefined;
|
||||
const priorityFilter = [
|
||||
'any',
|
||||
['==', ['to-number', ['get', 'permitted'], 0], 1],
|
||||
['==', ['to-number', ['get', 'selected'], 0], 1],
|
||||
['==', ['to-number', ['get', 'highlighted'], 0], 1],
|
||||
] as unknown as unknown[];
|
||||
const nonPriorityFilter = [
|
||||
'all',
|
||||
['==', ['to-number', ['get', 'permitted'], 0], 0],
|
||||
['==', ['to-number', ['get', 'selected'], 0], 0],
|
||||
['==', ['to-number', ['get', 'highlighted'], 0], 0],
|
||||
] as unknown as unknown[];
|
||||
|
||||
if (!map.getLayer(haloId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: haloId,
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
layout: {
|
||||
visibility,
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
|
||||
['==', ['get', 'permitted'], 1], 110,
|
||||
['==', ['get', 'selected'], 1], 60,
|
||||
['==', ['get', 'highlighted'], 1], 55,
|
||||
20,
|
||||
] as never,
|
||||
},
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'circle-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 0.38,
|
||||
['==', ['get', 'highlighted'], 1], 0.34,
|
||||
0.16,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship halo layer add failed:', e);
|
||||
}
|
||||
}
|
||||
// halo: data-driven expressions are static — visibility handled by fast toggle above
|
||||
|
||||
if (!map.getLayer(outlineId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: outlineId,
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-stroke-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
||||
GLOBE_OUTLINE_OTHER,
|
||||
] as never,
|
||||
'circle-stroke-width': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 3.4,
|
||||
['==', ['get', 'highlighted'], 1], 2.7,
|
||||
['==', ['get', 'permitted'], 1], 1.8,
|
||||
0.7,
|
||||
] as never,
|
||||
'circle-stroke-opacity': 0.85,
|
||||
},
|
||||
layout: {
|
||||
visibility,
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
|
||||
['==', ['get', 'permitted'], 1], 120,
|
||||
['==', ['get', 'selected'], 1], 70,
|
||||
['==', ['get', 'highlighted'], 1], 65,
|
||||
30,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship outline layer add failed:', e);
|
||||
}
|
||||
}
|
||||
// outline: data-driven expressions are static — visibility handled by fast toggle
|
||||
|
||||
if (!map.getLayer(symbolLiteId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: symbolLiteId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
minzoom: 6.5,
|
||||
filter: nonPriorityFilter as never,
|
||||
layout: {
|
||||
visibility,
|
||||
'symbol-sort-key': 40 as never,
|
||||
'icon-image': [
|
||||
'case',
|
||||
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||
anchoredImgId,
|
||||
imgId,
|
||||
] as never,
|
||||
'icon-size': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
6.5,
|
||||
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45],
|
||||
8,
|
||||
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62],
|
||||
10,
|
||||
['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72],
|
||||
14,
|
||||
['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78],
|
||||
18,
|
||||
['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78],
|
||||
] as unknown as number[],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'icon-anchor': 'center',
|
||||
'icon-rotate': [
|
||||
'case',
|
||||
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||
0,
|
||||
['to-number', ['get', 'heading'], 0],
|
||||
] as never,
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-pitch-alignment': 'map',
|
||||
},
|
||||
paint: {
|
||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'icon-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
6.5,
|
||||
0.16,
|
||||
8,
|
||||
0.34,
|
||||
11,
|
||||
0.54,
|
||||
14,
|
||||
0.68,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship lite symbol layer add failed:', e);
|
||||
}
|
||||
}
|
||||
// lite symbol: lower LOD for non-priority vessels in low zoom
|
||||
|
||||
if (!map.getLayer(symbolId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: symbolId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
filter: priorityFilter as never,
|
||||
layout: {
|
||||
visibility,
|
||||
'symbol-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
|
||||
['==', ['get', 'permitted'], 1], 130,
|
||||
['==', ['get', 'selected'], 1], 80,
|
||||
['==', ['get', 'highlighted'], 1], 75,
|
||||
45,
|
||||
] as never,
|
||||
'icon-image': [
|
||||
'case',
|
||||
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||
anchoredImgId,
|
||||
imgId,
|
||||
] as never,
|
||||
'icon-size': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
3, ['to-number', ['get', 'iconSize3'], 0.35],
|
||||
7, ['to-number', ['get', 'iconSize7'], 0.45],
|
||||
10, ['to-number', ['get', 'iconSize10'], 0.58],
|
||||
14, ['to-number', ['get', 'iconSize14'], 0.85],
|
||||
18, ['to-number', ['get', 'iconSize18'], 2.5],
|
||||
] as unknown as number[],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'icon-anchor': 'center',
|
||||
'icon-rotate': [
|
||||
'case',
|
||||
['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0,
|
||||
['to-number', ['get', 'heading'], 0],
|
||||
] as never,
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-pitch-alignment': 'map',
|
||||
},
|
||||
paint: {
|
||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'icon-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 1,
|
||||
['==', ['get', 'highlighted'], 1], 0.95,
|
||||
['==', ['get', 'permitted'], 1], 0.93,
|
||||
0.9,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship symbol layer add failed:', e);
|
||||
}
|
||||
}
|
||||
// symbol: data-driven expressions are static — visibility handled by fast toggle
|
||||
|
||||
const labelFilter = [
|
||||
'all',
|
||||
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
|
||||
[
|
||||
'any',
|
||||
['==', ['get', 'permitted'], 1],
|
||||
['==', ['get', 'selected'], 1],
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
],
|
||||
] as unknown as unknown[];
|
||||
|
||||
if (!map.getLayer(labelId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: labelId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
minzoom: 7,
|
||||
filter: labelFilter as never,
|
||||
layout: {
|
||||
visibility: labelVisibility,
|
||||
'symbol-placement': 'point',
|
||||
'text-field': ['get', 'labelName'] as never,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
||||
'text-anchor': 'top',
|
||||
'text-offset': [0, 1.1],
|
||||
'text-padding': 2,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
['==', ['get', '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,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship label layer add failed:', e);
|
||||
}
|
||||
}
|
||||
// label: filter/text-field are static — visibility handled by fast toggle
|
||||
|
||||
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||
onGlobeShipsReady?.(true);
|
||||
if (projection === 'globe') {
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
settings.showShips,
|
||||
overlays.shipLabels,
|
||||
globeShipGeoJson,
|
||||
selectedMmsi,
|
||||
isBaseHighlightedMmsi,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
onGlobeShipsReady,
|
||||
]);
|
||||
|
||||
// Globe hover overlay ships
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const imgId = 'ship-globe-icon';
|
||||
const srcId = 'ships-globe-hover-src';
|
||||
const haloId = 'ships-globe-hover-halo';
|
||||
const outlineId = 'ships-globe-hover-outline';
|
||||
const symbolId = 'ships-globe-hover';
|
||||
|
||||
const hideHover = () => {
|
||||
for (const id of [symbolId, outlineId, haloId]) {
|
||||
guardedSetVisibility(map, id, 'none');
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
||||
hideHover();
|
||||
return;
|
||||
}
|
||||
|
||||
if (globeShipsEpochRef.current !== mapSyncEpoch) {
|
||||
globeShipsEpochRef.current = mapSyncEpoch;
|
||||
}
|
||||
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
if (!map.hasImage(imgId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
|
||||
if (hovered.length === 0) {
|
||||
hideHover();
|
||||
return;
|
||||
}
|
||||
const hoverSignature = hovered
|
||||
.map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`)
|
||||
.join('|');
|
||||
const hasHoverSource = map.getSource(srcId) != null;
|
||||
const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id));
|
||||
if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) {
|
||||
return;
|
||||
}
|
||||
globeHoverShipSignatureRef.current = hoverSignature;
|
||||
const needReorder = !hasHoverSource || !hasHoverLayers;
|
||||
|
||||
const hoverGeojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
||||
type: 'FeatureCollection',
|
||||
features: hovered.map((t) => {
|
||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||
const heading = getDisplayHeading({
|
||||
cog: t.cog,
|
||||
heading: t.heading,
|
||||
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
});
|
||||
const hull = clampNumber(
|
||||
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||
50,
|
||||
420,
|
||||
);
|
||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||
const selected = t.mmsi === selectedMmsi;
|
||||
const scale = selected ? 1.16 : 1.1;
|
||||
return {
|
||||
type: 'Feature',
|
||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
name: t.name || '',
|
||||
cog: heading,
|
||||
heading,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||
shipColor: getGlobeBaseShipColor({
|
||||
legacy: legacy?.shipCode || null,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||
}),
|
||||
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
|
||||
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
|
||||
iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1),
|
||||
iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0),
|
||||
iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0),
|
||||
selected: selected ? 1 : 0,
|
||||
permitted: legacy ? 1 : 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(hoverGeojson);
|
||||
else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Ship hover source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const before = undefined;
|
||||
|
||||
if (!map.getLayer(haloId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: haloId,
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
layout: {
|
||||
visibility: 'visible',
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 120,
|
||||
['==', ['get', 'permitted'], 1], 115,
|
||||
110,
|
||||
] as never,
|
||||
},
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
|
||||
'rgba(245,158,11,1)',
|
||||
] as never,
|
||||
'circle-opacity': 0.42,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship hover halo layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(haloId, 'visibility', 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(outlineId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: outlineId,
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-stroke-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
'rgba(245,158,11,0.95)',
|
||||
] as never,
|
||||
'circle-stroke-width': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 3.8,
|
||||
2.2,
|
||||
] as never,
|
||||
'circle-stroke-opacity': 0.9,
|
||||
},
|
||||
layout: {
|
||||
visibility: 'visible',
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 121,
|
||||
['==', ['get', 'permitted'], 1], 116,
|
||||
111,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship hover outline layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(outlineId, 'visibility', 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(symbolId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: symbolId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
layout: {
|
||||
visibility: 'visible',
|
||||
'symbol-sort-key': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 122,
|
||||
['==', ['get', 'permitted'], 1], 117,
|
||||
112,
|
||||
] as never,
|
||||
'icon-image': imgId,
|
||||
'icon-size': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
3, ['to-number', ['get', 'iconSize3'], 0.35],
|
||||
7, ['to-number', ['get', 'iconSize7'], 0.45],
|
||||
10, ['to-number', ['get', 'iconSize10'], 0.58],
|
||||
14, ['to-number', ['get', 'iconSize14'], 0.85],
|
||||
18, ['to-number', ['get', 'iconSize18'], 2.5],
|
||||
] as unknown as number[],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'icon-anchor': 'center',
|
||||
'icon-rotate': ['to-number', ['get', 'heading'], 0],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-pitch-alignment': 'map',
|
||||
},
|
||||
paint: {
|
||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'icon-opacity': 1,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Ship hover symbol layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(symbolId, 'visibility', 'visible');
|
||||
}
|
||||
|
||||
if (needReorder) {
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
settings.showShips,
|
||||
shipLayerData,
|
||||
legacyHits,
|
||||
shipHoverOverlaySet,
|
||||
selectedMmsi,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// Globe ship click selection
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (projection !== 'globe' || !settings.showShips) return;
|
||||
|
||||
const symbolId = 'ships-globe';
|
||||
const symbolLiteId = 'ships-globe-lite';
|
||||
const haloId = 'ships-globe-halo';
|
||||
const outlineId = 'ships-globe-outline';
|
||||
const clickedRadiusDeg2 = Math.pow(0.08, 2);
|
||||
|
||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||
try {
|
||||
const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id));
|
||||
let feats: unknown[] = [];
|
||||
if (layerIds.length > 0) {
|
||||
try {
|
||||
feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[];
|
||||
} catch {
|
||||
feats = [];
|
||||
}
|
||||
}
|
||||
const f = feats?.[0];
|
||||
const props = ((f as { properties?: Record<string, unknown> } | undefined)?.properties || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const mmsi = Number(props.mmsi);
|
||||
if (Number.isFinite(mmsi)) {
|
||||
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
|
||||
onToggleHighlightMmsi?.(mmsi);
|
||||
return;
|
||||
}
|
||||
onSelectMmsi(mmsi);
|
||||
return;
|
||||
}
|
||||
|
||||
const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng };
|
||||
const cosLat = Math.cos(clicked.lat * DEG2RAD);
|
||||
let bestMmsi: number | null = null;
|
||||
let bestD2 = Number.POSITIVE_INFINITY;
|
||||
for (const t of targets) {
|
||||
if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue;
|
||||
const dLon = (clicked.lon - t.lon) * cosLat;
|
||||
const dLat = clicked.lat - t.lat;
|
||||
const d2 = dLon * dLon + dLat * dLat;
|
||||
if (d2 <= clickedRadiusDeg2 && d2 < bestD2) {
|
||||
bestD2 = d2;
|
||||
bestMmsi = t.mmsi;
|
||||
}
|
||||
}
|
||||
if (bestMmsi != null) {
|
||||
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
|
||||
onToggleHighlightMmsi?.(bestMmsi);
|
||||
return;
|
||||
}
|
||||
onSelectMmsi(bestMmsi);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onSelectMmsi(null);
|
||||
};
|
||||
|
||||
map.on('click', onClick);
|
||||
return () => {
|
||||
try {
|
||||
map.off('click', onClick);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]);
|
||||
// Globe 호버 오버레이 + 클릭 선택
|
||||
useGlobeShipHover(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||
projection: opts.projection,
|
||||
settings: opts.settings,
|
||||
shipLayerData: opts.shipLayerData,
|
||||
shipHoverOverlaySet: opts.shipHoverOverlaySet,
|
||||
legacyHits: opts.legacyHits,
|
||||
selectedMmsi: opts.selectedMmsi,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
onSelectMmsi: opts.onSelectMmsi,
|
||||
onToggleHighlightMmsi: opts.onToggleHighlightMmsi,
|
||||
targets: opts.targets,
|
||||
hasAuxiliarySelectModifier: opts.hasAuxiliarySelectModifier,
|
||||
});
|
||||
}
|
||||
|
||||
485
apps/web/src/widgets/map3d/lib/deckLayerFactories.ts
Normal file
485
apps/web/src/widgets/map3d/lib/deckLayerFactories.ts
Normal file
@ -0,0 +1,485 @@
|
||||
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
||||
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, PairRangeCircle } from '../types';
|
||||
import {
|
||||
SHIP_ICON_MAPPING,
|
||||
FLAT_SHIP_ICON_SIZE,
|
||||
FLAT_SHIP_ICON_SIZE_SELECTED,
|
||||
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
||||
FLAT_LEGACY_HALO_RADIUS,
|
||||
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
||||
EMPTY_MMSI_SET,
|
||||
DEPTH_DISABLED_PARAMS,
|
||||
GLOBE_OVERLAY_PARAMS,
|
||||
HALO_OUTLINE_COLOR,
|
||||
HALO_OUTLINE_COLOR_SELECTED,
|
||||
HALO_OUTLINE_COLOR_HIGHLIGHTED,
|
||||
PAIR_RANGE_NORMAL_DECK,
|
||||
PAIR_RANGE_WARN_DECK,
|
||||
PAIR_LINE_NORMAL_DECK,
|
||||
PAIR_LINE_WARN_DECK,
|
||||
FC_LINE_NORMAL_DECK,
|
||||
FC_LINE_SUSPICIOUS_DECK,
|
||||
FLEET_RANGE_LINE_DECK,
|
||||
FLEET_RANGE_FILL_DECK,
|
||||
PAIR_RANGE_NORMAL_DECK_HL,
|
||||
PAIR_RANGE_WARN_DECK_HL,
|
||||
PAIR_LINE_NORMAL_DECK_HL,
|
||||
PAIR_LINE_WARN_DECK_HL,
|
||||
FC_LINE_NORMAL_DECK_HL,
|
||||
FC_LINE_SUSPICIOUS_DECK_HL,
|
||||
FLEET_RANGE_LINE_DECK_HL,
|
||||
FLEET_RANGE_FILL_DECK_HL,
|
||||
} from '../constants';
|
||||
import { getDisplayHeading, getShipColor } from './shipUtils';
|
||||
import { getCachedShipIcon } from './shipIconCache';
|
||||
|
||||
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
|
||||
|
||||
interface DeckHoverCallbacks {
|
||||
touchDeckHoverState: (isHover: boolean) => void;
|
||||
setDeckHoverPairs: (next: number[]) => void;
|
||||
setDeckHoverMmsi: (next: number[]) => void;
|
||||
clearDeckHoverPairs: () => void;
|
||||
clearMapFleetHoverState: () => void;
|
||||
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||
toFleetMmsiList: (value: unknown) => number[];
|
||||
}
|
||||
|
||||
interface DeckSelectCallbacks {
|
||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||
onSelectMmsi: (mmsi: number | null) => void;
|
||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
|
||||
}
|
||||
|
||||
/* ── Mercator Deck 레이어 ─────────────────────────────── */
|
||||
|
||||
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
|
||||
shipLayerData: AisTarget[];
|
||||
shipOverlayLayerData: AisTarget[];
|
||||
legacyTargetsOrdered: AisTarget[];
|
||||
legacyOverlayTargets: AisTarget[];
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
pairLinks: PairLink[] | undefined;
|
||||
fcDashed: DashSeg[];
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
pairRanges: PairRangeCircle[];
|
||||
pairLinksInteractive: PairLink[];
|
||||
pairRangesInteractive: PairRangeCircle[];
|
||||
fcLinesInteractive: DashSeg[];
|
||||
fleetCirclesInteractive: FleetCircle[];
|
||||
overlays: MapToggleState;
|
||||
showDensity: boolean;
|
||||
showShips: boolean;
|
||||
selectedMmsi: number | null;
|
||||
shipHighlightSet: Set<number>;
|
||||
}
|
||||
|
||||
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
|
||||
const layers: unknown[] = [];
|
||||
const overlayParams = DEPTH_DISABLED_PARAMS;
|
||||
const clearDeckHover = () => { ctx.touchDeckHoverState(false); };
|
||||
const isTargetShip = (mmsi: number) => (ctx.legacyHits ? ctx.legacyHits.has(mmsi) : false);
|
||||
|
||||
const shipOtherData: AisTarget[] = [];
|
||||
const shipTargetData: AisTarget[] = [];
|
||||
for (const t of ctx.shipLayerData) {
|
||||
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||
else shipOtherData.push(t);
|
||||
}
|
||||
const shipOverlayOtherData: AisTarget[] = [];
|
||||
for (const t of ctx.shipOverlayLayerData) {
|
||||
if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t);
|
||||
}
|
||||
|
||||
/* ─ density ─ */
|
||||
if (ctx.showDensity) {
|
||||
layers.push(
|
||||
new HexagonLayer<AisTarget>({
|
||||
id: 'density',
|
||||
data: ctx.shipLayerData,
|
||||
pickable: true,
|
||||
extruded: true,
|
||||
radius: 2500,
|
||||
elevationScale: 35,
|
||||
coverage: 0.92,
|
||||
opacity: 0.35,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ pair range ─ */
|
||||
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<PairRangeCircle>({
|
||||
id: 'pair-range',
|
||||
data: ctx.pairRanges,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
radiusMinPixels: 10,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 1,
|
||||
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
||||
getPosition: (d) => d.center,
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const p = info.object as PairRangeCircle;
|
||||
ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]);
|
||||
ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
|
||||
ctx.clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) { ctx.onSelectMmsi(null); return; }
|
||||
const obj = info.object as PairRangeCircle;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
ctx.onToggleHighlightMmsi?.(obj.aMmsi);
|
||||
ctx.onToggleHighlightMmsi?.(obj.bMmsi);
|
||||
return;
|
||||
}
|
||||
ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ pair lines ─ */
|
||||
if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) {
|
||||
layers.push(
|
||||
new LineLayer<PairLink>({
|
||||
id: 'pair-lines',
|
||||
data: ctx.pairLinks,
|
||||
pickable: true,
|
||||
parameters: overlayParams,
|
||||
getSourcePosition: (d) => d.from,
|
||||
getTargetPosition: (d) => d.to,
|
||||
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
|
||||
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
||||
widthUnits: 'pixels',
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const obj = info.object as PairLink;
|
||||
ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
|
||||
ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
|
||||
ctx.clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as PairLink;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
ctx.onToggleHighlightMmsi?.(obj.aMmsi);
|
||||
ctx.onToggleHighlightMmsi?.(obj.bMmsi);
|
||||
return;
|
||||
}
|
||||
ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ fc lines ─ */
|
||||
if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) {
|
||||
layers.push(
|
||||
new LineLayer<DashSeg>({
|
||||
id: 'fc-lines',
|
||||
data: ctx.fcDashed,
|
||||
pickable: true,
|
||||
parameters: overlayParams,
|
||||
getSourcePosition: (d) => d.from,
|
||||
getTargetPosition: (d) => d.to,
|
||||
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
||||
getWidth: () => 1.3,
|
||||
widthUnits: 'pixels',
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const obj = info.object as DashSeg;
|
||||
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
|
||||
ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
|
||||
ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
|
||||
ctx.clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as DashSeg;
|
||||
if (obj.fromMmsi == null || obj.toMmsi == null) return;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
ctx.onToggleHighlightMmsi?.(obj.fromMmsi);
|
||||
ctx.onToggleHighlightMmsi?.(obj.toMmsi);
|
||||
return;
|
||||
}
|
||||
ctx.onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ fleet circles ─ */
|
||||
if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<FleetCircle>({
|
||||
id: 'fleet-circles',
|
||||
data: ctx.fleetCircles,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 1.1,
|
||||
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
||||
getPosition: (d) => d.center,
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = ctx.toFleetMmsiList(obj.vesselMmsis);
|
||||
ctx.setMapFleetHoverState(obj.ownerKey || null, list);
|
||||
ctx.setDeckHoverMmsi(list);
|
||||
ctx.clearDeckHoverPairs();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = ctx.toFleetMmsiList(obj.vesselMmsis);
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
for (const mmsi of list) ctx.onToggleHighlightMmsi?.(mmsi);
|
||||
return;
|
||||
}
|
||||
const first = list[0];
|
||||
if (first != null) ctx.onDeckSelectOrHighlight({ mmsi: first });
|
||||
},
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
new ScatterplotLayer<FleetCircle>({
|
||||
id: 'fleet-circles-fill',
|
||||
data: ctx.fleetCircles,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: true,
|
||||
stroked: false,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
getFillColor: () => FLEET_RANGE_FILL_DECK,
|
||||
getPosition: (d) => d.center,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ ships ─ */
|
||||
if (ctx.showShips) {
|
||||
const shipOnHover = (info: PickingInfo) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const obj = info.object as AisTarget;
|
||||
ctx.setDeckHoverMmsi([obj.mmsi]);
|
||||
ctx.clearDeckHoverPairs();
|
||||
ctx.clearMapFleetHoverState();
|
||||
};
|
||||
const shipOnClick = (info: PickingInfo) => {
|
||||
if (!info.object) { ctx.onSelectMmsi(null); return; }
|
||||
ctx.onDeckSelectOrHighlight(
|
||||
{
|
||||
mmsi: (info.object as AisTarget).mmsi,
|
||||
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
if (shipOtherData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-other',
|
||||
data: shipOtherData,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: () => FLAT_SHIP_ICON_SIZE,
|
||||
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
|
||||
onHover: shipOnHover,
|
||||
onClick: shipOnClick,
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (shipOverlayOtherData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-overlay-other',
|
||||
data: shipOverlayOtherData,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: (d) => {
|
||||
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
|
||||
if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
|
||||
return 0;
|
||||
},
|
||||
getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet),
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (ctx.legacyTargetsOrdered.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<AisTarget>({
|
||||
id: 'legacy-halo',
|
||||
data: ctx.legacyTargetsOrdered,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'pixels',
|
||||
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 2,
|
||||
getLineColor: () => HALO_OUTLINE_COLOR,
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (shipTargetData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-target',
|
||||
data: shipTargetData,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: () => FLAT_SHIP_ICON_SIZE,
|
||||
getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
||||
onHover: shipOnHover,
|
||||
onClick: shipOnClick,
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─ interactive overlays ─ */
|
||||
if (ctx.pairRangesInteractive.length > 0) {
|
||||
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
||||
}
|
||||
if (ctx.pairLinksInteractive.length > 0) {
|
||||
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
|
||||
}
|
||||
if (ctx.fcLinesInteractive.length > 0) {
|
||||
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
|
||||
}
|
||||
if (ctx.fleetCirclesInteractive.length > 0) {
|
||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
||||
}
|
||||
|
||||
/* ─ legacy overlay (highlight/selected) ─ */
|
||||
if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) {
|
||||
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||
}
|
||||
|
||||
if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) {
|
||||
const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi));
|
||||
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } }));
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/* ── Globe Deck 오버레이 레이어 ────────────────────────── */
|
||||
|
||||
export interface GlobeDeckLayerContext {
|
||||
pairRanges: PairRangeCircle[];
|
||||
pairLinks: PairLink[] | undefined;
|
||||
fcDashed: DashSeg[];
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
legacyTargetsOrdered: AisTarget[];
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
overlays: MapToggleState;
|
||||
showShips: boolean;
|
||||
selectedMmsi: number | null;
|
||||
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
|
||||
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
|
||||
isHighlightedMmsi: (mmsi: number) => boolean;
|
||||
touchDeckHoverState: (isHover: boolean) => void;
|
||||
setDeckHoverPairs: (next: number[]) => void;
|
||||
setDeckHoverMmsi: (next: number[]) => void;
|
||||
clearDeckHoverPairs: () => void;
|
||||
clearDeckHoverMmsi: () => void;
|
||||
clearMapFleetHoverState: () => void;
|
||||
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||
toFleetMmsiList: (value: unknown) => number[];
|
||||
}
|
||||
|
||||
export function buildGlobeDeckLayers(ctx: GlobeDeckLayerContext): unknown[] {
|
||||
const overlayParams = GLOBE_OVERLAY_PARAMS;
|
||||
const globeLayers: unknown[] = [];
|
||||
|
||||
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
|
||||
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: ctx.pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const p = info.object as PairRangeCircle; ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]); ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]); ctx.clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) {
|
||||
const links = ctx.pairLinks || [];
|
||||
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as PairLink; ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); ctx.clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) {
|
||||
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: ctx.fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); ctx.clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) {
|
||||
const circles = ctx.fleetCircles || [];
|
||||
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); ctx.clearMapFleetHoverState(); return; } ctx.touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = ctx.toFleetMmsiList(obj.vesselMmsis); ctx.setMapFleetHoverState(obj.ownerKey || null, list); ctx.setDeckHoverMmsi(list); ctx.clearDeckHoverPairs(); } }));
|
||||
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
|
||||
}
|
||||
|
||||
if (ctx.showShips && ctx.legacyTargetsOrdered.length > 0) {
|
||||
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: ctx.legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||
}
|
||||
|
||||
return globeLayers;
|
||||
}
|
||||
@ -83,12 +83,8 @@ export function extractProjectionType(map: maplibregl.Map): MapProjectionId | un
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getMapTilerKey(): string | null {
|
||||
const k = import.meta.env.VITE_MAPTILER_KEY;
|
||||
if (typeof k !== 'string') return null;
|
||||
const v = k.trim();
|
||||
return v ? v : null;
|
||||
}
|
||||
// Canonical source: shared/lib/map/mapTilerKey.ts (re-exported for local usage)
|
||||
export { getMapTilerKey } from '../../../shared/lib/map/mapTilerKey';
|
||||
|
||||
export function getLayerId(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user