Compare commits
27 커밋
feature/we
...
develop
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 7cdc8bef2f | |||
| 59a5e6beac | |||
| f9da13b694 | |||
| 7bca216c53 | |||
| ccaf20804d | |||
| 168bea0621 | |||
| 383b41f49a | |||
| 9048dfdfc6 | |||
| d9b8e9ca44 | |||
| 541135977c | |||
| 61fc3bbce4 | |||
| 255c082436 | |||
| 40229a75c0 | |||
| a132c7eaf8 | |||
| 2adcbc9a93 | |||
| e9a41c6663 | |||
| 6167a0ebd8 | |||
| a4859f54bc | |||
| c8c1b556d6 | |||
| ec9d894ac8 | |||
| 3fa0b67e97 | |||
| ec03a88fbd | |||
| 4d67b26ffa | |||
| b1551f800b | |||
| e2dc927ad2 | |||
| c2ca830ef0 | |||
| 4d1a8a0d1e |
@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||||
<title>WING 조업감시 데모</title>
|
<title>WING 조업감시 데모</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -12,21 +12,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deck.gl/aggregation-layers": "^9.2.7",
|
"@deck.gl/aggregation-layers": "^9.2.7",
|
||||||
"@deck.gl/core": "^9.2.7",
|
"@deck.gl/core": "^9.2.7",
|
||||||
|
"@deck.gl/extensions": "^9.2.7",
|
||||||
"@deck.gl/geo-layers": "^9.2.7",
|
"@deck.gl/geo-layers": "^9.2.7",
|
||||||
"@deck.gl/layers": "^9.2.7",
|
"@deck.gl/layers": "^9.2.7",
|
||||||
"@deck.gl/mapbox": "^9.2.7",
|
"@deck.gl/mapbox": "^9.2.7",
|
||||||
"@maptiler/weather": "^3.1.1",
|
"@maptiler/weather": "^3.1.1",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@stomp/stompjs": "^7.2.1",
|
||||||
|
"@wing/ui": "*",
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"@deck.gl/extensions": "^9.2.7",
|
|
||||||
"@stomp/stompjs": "^7.2.1",
|
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@ -35,6 +37,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
19
apps/web/src/app/styles/base.css
Normal file
19
apps/web/src/app/styles/base.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/* Google Fonts: loaded via index.html <link> */
|
||||||
|
/* CSS Reset: handled by Tailwind preflight */
|
||||||
|
/* CSS Variables: defined in @wing/ui/theme/tokens.css */
|
||||||
|
|
||||||
|
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: var(--wing-card-alpha);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ais-q::placeholder {
|
||||||
|
color: var(--wing-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--wing-card-alpha);
|
||||||
|
border-color: var(--wing-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--wing-card-alpha);
|
||||||
|
border-color: var(--wing-border);
|
||||||
|
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: var(--wing-muted);
|
||||||
|
}
|
||||||
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: var(--wing-card-alpha);
|
||||||
|
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: var(--wing-glass-dense);
|
||||||
|
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: var(--wing-border);
|
||||||
|
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, var(--wing-bg) 0%, var(--wing-surface) 50%, var(--wing-bg) 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;
|
||||||
|
}
|
||||||
217
apps/web/src/app/styles/components/map-panels.css
Normal file
217
apps/web/src/app/styles/components/map-panels.css
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
/* Map panels */
|
||||||
|
.map-legend {
|
||||||
|
position: absolute;
|
||||||
|
/* Keep attribution visible (bottom-right) for licensing/compliance. */
|
||||||
|
bottom: 44px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 800;
|
||||||
|
background: var(--wing-glass);
|
||||||
|
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: var(--wing-glass-dense);
|
||||||
|
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 var(--wing-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--wing-overlay);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-loader-overlay__panel {
|
||||||
|
width: min(72vw, 320px);
|
||||||
|
background: var(--wing-glass-dense);
|
||||||
|
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: var(--wing-glass) !important;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-group button {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-group button + button {
|
||||||
|
border-top: 1px solid var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root .maplibregl-ctrl-group button span,
|
||||||
|
[data-theme='dark'] .maplibregl-ctrl-group button span {
|
||||||
|
filter: invert(1);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .maplibregl-ctrl-group button span {
|
||||||
|
filter: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-attrib {
|
||||||
|
font-size: 10px !important;
|
||||||
|
background: var(--wing-glass) !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: var(--wing-glass);
|
||||||
|
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: var(--wing-glass-dense);
|
||||||
|
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: var(--wing-glass);
|
||||||
|
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;
|
||||||
|
}
|
||||||
56
apps/web/src/app/styles/components/panels.css
Normal file
56
apps/web/src/app/styles/components/panels.css
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
.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: var(--wing-card-alpha);
|
||||||
|
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;
|
||||||
|
}
|
||||||
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: var(--wing-glass);
|
||||||
|
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: var(--wing-glass-dense);
|
||||||
|
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: var(--wing-subtle);
|
||||||
|
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: var(--wing-glass);
|
||||||
|
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: var(--wing-glass);
|
||||||
|
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: var(--wing-glass-dense);
|
||||||
|
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: var(--wing-subtle);
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -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";
|
|
||||||
}
|
|
||||||
216
apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts
Normal file
216
apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* 오버레이/경고 시각 검증용 더미 데이터 — 김개발(DEV) 모드 전용.
|
||||||
|
*
|
||||||
|
* 서해 해역에 12척의 가상 선박을 배치하여 다음 시나리오를 재현:
|
||||||
|
* - 정상 쌍끌이 (pair link, ~1 NM)
|
||||||
|
* - 이격 쌍끌이 (pair_separation alarm, ~8 NM)
|
||||||
|
* - 선단 원 (fleet circle, 5척 동일 선주)
|
||||||
|
* - 환적 의심 (transshipment alarm, FC ↔ PS < 0.5 NM)
|
||||||
|
* - AIS 지연 (ais_stale alarm, 2시간 전 타임스탬프)
|
||||||
|
* - 수역 이탈 (zone_violation alarm, PT가 zone 4에 위치)
|
||||||
|
* - closed_season은 계절(월)에 따라 자동 판정 (2월에는 미발생)
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* DashboardPage에서 isDevMode일 때 targetsInScope + legacyHits에 병합.
|
||||||
|
* 기존 computePairLinks / computeFcLinks / computeFleetCircles /
|
||||||
|
* computeLegacyAlarms 파이프라인이 자동으로 오버레이/경고를 생성.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
|
||||||
|
// ── 타임스탬프 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FRESH_TS = new Date().toISOString();
|
||||||
|
const STALE_TS = new Date(Date.now() - 120 * 60_000).toISOString(); // 2시간 전 → ais_stale
|
||||||
|
|
||||||
|
// ── 팩토리 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeAis(o: Partial<AisTarget> & Pick<AisTarget, 'mmsi' | 'lat' | 'lon'>): AisTarget {
|
||||||
|
return {
|
||||||
|
imo: 0,
|
||||||
|
name: '',
|
||||||
|
callsign: '',
|
||||||
|
vesselType: '',
|
||||||
|
heading: o.cog ?? 0,
|
||||||
|
sog: 0,
|
||||||
|
cog: 0,
|
||||||
|
rot: 0,
|
||||||
|
length: 30,
|
||||||
|
width: 8,
|
||||||
|
draught: 4,
|
||||||
|
destination: '',
|
||||||
|
eta: '',
|
||||||
|
status: 'underway',
|
||||||
|
messageTimestamp: FRESH_TS,
|
||||||
|
receivedDate: FRESH_TS,
|
||||||
|
source: 'mock',
|
||||||
|
classType: 'A',
|
||||||
|
...o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLegacy(
|
||||||
|
o: Partial<LegacyVesselInfo> & Pick<LegacyVesselInfo, 'permitNo' | 'shipCode' | 'mmsiList'>,
|
||||||
|
): LegacyVesselInfo {
|
||||||
|
return {
|
||||||
|
shipNameRoman: '',
|
||||||
|
shipNameCn: null,
|
||||||
|
ton: 100,
|
||||||
|
callSign: '',
|
||||||
|
workSeaArea: '서해',
|
||||||
|
workTerm1: '2025-01-01',
|
||||||
|
workTerm2: '2025-12-31',
|
||||||
|
quota: '',
|
||||||
|
ownerCn: null,
|
||||||
|
ownerRoman: null,
|
||||||
|
pairPermitNo: null,
|
||||||
|
pairShipNameCn: null,
|
||||||
|
checklistSheet: null,
|
||||||
|
sources: { permittedList: true, checklist: false, fleet906: false },
|
||||||
|
...o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 선박 정의 (12척) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 1 — 정상 쌍끌이 (간격 ~1 NM, 경고 없음)
|
||||||
|
* 위치: 서해남부(zone 3) 125.3°E 34.0°N 부근
|
||||||
|
*/
|
||||||
|
const PT_01_AIS = makeAis({ mmsi: 990001, lat: 34.00, lon: 125.30, sog: 3.3, cog: 45, name: 'MOCK VESSEL 1' });
|
||||||
|
const PT_02_AIS = makeAis({ mmsi: 990002, lat: 34.01, lon: 125.32, sog: 3.3, cog: 45, name: 'MOCK VESSEL 2' });
|
||||||
|
|
||||||
|
const PT_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P001', shipCode: 'PT', mmsiList: [990001],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 1', shipNameCn: '模拟渔船一号',
|
||||||
|
pairPermitNo: 'MOCK-P002', pairShipNameCn: '模拟渔船二号',
|
||||||
|
ownerRoman: 'MOCK Owner A', ownerCn: '模拟A渔业',
|
||||||
|
});
|
||||||
|
const PT_02_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P002', shipCode: 'PT-S', mmsiList: [990002],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 2', shipNameCn: '模拟渔船二号',
|
||||||
|
pairPermitNo: 'MOCK-P001', pairShipNameCn: '模拟渔船一号',
|
||||||
|
ownerRoman: 'MOCK Owner A', ownerCn: '模拟A渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 2 — 이격 쌍끌이 (간격 ~8 NM → pair_separation alarm)
|
||||||
|
* 위치: 서해남부(zone 3) 125.0°E 34.5°N 부근
|
||||||
|
*/
|
||||||
|
const PT_03_AIS = makeAis({ mmsi: 990003, lat: 34.50, lon: 125.00, sog: 3.5, cog: 90, name: 'MOCK VESSEL 3' });
|
||||||
|
const PT_04_AIS = makeAis({ mmsi: 990004, lat: 34.60, lon: 125.12, sog: 3.5, cog: 90, name: 'MOCK VESSEL 4' });
|
||||||
|
|
||||||
|
const PT_03_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P003', shipCode: 'PT', mmsiList: [990003],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 3', shipNameCn: '模拟渔船三号',
|
||||||
|
pairPermitNo: 'MOCK-P004', pairShipNameCn: '模拟渔船四号',
|
||||||
|
ownerRoman: 'MOCK Owner B', ownerCn: '模拟B渔业',
|
||||||
|
});
|
||||||
|
const PT_04_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P004', shipCode: 'PT-S', mmsiList: [990004],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 4', shipNameCn: '模拟渔船四号',
|
||||||
|
pairPermitNo: 'MOCK-P003', pairShipNameCn: '模拟渔船三号',
|
||||||
|
ownerRoman: 'MOCK Owner B', ownerCn: '模拟B渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 3 — 선단 (동일 선주 5척 → fleet circle)
|
||||||
|
* 위치: 서해중간(zone 4) 124.8°E 35.2°N 부근
|
||||||
|
* #11(GN)은 AIS 지연 2시간 → ais_stale alarm 동시 발생
|
||||||
|
*/
|
||||||
|
const GN_01_AIS = makeAis({ mmsi: 990005, lat: 35.20, lon: 124.80, sog: 1.0, cog: 180, name: 'MOCK VESSEL 5' });
|
||||||
|
const GN_02_AIS = makeAis({ mmsi: 990006, lat: 35.22, lon: 124.85, sog: 1.2, cog: 170, name: 'MOCK VESSEL 6' });
|
||||||
|
const GN_03_AIS = makeAis({ mmsi: 990007, lat: 35.18, lon: 124.82, sog: 0.8, cog: 200, name: 'MOCK VESSEL 7' });
|
||||||
|
const OT_01_AIS = makeAis({ mmsi: 990008, lat: 35.25, lon: 124.78, sog: 3.5, cog: 160, name: 'MOCK VESSEL 8' });
|
||||||
|
const GN_04_AIS = makeAis({
|
||||||
|
mmsi: 990011, lat: 35.00, lon: 125.20, sog: 1.5, cog: 190, name: 'MOCK VESSEL 10',
|
||||||
|
messageTimestamp: STALE_TS, receivedDate: STALE_TS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const GN_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P005', shipCode: 'GN', mmsiList: [990005],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 5', shipNameCn: '模拟渔船五号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
const GN_02_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P006', shipCode: 'GN', mmsiList: [990006],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 6', shipNameCn: '模拟渔船六号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
const GN_03_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P007', shipCode: 'GN', mmsiList: [990007],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 7', shipNameCn: '模拟渔船七号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
const OT_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P008', shipCode: 'OT', mmsiList: [990008],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 8', shipNameCn: '模拟渔船八号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
const GN_04_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P011', shipCode: 'GN', mmsiList: [990011],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 10', shipNameCn: '模拟渔船十号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 4 — 환적 의심 (FC ↔ PS 거리 ~0.15 NM → transshipment alarm)
|
||||||
|
* 위치: 서해남부(zone 3) 125.5°E 34.3°N 부근
|
||||||
|
*/
|
||||||
|
const FC_01_AIS = makeAis({ mmsi: 990009, lat: 34.30, lon: 125.50, sog: 1.0, cog: 0, name: 'MOCK CARRIER 1' });
|
||||||
|
const PS_01_AIS = makeAis({ mmsi: 990010, lat: 34.302, lon: 125.502, sog: 0.5, cog: 10, name: 'MOCK VESSEL 9' });
|
||||||
|
|
||||||
|
const FC_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P009', shipCode: 'FC', mmsiList: [990009],
|
||||||
|
shipNameRoman: 'MOCK CARRIER 1', shipNameCn: '模拟运船一号',
|
||||||
|
ownerRoman: 'MOCK Owner D', ownerCn: '模拟D渔业',
|
||||||
|
});
|
||||||
|
const PS_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P010', shipCode: 'PS', mmsiList: [990010],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 9', shipNameCn: '模拟渔船九号',
|
||||||
|
ownerRoman: 'MOCK Owner E', ownerCn: '模拟E渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 5 — 수역 이탈 (PT가 zone 4 에 위치 → zone_violation alarm)
|
||||||
|
* PT는 zone 2,3만 허가. zone 4(서해중간)에 위치하면 이탈 판정.
|
||||||
|
* 위치: 서해중간(zone 4) 125.0°E 36.5°N 부근
|
||||||
|
*/
|
||||||
|
const PT_05_AIS = makeAis({ mmsi: 990012, lat: 36.50, lon: 125.00, sog: 3.3, cog: 270, name: 'MOCK VESSEL 11' });
|
||||||
|
|
||||||
|
const PT_05_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P012', shipCode: 'PT', mmsiList: [990012],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 11', shipNameCn: '模拟渔船十一号',
|
||||||
|
ownerRoman: 'MOCK Owner F', ownerCn: '模拟F渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 공개 API ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 더미 AIS 타겟 (12척) */
|
||||||
|
export const MOCK_AIS_TARGETS: AisTarget[] = [
|
||||||
|
PT_01_AIS, PT_02_AIS, // Group 1: 정상 쌍끌이
|
||||||
|
PT_03_AIS, PT_04_AIS, // Group 2: 이격 쌍끌이
|
||||||
|
GN_01_AIS, GN_02_AIS, GN_03_AIS, OT_01_AIS, GN_04_AIS, // Group 3: 선단 + AIS 지연
|
||||||
|
FC_01_AIS, PS_01_AIS, // Group 4: 환적 의심
|
||||||
|
PT_05_AIS, // Group 5: 수역 이탈
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 더미 legacy 매칭 엔트리 (MMSI → LegacyVesselInfo) */
|
||||||
|
export const MOCK_LEGACY_ENTRIES: [number, LegacyVesselInfo][] = [
|
||||||
|
[990001, PT_01_LEG],
|
||||||
|
[990002, PT_02_LEG],
|
||||||
|
[990003, PT_03_LEG],
|
||||||
|
[990004, PT_04_LEG],
|
||||||
|
[990005, GN_01_LEG],
|
||||||
|
[990006, GN_02_LEG],
|
||||||
|
[990007, GN_03_LEG],
|
||||||
|
[990008, OT_01_LEG],
|
||||||
|
[990009, FC_01_LEG],
|
||||||
|
[990010, PS_01_LEG],
|
||||||
|
[990011, GN_04_LEG],
|
||||||
|
[990012, PT_05_LEG],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 더미 MMSI 집합 — 필터링/하이라이팅에 활용 */
|
||||||
|
export const MOCK_MMSI_SET = new Set(MOCK_AIS_TARGETS.map((t) => t.mmsi));
|
||||||
@ -88,3 +88,21 @@ export const LEGACY_ALARM_KIND_LABEL: Record<LegacyAlarmKind, string> = {
|
|||||||
ais_stale: "AIS 지연",
|
ais_stale: "AIS 지연",
|
||||||
zone_violation: "수역 이탈",
|
zone_violation: "수역 이탈",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 경고 우선순위 (낮→높). 배열 뒤가 높은 우선순위. */
|
||||||
|
export const ALARM_KIND_PRIORITY: LegacyAlarmKind[] = [
|
||||||
|
"ais_stale",
|
||||||
|
"closed_season",
|
||||||
|
"transshipment",
|
||||||
|
"zone_violation",
|
||||||
|
"pair_separation",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 경고 배지 — 지도 위 선박 옆에 표시할 약어 + 색상 */
|
||||||
|
export const ALARM_BADGE: Record<LegacyAlarmKind, { label: string; color: string; rgba: [number, number, number, number] }> = {
|
||||||
|
pair_separation: { label: "이", color: "#ef4444", rgba: [239, 68, 68, 200] },
|
||||||
|
zone_violation: { label: "수", color: "#a855f7", rgba: [168, 85, 247, 200] },
|
||||||
|
transshipment: { label: "환", color: "#f97316", rgba: [249, 115, 22, 200] },
|
||||||
|
closed_season: { label: "휴", color: "#eab308", rgba: [234, 179, 8, 200] },
|
||||||
|
ais_stale: { label: "A", color: "#6b7280", rgba: [107, 114, 128, 200] },
|
||||||
|
};
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { ToggleButton } from '@wing/ui';
|
||||||
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
|
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -13,11 +14,11 @@ export function Map3DSettingsToggles({ value, onToggle }: Props) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tog">
|
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||||
{items.map((t) => (
|
{items.map((t) => (
|
||||||
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
<ToggleButton key={t.id} on={value[t.id]} onClick={() => onToggle(t.id)}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</div>
|
</ToggleButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -146,8 +146,7 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
|
|||||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
수심 구간 색상
|
수심 구간 색상
|
||||||
<span
|
<span
|
||||||
className={`tog-btn${autoGradient ? ' on' : ''}`}
|
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||||
style={{ fontSize: 8, padding: '1px 5px', marginLeft: 8 }}
|
|
||||||
onClick={toggleAutoGradient}
|
onClick={toggleAutoGradient}
|
||||||
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
||||||
>
|
>
|
||||||
@ -176,11 +175,11 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
|
|||||||
{/* ── Depth font size ───────────────────────────── */}
|
{/* ── Depth font size ───────────────────────────── */}
|
||||||
<div className="ms-section">
|
<div className="ms-section">
|
||||||
<div className="ms-label">수심 폰트 크기</div>
|
<div className="ms-label">수심 폰트 크기</div>
|
||||||
<div className="tog" style={{ gap: 3 }}>
|
<div className="flex flex-wrap gap-0.75">
|
||||||
{FONT_SIZES.map((fs) => (
|
{FONT_SIZES.map((fs) => (
|
||||||
<div
|
<div
|
||||||
key={fs.value}
|
key={fs.value}
|
||||||
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
|
className={`cursor-pointer rounded border px-1.5 py-0.5 text-[8px] transition-all duration-150 select-none ${value.depthFontSize === fs.value ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||||
onClick={() => update('depthFontSize', fs.value)}
|
onClick={() => update('depthFontSize', fs.value)}
|
||||||
>
|
>
|
||||||
{fs.label}
|
{fs.label}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { ToggleButton } from '@wing/ui';
|
||||||
|
|
||||||
export type MapToggleState = {
|
export type MapToggleState = {
|
||||||
pairLines: boolean;
|
pairLines: boolean;
|
||||||
pairRange: boolean;
|
pairRange: boolean;
|
||||||
@ -27,11 +29,16 @@ export function MapToggles({ value, onToggle }: Props) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tog tog-map">
|
<div className="flex flex-wrap gap-1">
|
||||||
{items.map((t) => (
|
{items.map((t) => (
|
||||||
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
<ToggleButton
|
||||||
|
key={t.id}
|
||||||
|
on={value[t.id]}
|
||||||
|
onClick={() => onToggle(t.id)}
|
||||||
|
className="flex-[1_1_calc(25%-4px)] overflow-hidden text-center text-ellipsis whitespace-nowrap"
|
||||||
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</div>
|
</ToggleButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { Layer } from '@deck.gl/core';
|
|||||||
import type { ProcessedTrack } from '../model/track.types';
|
import type { ProcessedTrack } from '../model/track.types';
|
||||||
import { getShipKindColor } from '../lib/adapters';
|
import { getShipKindColor } from '../lib/adapters';
|
||||||
import { TRACK_REPLAY_LAYER_IDS } from './trackLayers';
|
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 {
|
interface ReplayTrip {
|
||||||
vesselId: string;
|
vesselId: string;
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||||
import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../widgets/map3d/constants';
|
import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants';
|
||||||
import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache';
|
|
||||||
import { getShipKindColor } from '../lib/adapters';
|
import { getShipKindColor } from '../lib/adapters';
|
||||||
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
|
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
|
||||||
|
|
||||||
@ -139,20 +138,21 @@ export function createDynamicTrackLayers(options: {
|
|||||||
|
|
||||||
if (showVirtualShip) {
|
if (showVirtualShip) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new IconLayer<CurrentVesselPosition>({
|
new ScatterplotLayer<CurrentVesselPosition>({
|
||||||
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
|
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
|
||||||
data: currentPositions,
|
data: currentPositions,
|
||||||
iconAtlas: getCachedShipIcon(),
|
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
|
||||||
getIcon: () => 'ship',
|
|
||||||
getPosition: (d) => d.position,
|
getPosition: (d) => d.position,
|
||||||
getSize: 22,
|
getFillColor: (d) => {
|
||||||
sizeUnits: 'pixels',
|
|
||||||
getAngle: (d) => -d.heading,
|
|
||||||
getColor: (d) => {
|
|
||||||
const base = getShipKindColor(d.shipKindCode);
|
const base = getShipKindColor(d.shipKindCode);
|
||||||
return [base[0], base[1], base[2], 245] as [number, number, number, number];
|
return [base[0], base[1], base[2], 230] as [number, number, number, number];
|
||||||
},
|
},
|
||||||
|
getLineColor: [255, 255, 255, 200],
|
||||||
|
getRadius: 5,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
radiusMinPixels: 4,
|
||||||
|
radiusMaxPixels: 8,
|
||||||
|
stroked: true,
|
||||||
|
lineWidthMinPixels: 1,
|
||||||
parameters: DEPTH_DISABLED_PARAMS,
|
parameters: DEPTH_DISABLED_PARAMS,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
|
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
|
||||||
@ -183,6 +183,7 @@ export function createDynamicTrackLayers(options: {
|
|||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
getPixelOffset: [14, 0],
|
getPixelOffset: [14, 0],
|
||||||
fontFamily: 'Malgun Gothic, Arial, sans-serif',
|
fontFamily: 'Malgun Gothic, Arial, sans-serif',
|
||||||
|
fontSettings: { sdf: true },
|
||||||
outlineColor: [2, 6, 23, 220],
|
outlineColor: [2, 6, 23, 220],
|
||||||
outlineWidth: 2,
|
outlineWidth: 2,
|
||||||
parameters: DEPTH_DISABLED_PARAMS,
|
parameters: DEPTH_DISABLED_PARAMS,
|
||||||
|
|||||||
@ -9,26 +9,26 @@ type Props = {
|
|||||||
onToggleAll: () => void;
|
onToggleAll: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TB = "cursor-pointer rounded-[5px] border p-1 text-center transition-all duration-150 select-none";
|
||||||
|
const TB_ON = "border-wing-accent bg-wing-accent/10";
|
||||||
|
const TB_OFF = "border-transparent bg-wing-card hover:border-wing-border";
|
||||||
|
|
||||||
export function TypeFilterGrid({ enabled, totalCount, countsByType, onToggle, onToggleAll }: Props) {
|
export function TypeFilterGrid({ enabled, totalCount, countsByType, onToggle, onToggleAll }: Props) {
|
||||||
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
|
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tg">
|
<div className="grid grid-cols-3 gap-0.75">
|
||||||
<div className={`tb ${allOn ? "on" : ""}`} onClick={onToggleAll} style={{ gridColumn: "1/-1" }}>
|
<div className={`col-span-full ${TB} ${allOn ? TB_ON : TB_OFF}`} onClick={onToggleAll}>
|
||||||
<div className="c" style={{ color: "var(--accent)" }}>
|
<div className="text-[11px] font-extrabold text-wing-accent">전체</div>
|
||||||
전체
|
<div className="text-[8px] text-wing-muted">{totalCount}척</div>
|
||||||
</div>
|
|
||||||
<div className="n">{totalCount}척</div>
|
|
||||||
</div>
|
</div>
|
||||||
{VESSEL_TYPE_ORDER.map((code) => {
|
{VESSEL_TYPE_ORDER.map((code) => {
|
||||||
const t = VESSEL_TYPES[code];
|
const t = VESSEL_TYPES[code];
|
||||||
const cnt = countsByType[code] ?? 0;
|
const cnt = countsByType[code] ?? 0;
|
||||||
return (
|
return (
|
||||||
<div key={code} className={`tb ${enabled[code] ? "on" : ""}`} onClick={() => onToggle(code)}>
|
<div key={code} className={`${TB} ${enabled[code] ? TB_ON : TB_OFF}`} onClick={() => onToggle(code)}>
|
||||||
<div className="c" style={{ color: t.color }}>
|
<div className="text-[11px] font-extrabold" style={{ color: t.color }}>{code}</div>
|
||||||
{code}
|
<div className="text-[8px] text-wing-muted">{cnt}척</div>
|
||||||
</div>
|
|
||||||
<div className="n">{cnt}척</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
RadarLayer,
|
RadarLayer,
|
||||||
ColorRamp,
|
ColorRamp,
|
||||||
} from '@maptiler/weather';
|
} from '@maptiler/weather';
|
||||||
import { getMapTilerKey } from '../../widgets/map3d/lib/mapCore';
|
import { getMapTilerKey } from '../../shared/lib/map/mapTilerKey';
|
||||||
|
|
||||||
/** 6종 기상 레이어 ID */
|
/** 6종 기상 레이어 ID */
|
||||||
export type WeatherLayerId =
|
export type WeatherLayerId =
|
||||||
|
|||||||
@ -1,37 +1,23 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useAuth } from "../../shared/auth";
|
import { useAuth } from "../../shared/auth";
|
||||||
import { usePersistedState } from "../../shared/hooks";
|
import { useTheme } from "../../shared/hooks";
|
||||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
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 type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
|
||||||
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
import { ALARM_KIND_PRIORITY, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||||
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||||
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
|
||||||
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
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 { useZones } from "../../entities/zone/api/useZones";
|
||||||
import { useSubcables } from "../../entities/subcable/api/useSubcables";
|
import { useSubcables } from "../../entities/subcable/api/useSubcables";
|
||||||
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
|
||||||
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
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 { MapLegend } from "../../widgets/legend/MapLegend";
|
||||||
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
|
import { Map3D } from "../../widgets/map3d/Map3D";
|
||||||
import type { MapViewState } from "../../widgets/map3d/types";
|
|
||||||
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
|
||||||
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
|
||||||
import { Topbar } from "../../widgets/topbar/Topbar";
|
import { Topbar } from "../../widgets/topbar/Topbar";
|
||||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
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 { 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 { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
|
||||||
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
|
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
|
||||||
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
|
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
|
||||||
@ -39,6 +25,7 @@ import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolli
|
|||||||
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
||||||
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
||||||
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
||||||
|
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||||
import {
|
import {
|
||||||
buildLegacyHitMap,
|
buildLegacyHitMap,
|
||||||
computeCountsByType,
|
computeCountsByType,
|
||||||
@ -49,18 +36,17 @@ import {
|
|||||||
deriveLegacyVessels,
|
deriveLegacyVessels,
|
||||||
filterByShipCodes,
|
filterByShipCodes,
|
||||||
} from "../../features/legacyDashboard/model/derive";
|
} from "../../features/legacyDashboard/model/derive";
|
||||||
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
import { MOCK_AIS_TARGETS, MOCK_LEGACY_ENTRIES } from "../../features/legacyDashboard/dev/mockOverlayData";
|
||||||
|
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 = {
|
const AIS_CENTER = {
|
||||||
lon: 126.95,
|
lon: 126.95,
|
||||||
lat: 35.95,
|
lat: 35.95,
|
||||||
radiusMeters: 2_000_000,
|
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) {
|
function inBbox(lon: number, lat: number, bbox: Bbox) {
|
||||||
const [lonMin, latMin, lonMax, latMax] = bbox;
|
const [lonMin, latMin, lonMax, latMax] = bbox;
|
||||||
if (lat < latMin || lat > latMax) return false;
|
if (lat < latMin || lat > latMax) return false;
|
||||||
@ -68,34 +54,59 @@ function inBbox(lon: number, lat: number, bbox: Bbox) {
|
|||||||
return lon >= lonMin || lon <= lonMax;
|
return lon >= lonMin || lon <= lonMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBbox(b: Bbox | null) {
|
function useLegacyIndex(data: LegacyVesselDataset | 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 {
|
|
||||||
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const uid = user?.id ?? null;
|
||||||
|
const isDevMode = user?.name?.includes('(DEV)') ?? false;
|
||||||
|
|
||||||
|
// ── Data fetching ──
|
||||||
const { data: zones, error: zonesError } = useZones();
|
const { data: zones, error: zonesError } = useZones();
|
||||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||||
const { data: subcableData } = useSubcables();
|
const { data: subcableData } = useSubcables();
|
||||||
const legacyIndex = useLegacyIndex(legacyData);
|
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 weather = useWeatherPolling(zones);
|
||||||
const [mapInstance, setMapInstance] = useState<import("maplibre-gl").Map | null>(null);
|
|
||||||
const weatherOverlay = useWeatherOverlay(mapInstance);
|
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({
|
const { targets, snapshot } = useAisTargetPolling({
|
||||||
chnprmshipMinutes: 120,
|
chnprmshipMinutes: 120,
|
||||||
incrementalMinutes: 2,
|
incrementalMinutes: 2,
|
||||||
@ -107,48 +118,7 @@ export function DashboardPage() {
|
|||||||
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
|
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
// ── Track request ──
|
||||||
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), []);
|
|
||||||
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
||||||
const trackStore = useTrackQueryStore.getState();
|
const trackStore = useTrackQueryStore.getState();
|
||||||
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
||||||
@ -172,46 +142,21 @@ export function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [targets]);
|
}, [targets]);
|
||||||
|
|
||||||
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
// ── Derived data ──
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetsInScope = useMemo(() => {
|
const targetsInScope = useMemo(() => {
|
||||||
if (!useViewportFilter || !viewBbox) return targets;
|
const base = (!useViewportFilter || !viewBbox)
|
||||||
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
? targets
|
||||||
}, [targets, useViewportFilter, viewBbox]);
|
: targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
||||||
|
return isDevMode ? [...base, ...MOCK_AIS_TARGETS] : base;
|
||||||
|
}, [targets, useViewportFilter, viewBbox, isDevMode]);
|
||||||
|
|
||||||
const legacyHits = useMemo(() => buildLegacyHitMap(targetsInScope, legacyIndex), [targetsInScope, legacyIndex]);
|
const legacyHits = useMemo(() => {
|
||||||
|
const hits = buildLegacyHitMap(targetsInScope, legacyIndex);
|
||||||
|
if (isDevMode) {
|
||||||
|
for (const [mmsi, info] of MOCK_LEGACY_ENTRIES) hits.set(mmsi, info);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}, [targetsInScope, legacyIndex, isDevMode]);
|
||||||
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
|
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
|
||||||
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
|
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
|
||||||
|
|
||||||
@ -244,17 +189,14 @@ export function DashboardPage() {
|
|||||||
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
||||||
|
|
||||||
const alarmKindCounts = useMemo(() => {
|
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) {
|
for (const a of alarms) {
|
||||||
base[a.kind] = (base[a.kind] ?? 0) + 1;
|
base[a.kind] = (base[a.kind] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}, [alarms]);
|
}, [alarms]);
|
||||||
|
|
||||||
const enabledAlarmKinds = useMemo(() => {
|
const enabledAlarmKinds = useMemo(() => LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]), [alarmKindEnabled]);
|
||||||
return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]);
|
|
||||||
}, [alarmKindEnabled]);
|
|
||||||
|
|
||||||
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
||||||
|
|
||||||
const filteredAlarms = useMemo(() => {
|
const filteredAlarms = useMemo(() => {
|
||||||
@ -263,6 +205,17 @@ export function DashboardPage() {
|
|||||||
return alarms.filter((a) => enabled.has(a.kind));
|
return alarms.filter((a) => enabled.has(a.kind));
|
||||||
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
|
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
|
||||||
|
|
||||||
|
const alarmMmsiMap = useMemo(() => {
|
||||||
|
const m = new Map<number, LegacyAlarmKind>();
|
||||||
|
for (const kind of ALARM_KIND_PRIORITY) {
|
||||||
|
for (const alarm of filteredAlarms) {
|
||||||
|
if (alarm.kind !== kind) continue;
|
||||||
|
for (const mmsi of alarm.relatedMmsi) m.set(mmsi, kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [filteredAlarms]);
|
||||||
|
|
||||||
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
@ -291,14 +244,11 @@ export function DashboardPage() {
|
|||||||
[highlightedMmsiSet, availableTargetMmsiSet],
|
[highlightedMmsiSet, availableTargetMmsiSet],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setUniqueSorted = (items: number[]) =>
|
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
||||||
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
|
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 handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
||||||
if (!mmsis.length) return;
|
if (!mmsis.length) return;
|
||||||
const members = mmsis
|
const members = mmsis
|
||||||
@ -312,413 +262,54 @@ export function DashboardPage() {
|
|||||||
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
|
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
|
||||||
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
|
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
|
||||||
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
|
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
|
||||||
setFleetFocus({
|
setFleetFocus({ id: `${ownerKey}-${Date.now()}`, center, zoom: 9 });
|
||||||
id: `${ownerKey}-${Date.now()}`,
|
|
||||||
center,
|
|
||||||
zoom: 9,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleHighlightedMmsi = (mmsi: number) => {
|
// ── Render ──
|
||||||
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}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="grid h-screen grid-cols-[310px_1fr] grid-rows-[44px_1fr] max-md:grid-cols-[1fr] max-md:grid-rows-[auto_1fr]">
|
||||||
<Topbar
|
<Topbar
|
||||||
total={legacyVesselsAll.length}
|
total={legacyVesselsAll.length}
|
||||||
fishing={fishingCount}
|
fishing={fishingCount}
|
||||||
transit={transitCount}
|
transit={transitCount}
|
||||||
pairLinks={pairLinksAll.length}
|
pairLinks={pairLinksAll.length}
|
||||||
alarms={alarms.length}
|
alarms={alarms.length}
|
||||||
pollingStatus={snapshot.status}
|
|
||||||
lastFetchMinutes={snapshot.lastFetchMinutes}
|
|
||||||
clock={clock}
|
clock={clock}
|
||||||
adminMode={adminMode}
|
adminMode={adminMode}
|
||||||
onLogoClick={onLogoClick}
|
onLogoClick={onLogoClick}
|
||||||
userName={user?.name}
|
userName={user?.name}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
|
isSidebarOpen={isSidebarOpen}
|
||||||
|
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="sidebar">
|
<DashboardSidebar
|
||||||
<div className="sb">
|
isOpen={isSidebarOpen}
|
||||||
<div className="sb-t">업종 필터</div>
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
<div className="tog">
|
state={state}
|
||||||
<div
|
legacyVesselsAll={legacyVesselsAll}
|
||||||
className={`tog-btn ${showTargets ? "on" : ""}`}
|
legacyVesselsFiltered={legacyVesselsFiltered}
|
||||||
onClick={() => {
|
legacyCounts={legacyCounts}
|
||||||
setShowTargets((v) => {
|
selectedLegacyVessel={selectedLegacyVessel}
|
||||||
const next = !v;
|
activeHighlightedMmsiSet={activeHighlightedMmsiSet}
|
||||||
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
legacyHits={legacyHits}
|
||||||
return next;
|
filteredAlarms={filteredAlarms}
|
||||||
});
|
alarms={alarms}
|
||||||
}}
|
alarmKindCounts={alarmKindCounts}
|
||||||
title="레거시(CN permit) 대상 선박 표시"
|
speedPanelType={speedPanelType}
|
||||||
>
|
onFleetContextMenu={handleFleetContextMenu}
|
||||||
대상 선박
|
snapshot={snapshot}
|
||||||
</div>
|
legacyError={legacyError}
|
||||||
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
legacyData={legacyData}
|
||||||
기타 AIS
|
targetsInScope={targetsInScope}
|
||||||
</div>
|
zonesError={zonesError}
|
||||||
</div>
|
zones={zones}
|
||||||
<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}
|
legacyIndex={legacyIndex}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
<div className="relative bg-[#010610]">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="map-area">
|
|
||||||
{showMapLoader ? (
|
{showMapLoader ? (
|
||||||
<div className="map-loader-overlay" role="status" aria-live="polite">
|
<div className="map-loader-overlay" role="status" aria-live="polite">
|
||||||
<div className="map-loader-overlay__panel">
|
<div className="map-loader-overlay__panel">
|
||||||
@ -778,6 +369,7 @@ export function DashboardPage() {
|
|||||||
onCloseTrackMenu={handleCloseTrackMenu}
|
onCloseTrackMenu={handleCloseTrackMenu}
|
||||||
onOpenTrackMenu={handleOpenTrackMenu}
|
onOpenTrackMenu={handleOpenTrackMenu}
|
||||||
onMapReady={handleMapReady}
|
onMapReady={handleMapReady}
|
||||||
|
alarmMmsiMap={alarmMmsiMap}
|
||||||
/>
|
/>
|
||||||
<GlobalTrackReplayPanel />
|
<GlobalTrackReplayPanel />
|
||||||
<WeatherPanel
|
<WeatherPanel
|
||||||
|
|||||||
406
apps/web/src/pages/dashboard/DashboardSidebar.tsx
Normal file
406
apps/web/src/pages/dashboard/DashboardSidebar.tsx
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ToggleButton, Section } from '@wing/ui';
|
||||||
|
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 {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
state: ReturnType<typeof useDashboardState>;
|
||||||
|
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>;
|
||||||
|
speedPanelType: VesselTypeCode;
|
||||||
|
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
|
||||||
|
snapshot: AisPollingSnapshot;
|
||||||
|
legacyError: string | null;
|
||||||
|
legacyData: LegacyVesselDataset | null;
|
||||||
|
targetsInScope: AisTarget[];
|
||||||
|
zonesError: string | null;
|
||||||
|
zones: ZonesGeoJson | null;
|
||||||
|
legacyIndex: LegacyVesselIndex | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSidebar({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
state,
|
||||||
|
legacyVesselsAll,
|
||||||
|
legacyVesselsFiltered,
|
||||||
|
legacyCounts,
|
||||||
|
selectedLegacyVessel,
|
||||||
|
activeHighlightedMmsiSet,
|
||||||
|
legacyHits,
|
||||||
|
filteredAlarms,
|
||||||
|
alarms,
|
||||||
|
alarmKindCounts,
|
||||||
|
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;
|
||||||
|
|
||||||
|
const [isAlarmFilterOpen, setIsAlarmFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-[1100] bg-black/50 md:hidden" onClick={onClose} aria-hidden />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
fixed inset-y-0 left-0 z-[1200] w-[310px] max-w-[100vw] transform overflow-y-auto
|
||||||
|
border-r border-wing-border bg-wing-surface transition-transform duration-200
|
||||||
|
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
|
md:static md:z-auto md:translate-x-0 md:transition-none md:overflow-hidden md:flex md:flex-col
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="h-[44px] md:hidden" />
|
||||||
|
|
||||||
|
<Section title="업종 필터" className="md:shrink-0">
|
||||||
|
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||||
|
<ToggleButton
|
||||||
|
on={showTargets}
|
||||||
|
onClick={() => {
|
||||||
|
setShowTargets((v) => {
|
||||||
|
const next = !v;
|
||||||
|
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="레거시(CN permit) 대상 선박 표시"
|
||||||
|
>
|
||||||
|
대상 선박
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton on={showOthers} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||||
|
기타 AIS
|
||||||
|
</ToggleButton>
|
||||||
|
</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 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="지도 표시 설정"
|
||||||
|
className="md:shrink-0"
|
||||||
|
actions={
|
||||||
|
<ToggleButton
|
||||||
|
on={projection === 'globe'}
|
||||||
|
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
||||||
|
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영'}
|
||||||
|
className={`px-2 py-0.5 text-[9px]${isProjectionToggleDisabled ? " opacity-40 cursor-not-allowed" : ""}`}
|
||||||
|
>
|
||||||
|
3D
|
||||||
|
</ToggleButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="속도 프로파일" defaultOpen={false} className="md:shrink-0">
|
||||||
|
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
선단 연관관계{' '}
|
||||||
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">
|
||||||
|
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
className="md:shrink-0 max-h-[260px] flex flex-col overflow-hidden"
|
||||||
|
contentClassName="flex-1 min-h-0 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
선박 목록{' '}
|
||||||
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({legacyVesselsFiltered.length}척)</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
className="md:flex-1 md:min-h-0 flex flex-col overflow-hidden"
|
||||||
|
contentClassName="md:flex-1 md:min-h-0 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<VesselList
|
||||||
|
vessels={legacyVesselsFiltered}
|
||||||
|
selectedMmsi={selectedMmsi}
|
||||||
|
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||||
|
onSelectMmsi={setSelectedMmsi}
|
||||||
|
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||||
|
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||||
|
onClearHover={() => setHoveredMmsiSet([])}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
실시간 경고{' '}
|
||||||
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({filteredAlarms.length}/{alarms.length})</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<ToggleButton on={isAlarmFilterOpen} onClick={() => setIsAlarmFilterOpen((v) => !v)}>
|
||||||
|
필터
|
||||||
|
</ToggleButton>
|
||||||
|
}
|
||||||
|
className="md:shrink-0 max-h-[180px] flex flex-col overflow-hidden"
|
||||||
|
contentClassName="flex-1 min-h-0 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{isAlarmFilterOpen && (
|
||||||
|
<div className="flex flex-wrap gap-x-2.5 gap-y-1 mb-1.5">
|
||||||
|
{LEGACY_ALARM_KINDS.map((k) => (
|
||||||
|
<label key={k} className="inline-flex gap-1 items-center cursor-pointer select-none">
|
||||||
|
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
|
<span className="text-[8px] text-wing-muted whitespace-nowrap">
|
||||||
|
{LEGACY_ALARM_KIND_LABEL[k]} <span className="text-wing-text">{alarmKindCounts[k] ?? 0}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{adminMode ? (
|
||||||
|
<>
|
||||||
|
<Section title="ADMIN · AIS Target Polling" defaultOpen={false} className="md:shrink-0">
|
||||||
|
<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' ? 'var(--wing-success)' : snapshot.status === 'error' ? 'var(--wing-danger)' : 'var(--wing-warning)' }}>
|
||||||
|
{snapshot.status.toUpperCase()}
|
||||||
|
</b>
|
||||||
|
{snapshot.error ? <span style={{ marginLeft: 6, color: 'var(--wing-danger)' }}>{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>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false} className="md:shrink-0">
|
||||||
|
{legacyError ? (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>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: 'var(--wing-warning)' }}>{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>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="ADMIN · Viewport / BBox" defaultOpen={false} className="md:shrink-0">
|
||||||
|
<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로 필터링해서 내려받기"
|
||||||
|
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>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="ADMIN · Map (Extras)" defaultOpen={false} className="md:shrink-0">
|
||||||
|
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="ADMIN · AIS Targets (All)"
|
||||||
|
defaultOpen={false}
|
||||||
|
className="md:shrink-0 md:max-h-[200px] flex flex-col md:overflow-hidden"
|
||||||
|
contentClassName="md:flex-1 md:min-h-0 md:overflow-y-auto"
|
||||||
|
>
|
||||||
|
<AisTargetList targets={targetsInScope} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} legacyIndex={legacyIndex} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="md:shrink-0">
|
||||||
|
{zonesError ? (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>zones load error: {zonesError}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{zones ? `loaded (${zones.features.length} features)` : 'loading...'}</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
) : 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 +1,2 @@
|
|||||||
export { usePersistedState } from './usePersistedState';
|
export { usePersistedState } from './usePersistedState';
|
||||||
|
export { useTheme } from './useTheme';
|
||||||
|
|||||||
47
apps/web/src/shared/hooks/useTheme.ts
Normal file
47
apps/web/src/shared/hooks/useTheme.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'wing:theme';
|
||||||
|
const DEFAULT_THEME: Theme = 'dark';
|
||||||
|
|
||||||
|
function readTheme(): Theme {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === 'light' || raw === 'dark') return raw;
|
||||||
|
} catch {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
const t = readTheme();
|
||||||
|
applyTheme(t);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const setTheme = useCallback((t: Theme) => {
|
||||||
|
setThemeState(t);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, t);
|
||||||
|
} catch {
|
||||||
|
// quota exceeded or unavailable
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
|
}, [theme, setTheme]);
|
||||||
|
|
||||||
|
return { theme, setTheme, toggleTheme } as const;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -1,10 +1,23 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
||||||
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||||
|
|
||||||
export function MapLegend() {
|
export function MapLegend() {
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-legend">
|
<div className="map-legend">
|
||||||
<div className="lt">수역</div>
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center justify-between border-none bg-transparent p-0 text-left"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="lt" style={{ marginBottom: 0 }}>범례</span>
|
||||||
|
<span className="text-[9px] text-wing-muted">{isOpen ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<div className="lt" style={{ marginTop: 6 }}>수역</div>
|
||||||
{ZONE_IDS.map((z) => (
|
{ZONE_IDS.map((z) => (
|
||||||
<div key={z} className="li">
|
<div key={z} className="li">
|
||||||
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
||||||
@ -12,9 +25,7 @@ export function MapLegend() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(속도)</div>
|
||||||
기타 AIS 선박(속도)
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||||
SOG ≥ 10 kt
|
SOG ≥ 10 kt
|
||||||
@ -32,12 +43,10 @@ export function MapLegend() {
|
|||||||
SOG unknown
|
SOG unknown
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
||||||
CN Permit(업종)
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
||||||
PT 본선 (ring + 색상)
|
PT 본선
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
||||||
@ -64,16 +73,12 @@ export function MapLegend() {
|
|||||||
C21
|
C21
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>밀도(3D)</div>
|
||||||
밀도(3D)
|
|
||||||
</div>
|
|
||||||
<div className="li" style={{ color: "var(--muted)" }}>
|
<div className="li" style={{ color: "var(--muted)" }}>
|
||||||
Hexagon: 화면 내 AIS 포인트 집계
|
Hexagon: 화면 내 AIS 포인트 집계
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>연결선</div>
|
||||||
연결선
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
||||||
PT↔PT-S 쌍 (정상)
|
PT↔PT-S 쌍 (정상)
|
||||||
@ -109,6 +114,8 @@ export function MapLegend() {
|
|||||||
/>
|
/>
|
||||||
예측 벡터 (15분)
|
예측 벡터 (15분)
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export function Map3D({
|
|||||||
onCloseTrackMenu,
|
onCloseTrackMenu,
|
||||||
onOpenTrackMenu,
|
onOpenTrackMenu,
|
||||||
onMapReady,
|
onMapReady,
|
||||||
|
alarmMmsiMap,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// ── Shared refs ──────────────────────────────────────────────────────
|
// ── Shared refs ──────────────────────────────────────────────────────
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -93,6 +94,7 @@ export function Map3D({
|
|||||||
const projectionBusyRef = useRef(false);
|
const projectionBusyRef = useRef(false);
|
||||||
const deckHoverRafRef = useRef<number | null>(null);
|
const deckHoverRafRef = useRef<number | null>(null);
|
||||||
const deckHoverHasHitRef = useRef(false);
|
const deckHoverHasHitRef = useRef(false);
|
||||||
|
const mapInitiatedSelectRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]);
|
useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]);
|
||||||
useEffect(() => { projectionRef.current = projection; }, [projection]);
|
useEffect(() => { projectionRef.current = projection; }, [projection]);
|
||||||
@ -237,12 +239,15 @@ export function Map3D({
|
|||||||
return out;
|
return out;
|
||||||
}, [highlightedMmsiSetForShips, selectedMmsi]);
|
}, [highlightedMmsiSetForShips, selectedMmsi]);
|
||||||
|
|
||||||
|
// Globe: 직접 호버/선택된 선박만 hover overlay에 포함
|
||||||
|
// 선단/쌍 멤버는 feature-state(outline 색상)로 하이라이트 → hover overlay 불필요
|
||||||
|
// → alarm badge 레이어 가림 방지
|
||||||
const shipHoverOverlaySet = useMemo(
|
const shipHoverOverlaySet = useMemo(
|
||||||
() =>
|
() =>
|
||||||
projection === 'globe'
|
projection === 'globe'
|
||||||
? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet)
|
? mergeNumberSets(shipHighlightSet, hoveredDeckMmsiSetRef)
|
||||||
: shipHighlightSet,
|
: shipHighlightSet,
|
||||||
[projection, highlightedMmsiSetCombined, shipHighlightSet],
|
[projection, shipHighlightSet, hoveredDeckMmsiSetRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const shipOverlayLayerData = useMemo(() => {
|
const shipOverlayLayerData = useMemo(() => {
|
||||||
@ -272,6 +277,13 @@ export function Map3D({
|
|||||||
return out;
|
return out;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 지도 내부 클릭에서의 선택 — fly-to 비활성화 플래그 설정
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const onMapSelectMmsi = useCallback((mmsi: number | null) => {
|
||||||
|
mapInitiatedSelectRef.current = true;
|
||||||
|
onSelectMmsi(mmsi);
|
||||||
|
}, [onSelectMmsi]);
|
||||||
|
|
||||||
const onDeckSelectOrHighlight = useCallback(
|
const onDeckSelectOrHighlight = useCallback(
|
||||||
(info: unknown, allowMultiSelect = false) => {
|
(info: unknown, allowMultiSelect = false) => {
|
||||||
const obj = info as {
|
const obj = info as {
|
||||||
@ -287,12 +299,12 @@ export function Map3D({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!allowMultiSelect && selectedMmsi === mmsi) {
|
if (!allowMultiSelect && selectedMmsi === mmsi) {
|
||||||
onSelectMmsi(null);
|
onMapSelectMmsi(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelectMmsi(mmsi);
|
onMapSelectMmsi(mmsi);
|
||||||
},
|
},
|
||||||
[hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
|
[hasAuxiliarySelectModifier, onMapSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
@ -561,9 +573,9 @@ export function Map3D({
|
|||||||
{
|
{
|
||||||
projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
|
projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
|
||||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||||
onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
||||||
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||||
onGlobeShipsReady,
|
onGlobeShipsReady, alarmMmsiMap,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -596,8 +608,8 @@ export function Map3D({
|
|||||||
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
||||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
|
||||||
ensureMercatorOverlay, projectionRef,
|
ensureMercatorOverlay, alarmMmsiMap,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -632,22 +644,30 @@ export function Map3D({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!onOpenTrackMenu) return;
|
if (!onOpenTrackMenu) return;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return;
|
if (!map || projectionBusyRef.current) return;
|
||||||
|
|
||||||
let mmsi: number | null = null;
|
let mmsi: number | null = null;
|
||||||
|
|
||||||
if (projectionRef.current === 'globe') {
|
// Globe/Mercator 공통: MapLibre 레이어에서 bbox 쿼리 (호버 상태 무관)
|
||||||
// Globe: MapLibre 네이티브 레이어에서 쿼리
|
let shipLayerIds: string[] = [];
|
||||||
const point: [number, number] = [e.offsetX, e.offsetY];
|
try {
|
||||||
const shipLayerIds = [
|
shipLayerIds = projectionRef.current === 'globe'
|
||||||
|
? [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
].filter((id) => map.getLayer(id));
|
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||||
|
].filter((id) => map.getLayer(id))
|
||||||
|
: [];
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (shipLayerIds.length > 0) {
|
||||||
|
const tolerance = 8;
|
||||||
|
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||||
|
[e.offsetX - tolerance, e.offsetY - tolerance],
|
||||||
|
[e.offsetX + tolerance, e.offsetY + tolerance],
|
||||||
|
];
|
||||||
let features: maplibregl.MapGeoJSONFeature[] = [];
|
let features: maplibregl.MapGeoJSONFeature[] = [];
|
||||||
try {
|
try {
|
||||||
if (shipLayerIds.length > 0) {
|
features = map.queryRenderedFeatures(bbox, { layers: shipLayerIds });
|
||||||
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
@ -655,8 +675,10 @@ export function Map3D({
|
|||||||
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
|
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
|
||||||
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
|
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
|
|
||||||
|
// Mercator fallback: Deck.gl 호버 상태에서 MMSI 참조
|
||||||
|
if (mmsi == null && projectionRef.current !== 'globe') {
|
||||||
const hovered = hoveredDeckMmsiRef.current;
|
const hovered = hoveredDeckMmsiRef.current;
|
||||||
if (hovered.length > 0) mmsi = hovered[0];
|
if (hovered.length > 0) mmsi = hovered[0];
|
||||||
}
|
}
|
||||||
@ -673,7 +695,7 @@ export function Map3D({
|
|||||||
|
|
||||||
useFlyTo(
|
useFlyTo(
|
||||||
mapRef, projectionRef,
|
mapRef, projectionRef,
|
||||||
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
{ selectedMmsi, shipData, mapInitiatedSelectRef, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출
|
// Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출
|
||||||
|
|||||||
@ -15,18 +15,9 @@ const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
|||||||
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||||
|
|
||||||
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
||||||
|
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
||||||
|
|
||||||
export const SHIP_ICON_MAPPING = {
|
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
|
||||||
ship: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 128,
|
|
||||||
height: 128,
|
|
||||||
anchorX: 64,
|
|
||||||
anchorY: 64,
|
|
||||||
mask: true,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ── Ship constants ──
|
// ── Ship constants ──
|
||||||
|
|
||||||
@ -70,10 +61,8 @@ export const DECK_VIEW_ID = 'mapbox';
|
|||||||
|
|
||||||
// ── Depth params ──
|
// ── Depth params ──
|
||||||
|
|
||||||
export const DEPTH_DISABLED_PARAMS = {
|
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
||||||
depthCompare: 'always',
|
export { DEPTH_DISABLED_PARAMS } from '../../shared/lib/map/mapConstants';
|
||||||
depthWriteEnabled: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const GLOBE_OVERLAY_PARAMS = {
|
export const GLOBE_OVERLAY_PARAMS = {
|
||||||
depthCompare: 'less-equal',
|
depthCompare: 'less-equal',
|
||||||
|
|||||||
@ -1,47 +1,15 @@
|
|||||||
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
import { useEffect, useMemo, useRef, 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 { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { type PickingInfo } from '@deck.gl/core';
|
import { type PickingInfo } from '@deck.gl/core';
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||||
|
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
||||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
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 { toSafeNumber } from '../lib/setUtils';
|
||||||
import { getDisplayHeading, getShipColor } from '../lib/shipUtils';
|
|
||||||
import {
|
import {
|
||||||
getShipTooltipHtml,
|
getShipTooltipHtml,
|
||||||
getPairLinkTooltipHtml,
|
getPairLinkTooltipHtml,
|
||||||
@ -50,7 +18,7 @@ import {
|
|||||||
getFleetCircleTooltipHtml,
|
getFleetCircleTooltipHtml,
|
||||||
} from '../lib/tooltips';
|
} from '../lib/tooltips';
|
||||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||||
import { getCachedShipIcon } from '../lib/shipIconCache';
|
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
||||||
|
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
||||||
@ -100,7 +68,7 @@ export function useDeckLayers(
|
|||||||
onSelectMmsi: (mmsi: number | null) => void;
|
onSelectMmsi: (mmsi: number | null) => void;
|
||||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||||
projectionRef: MutableRefObject<MapProjectionId>;
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
@ -113,7 +81,7 @@ export function useDeckLayers(
|
|||||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||||
ensureMercatorOverlay, projectionRef,
|
ensureMercatorOverlay, alarmMmsiMap,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const legacyTargets = useMemo(() => {
|
const legacyTargets = useMemo(() => {
|
||||||
@ -133,6 +101,14 @@ export function useDeckLayers(
|
|||||||
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||||
}, [legacyTargets, shipHighlightSet]);
|
}, [legacyTargets, shipHighlightSet]);
|
||||||
|
|
||||||
|
const alarmTargets = useMemo(() => {
|
||||||
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
||||||
|
return shipData.filter((t) => alarmMmsiMap.has(t.mmsi));
|
||||||
|
}, [shipData, alarmMmsiMap]);
|
||||||
|
|
||||||
|
const mercatorLayersRef = useRef<unknown[]>([]);
|
||||||
|
const alarmRafRef = useRef(0);
|
||||||
|
|
||||||
// Mercator Deck layers
|
// Mercator Deck layers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@ -151,347 +127,46 @@ export function useDeckLayers(
|
|||||||
const deckTarget = ensureMercatorOverlay();
|
const deckTarget = ensureMercatorOverlay();
|
||||||
if (!deckTarget) return;
|
if (!deckTarget) return;
|
||||||
|
|
||||||
const layers: unknown[] = [];
|
const layers = buildMercatorDeckLayers({
|
||||||
const overlayParams = DEPTH_DISABLED_PARAMS;
|
shipLayerData,
|
||||||
const clearDeckHover = () => {
|
shipOverlayLayerData,
|
||||||
touchDeckHoverState(false);
|
legacyTargetsOrdered,
|
||||||
};
|
legacyOverlayTargets,
|
||||||
const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false);
|
legacyHits,
|
||||||
const shipOtherData: AisTarget[] = [];
|
pairLinks,
|
||||||
const shipTargetData: AisTarget[] = [];
|
fcDashed,
|
||||||
for (const t of shipLayerData) {
|
fleetCircles,
|
||||||
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
pairRanges,
|
||||||
else shipOtherData.push(t);
|
pairLinksInteractive,
|
||||||
}
|
pairRangesInteractive,
|
||||||
const shipOverlayOtherData: AisTarget[] = [];
|
fcLinesInteractive,
|
||||||
const shipOverlayTargetData: AisTarget[] = [];
|
fleetCirclesInteractive,
|
||||||
for (const t of shipOverlayLayerData) {
|
overlays,
|
||||||
if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t);
|
showDensity: settings.showDensity,
|
||||||
else shipOverlayOtherData.push(t);
|
showShips: settings.showShips,
|
||||||
}
|
selectedMmsi,
|
||||||
|
shipHighlightSet,
|
||||||
if (settings.showDensity) {
|
touchDeckHoverState,
|
||||||
layers.push(
|
setDeckHoverPairs,
|
||||||
new HexagonLayer<AisTarget>({
|
setDeckHoverMmsi,
|
||||||
id: 'density',
|
clearDeckHoverPairs,
|
||||||
data: shipLayerData,
|
clearMapFleetHoverState,
|
||||||
pickable: true,
|
setMapFleetHoverState,
|
||||||
extruded: true,
|
toFleetMmsiList,
|
||||||
radius: 2500,
|
hasAuxiliarySelectModifier,
|
||||||
elevationScale: 35,
|
onSelectMmsi,
|
||||||
coverage: 0.92,
|
onToggleHighlightMmsi,
|
||||||
opacity: 0.35,
|
onDeckSelectOrHighlight,
|
||||||
getPosition: (d) => [d.lon, d.lat],
|
alarmTargets,
|
||||||
}),
|
alarmMmsiMap,
|
||||||
);
|
alarmPulseRadius: 8,
|
||||||
}
|
alarmPulseHoverRadius: 12,
|
||||||
|
});
|
||||||
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 normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
||||||
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
||||||
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
||||||
|
mercatorLayersRef.current = normalizedLayers;
|
||||||
const deckProps = {
|
const deckProps = {
|
||||||
layers: normalizedLayers,
|
layers: normalizedLayers,
|
||||||
getTooltip: (info: PickingInfo) => {
|
getTooltip: (info: PickingInfo) => {
|
||||||
@ -543,12 +218,6 @@ export function useDeckLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelectMmsi(t.mmsi);
|
onSelectMmsi(t.mmsi);
|
||||||
const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
|
||||||
if (projectionRef.current === 'globe') {
|
|
||||||
map.flyTo(clickOpts);
|
|
||||||
} else {
|
|
||||||
map.easeTo(clickOpts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -579,6 +248,7 @@ export function useDeckLayers(
|
|||||||
overlays.pairLines,
|
overlays.pairLines,
|
||||||
overlays.fcLines,
|
overlays.fcLines,
|
||||||
overlays.fleetCircles,
|
overlays.fleetCircles,
|
||||||
|
overlays.shipLabels,
|
||||||
settings.showDensity,
|
settings.showDensity,
|
||||||
settings.showShips,
|
settings.showShips,
|
||||||
trackReplayDeckLayers,
|
trackReplayDeckLayers,
|
||||||
@ -592,8 +262,73 @@ export function useDeckLayers(
|
|||||||
toFleetMmsiList,
|
toFleetMmsiList,
|
||||||
touchDeckHoverState,
|
touchDeckHoverState,
|
||||||
hasAuxiliarySelectModifier,
|
hasAuxiliarySelectModifier,
|
||||||
|
alarmTargets,
|
||||||
|
alarmMmsiMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Mercator alarm pulse breathing animation (rAF)
|
||||||
|
useEffect(() => {
|
||||||
|
if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) {
|
||||||
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
|
alarmRafRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
// 프로젝션 전환 중에는 overlay에 접근하지 않음 — WebGL 자원 무효화 방지
|
||||||
|
if (projectionBusyRef.current) {
|
||||||
|
alarmRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentOverlay = overlayRef.current;
|
||||||
|
if (!currentOverlay) {
|
||||||
|
alarmRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2;
|
||||||
|
const normalR = 8 + t * 6;
|
||||||
|
const hoverR = 12 + t * 6;
|
||||||
|
|
||||||
|
const pulseLyr = new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'alarm-pulse',
|
||||||
|
data: alarmTargets,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
filled: true,
|
||||||
|
stroked: false,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: (d) => {
|
||||||
|
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
|
||||||
|
return isHover ? hoverR : normalR;
|
||||||
|
},
|
||||||
|
getFillColor: (d) => {
|
||||||
|
const kind = alarmMmsiMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
updateTriggers: { getRadius: [normalR, hoverR] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = mercatorLayersRef.current.map((l) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentOverlay.setProps({ layers: updated } as never);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
alarmRafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
alarmRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
|
alarmRafRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]);
|
||||||
|
|
||||||
// Globe Deck overlay
|
// Globe Deck overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@ -610,32 +345,28 @@ export function useDeckLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const globeLayers = buildGlobeDeckLayers({
|
||||||
const overlayParams = GLOBE_OVERLAY_PARAMS;
|
pairRanges,
|
||||||
const globeLayers: unknown[] = [];
|
pairLinks,
|
||||||
|
fcDashed,
|
||||||
if (overlays.pairRange && pairRanges.length > 0) {
|
fleetCircles,
|
||||||
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(); } }));
|
legacyTargetsOrdered,
|
||||||
}
|
legacyHits,
|
||||||
|
overlays,
|
||||||
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
showShips: settings.showShips,
|
||||||
const links = pairLinks || [];
|
selectedMmsi,
|
||||||
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(); } }));
|
isHighlightedFleet,
|
||||||
}
|
isHighlightedPair,
|
||||||
|
isHighlightedMmsi,
|
||||||
if (overlays.fcLines && fcDashed.length > 0) {
|
touchDeckHoverState,
|
||||||
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(); } }));
|
setDeckHoverPairs,
|
||||||
}
|
setDeckHoverMmsi,
|
||||||
|
clearDeckHoverPairs,
|
||||||
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
clearDeckHoverMmsi,
|
||||||
const circles = fleetCircles || [];
|
clearMapFleetHoverState,
|
||||||
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(); } }));
|
setMapFleetHoverState,
|
||||||
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 }));
|
toFleetMmsiList,
|
||||||
}
|
});
|
||||||
|
|
||||||
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 normalizedLayers = sanitizeDeckLayerList(globeLayers);
|
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
|
||||||
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
|
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useEffect, type MutableRefObject } from 'react';
|
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
import type maplibregl from 'maplibre-gl';
|
import type maplibregl from 'maplibre-gl';
|
||||||
import { onMapStyleReady } from '../lib/mapCore';
|
|
||||||
import type { MapProjectionId } from '../types';
|
import type { MapProjectionId } from '../types';
|
||||||
|
|
||||||
export function useFlyTo(
|
export function useFlyTo(
|
||||||
@ -9,53 +8,62 @@ export function useFlyTo(
|
|||||||
opts: {
|
opts: {
|
||||||
selectedMmsi: number | null;
|
selectedMmsi: number | null;
|
||||||
shipData: { mmsi: number; lon: number; lat: number }[];
|
shipData: { mmsi: number; lon: number; lat: number }[];
|
||||||
|
/** true일 때 selectedMmsi fly-to 스킵 (지도 클릭 선택 시) */
|
||||||
|
mapInitiatedSelectRef: MutableRefObject<boolean>;
|
||||||
fleetFocusId: string | number | undefined;
|
fleetFocusId: string | number | undefined;
|
||||||
fleetFocusLon: number | undefined;
|
fleetFocusLon: number | undefined;
|
||||||
fleetFocusLat: number | undefined;
|
fleetFocusLat: number | undefined;
|
||||||
fleetFocusZoom: number | undefined;
|
fleetFocusZoom: number | undefined;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
|
const { selectedMmsi, shipData, mapInitiatedSelectRef, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
|
||||||
|
|
||||||
|
// shipData를 ref로 — 의존성에서 제외하여 AIS poll마다 재실행 방지
|
||||||
|
const shipDataRef = useRef(shipData);
|
||||||
|
useEffect(() => { shipDataRef.current = shipData; }, [shipData]);
|
||||||
|
|
||||||
|
// 패널(좌측 목록)에서 선택 시 해당 선박 위치로 즉시 이동
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 지도 내부 클릭에서 발생한 선택이면 스킵
|
||||||
|
if (mapInitiatedSelectRef.current) {
|
||||||
|
mapInitiatedSelectRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map || selectedMmsi == null) return;
|
||||||
if (!selectedMmsi) return;
|
|
||||||
const t = shipData.find((x) => x.mmsi === selectedMmsi);
|
const target = shipDataRef.current.find((t) => t.mmsi === selectedMmsi);
|
||||||
if (!t) return;
|
if (!target || !Number.isFinite(target.lon) || !Number.isFinite(target.lat)) return;
|
||||||
const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
|
||||||
|
try {
|
||||||
|
const flyOpts = { center: [target.lon, target.lat] as [number, number], duration: 400 };
|
||||||
if (projectionRef.current === 'globe') {
|
if (projectionRef.current === 'globe') {
|
||||||
map.flyTo(flyOpts);
|
map.flyTo(flyOpts);
|
||||||
} else {
|
} else {
|
||||||
map.easeTo(flyOpts);
|
map.easeTo(flyOpts);
|
||||||
}
|
}
|
||||||
}, [selectedMmsi, shipData]);
|
} catch {
|
||||||
|
// ignore — style not ready 등
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedMmsi]);
|
||||||
|
|
||||||
|
// 선단 포커스 이동
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
|
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
|
||||||
return;
|
return;
|
||||||
const lon = fleetFocusLon;
|
|
||||||
const lat = fleetFocusLat;
|
|
||||||
const zoom = fleetFocusZoom ?? 10;
|
|
||||||
|
|
||||||
const apply = () => {
|
try {
|
||||||
const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
|
const flyOpts = { center: [fleetFocusLon, fleetFocusLat] as [number, number], zoom: fleetFocusZoom ?? 10, duration: 500 };
|
||||||
if (projectionRef.current === 'globe') {
|
if (projectionRef.current === 'globe') {
|
||||||
map.flyTo(flyOpts);
|
map.flyTo(flyOpts);
|
||||||
} else {
|
} else {
|
||||||
map.easeTo(flyOpts);
|
map.easeTo(flyOpts);
|
||||||
}
|
}
|
||||||
};
|
} catch {
|
||||||
|
// ignore
|
||||||
if (map.isStyleLoaded()) {
|
|
||||||
apply();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = onMapStyleReady(map, apply);
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
||||||
}
|
}
|
||||||
|
|||||||
357
apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
Normal file
357
apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
import { useCallback, useEffect, useRef, 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_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 { dashifyLine } from '../lib/dashifyLine';
|
||||||
|
|
||||||
|
// ── Overlay line width constants ──
|
||||||
|
const FC_LINE_W_NORMAL = 2.2;
|
||||||
|
const FC_LINE_W_HL = 3.2;
|
||||||
|
const FLEET_LINE_W_NORMAL = 2.0;
|
||||||
|
const FLEET_LINE_W_HL = 3.0;
|
||||||
|
|
||||||
|
// ── Breathing animation constants ──
|
||||||
|
const BREATHE_AMP = 2.0;
|
||||||
|
const BREATHE_PERIOD_MS = 1200;
|
||||||
|
|
||||||
|
/** Globe FC lines + fleet circles 오버레이 (stroke only — fill 제거) */
|
||||||
|
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;
|
||||||
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용하기 위해 사용
|
||||||
|
const paintStateRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// ── FC lines 데이터 effect ──
|
||||||
|
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
|
||||||
|
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'fc-lines-ml-src';
|
||||||
|
const layerId = 'fc-lines-ml';
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projection !== 'globe') {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
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 {
|
||||||
|
return; // 다음 poll에서 재시도
|
||||||
|
}
|
||||||
|
|
||||||
|
const needReorder = !map.getLayer(layerId);
|
||||||
|
if (needReorder) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
|
paint: {
|
||||||
|
'line-color': FC_LINE_NORMAL_ML,
|
||||||
|
'line-width': FC_LINE_W_NORMAL,
|
||||||
|
'line-opacity': 0,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return; // 다음 poll에서 재시도
|
||||||
|
}
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
paintStateRef.current();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
ensure();
|
||||||
|
return () => { stop(); };
|
||||||
|
}, [projection, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
|
// ── Fleet circles 데이터 effect (stroke only — fill 제거) ──
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'fleet-circles-ml-src';
|
||||||
|
const layerId = 'fleet-circles-ml';
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projection !== 'globe' || (fleetCircles?.length ?? 0) === 0) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const circles = fleetCircles || [];
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return; // 다음 poll에서 재시도
|
||||||
|
}
|
||||||
|
|
||||||
|
const needReorder = !map.getLayer(layerId);
|
||||||
|
if (needReorder) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
|
paint: {
|
||||||
|
'line-color': FLEET_LINE_ML,
|
||||||
|
'line-width': FLEET_LINE_W_NORMAL,
|
||||||
|
'line-opacity': 0,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return; // 다음 poll에서 재시도
|
||||||
|
}
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
paintStateRef.current();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
ensure();
|
||||||
|
return () => { stop(); };
|
||||||
|
}, [projection, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
|
// ── FC + Fleet paint state update (가시성 + 하이라이트 통합) ──
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const updateFcFleetPaintStates = useCallback(() => {
|
||||||
|
if (projection !== 'globe') return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) 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;
|
||||||
|
|
||||||
|
// ── FC lines ──
|
||||||
|
const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
const fcVisible = overlays.fcLines || pairActive;
|
||||||
|
// ── Fleet circles ──
|
||||||
|
const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
const fleetVisible = overlays.fleetCircles || fleetActive;
|
||||||
|
try {
|
||||||
|
if (map.getLayer('fc-lines-ml')) {
|
||||||
|
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);
|
||||||
|
if (fcVisible) {
|
||||||
|
map.setPaintProperty(
|
||||||
|
'fc-lines-ml', 'line-color',
|
||||||
|
fcEndpointHighlightExpr !== false
|
||||||
|
? ['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
|
||||||
|
: ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||||
|
);
|
||||||
|
map.setPaintProperty(
|
||||||
|
'fc-lines-ml', 'line-width',
|
||||||
|
fcEndpointHighlightExpr !== false
|
||||||
|
? ['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never
|
||||||
|
: FC_LINE_W_NORMAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (map.getLayer('fleet-circles-ml')) {
|
||||||
|
map.setPaintProperty('fleet-circles-ml', 'line-opacity', fleetVisible ? 0.85 : 0);
|
||||||
|
if (fleetVisible) {
|
||||||
|
map.setPaintProperty('fleet-circles-ml', 'line-color',
|
||||||
|
fleetHighlightExpr !== false
|
||||||
|
? ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never
|
||||||
|
: FLEET_LINE_ML,
|
||||||
|
);
|
||||||
|
map.setPaintProperty('fleet-circles-ml', 'line-width',
|
||||||
|
fleetHighlightExpr !== false
|
||||||
|
? ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never
|
||||||
|
: FLEET_LINE_W_NORMAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
kickRepaint(map);
|
||||||
|
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, overlays.fcLines, overlays.fleetCircles]);
|
||||||
|
|
||||||
|
// paintStateRef를 최신 콜백으로 유지
|
||||||
|
useEffect(() => {
|
||||||
|
paintStateRef.current = updateFcFleetPaintStates;
|
||||||
|
}, [updateFcFleetPaintStates]);
|
||||||
|
|
||||||
|
// paint state 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
updateFcFleetPaintStates();
|
||||||
|
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, overlays.fcLines, overlays.fleetCircles, updateFcFleetPaintStates, fcLinks, fleetCircles]);
|
||||||
|
|
||||||
|
// ── Breathing animation ──
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
const hasFcHover = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
if (!map || (!hasFleetHover && !hasFcHover) || projection !== 'globe') {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
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;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||||
|
try {
|
||||||
|
if (map.getLayer('fc-lines-ml') && fcEndpointHighlightExpr !== false) {
|
||||||
|
const hlW = FC_LINE_W_HL + t * BREATHE_AMP;
|
||||||
|
map.setPaintProperty('fc-lines-ml', 'line-width',
|
||||||
|
['case', fcEndpointHighlightExpr, hlW, FC_LINE_W_NORMAL] as never);
|
||||||
|
}
|
||||||
|
if (map.getLayer('fleet-circles-ml') && fleetHighlightExpr !== false) {
|
||||||
|
const hlW = FLEET_LINE_W_HL + t * BREATHE_AMP;
|
||||||
|
map.setPaintProperty('fleet-circles-ml', 'line-width',
|
||||||
|
['case', fleetHighlightExpr, hlW, FLEET_LINE_W_NORMAL] as never);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection]);
|
||||||
|
}
|
||||||
@ -14,6 +14,10 @@ import {
|
|||||||
} from '../lib/tooltips';
|
} from '../lib/tooltips';
|
||||||
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
|
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
|
||||||
|
|
||||||
|
// setData() 후 타일 재빌드 중 queryRenderedFeatures가 일시적으로 빈 배열을 반환.
|
||||||
|
// 즉시 clear 대신 딜레이를 두어 깜박임 방지.
|
||||||
|
const TOOLTIP_CLEAR_DELAY_MS = 150;
|
||||||
|
|
||||||
export function useGlobeInteraction(
|
export function useGlobeInteraction(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
@ -57,7 +61,7 @@ export function useGlobeInteraction(
|
|||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
|
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !map.isStyleLoaded()) return;
|
if (!map) return;
|
||||||
if (!mapTooltipRef.current) {
|
if (!mapTooltipRef.current) {
|
||||||
mapTooltipRef.current = new maplibregl.Popup({
|
mapTooltipRef.current = new maplibregl.Popup({
|
||||||
closeButton: false,
|
closeButton: false,
|
||||||
@ -74,6 +78,12 @@ export function useGlobeInteraction(
|
|||||||
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
|
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// buildGlobeFeatureTooltip을 ref로 관리 — legacyHits/shipByMmsi가 매 AIS poll마다 변경되므로
|
||||||
|
// useCallback 의존성으로 넣으면 effect가 재실행되어 cleanup에서 tooltip이 제거됨
|
||||||
|
// ref로 관리하면 effect 재실행 없이 항상 최신 함수 참조
|
||||||
|
type TooltipFeature = { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined;
|
||||||
|
const buildTooltipRef = useRef<(feature: TooltipFeature) => { html: string } | null>(() => null);
|
||||||
|
|
||||||
const buildGlobeFeatureTooltip = useCallback(
|
const buildGlobeFeatureTooltip = useCallback(
|
||||||
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
|
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
|
||||||
if (!feature) return null;
|
if (!feature) return null;
|
||||||
@ -136,17 +146,14 @@ export function useGlobeInteraction(
|
|||||||
[legacyHits, shipByMmsi],
|
[legacyHits, shipByMmsi],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
buildTooltipRef.current = buildGlobeFeatureTooltip;
|
||||||
|
}, [buildGlobeFeatureTooltip]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const clearDeckGlobeHoverState = () => {
|
|
||||||
clearDeckHoverMmsi();
|
|
||||||
clearDeckHoverPairs();
|
|
||||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
|
||||||
clearMapFleetHoverState();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetGlobeHoverStates = () => {
|
const resetGlobeHoverStates = () => {
|
||||||
clearDeckHoverMmsi();
|
clearDeckHoverMmsi();
|
||||||
clearDeckHoverPairs();
|
clearDeckHoverPairs();
|
||||||
@ -155,36 +162,52 @@ export function useGlobeInteraction(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeMmsiList = (value: unknown): number[] => {
|
const normalizeMmsiList = (value: unknown): number[] => {
|
||||||
if (!Array.isArray(value)) return [];
|
let arr = value;
|
||||||
|
// MapLibre는 GeoJSON 배열 프로퍼티를 JSON 문자열로 반환할 수 있음
|
||||||
|
if (typeof arr === 'string') {
|
||||||
|
try { arr = JSON.parse(arr); } catch { return []; }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
const out: number[] = [];
|
const out: number[] = [];
|
||||||
for (const n of value) {
|
for (const n of arr) {
|
||||||
const m = toIntMmsi(n);
|
const m = toIntMmsi(n);
|
||||||
if (m != null) out.push(m);
|
if (m != null) out.push(m);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 지연 clear 타이머 — setData() 타일 재빌드 중 일시적 빈 결과를 무시
|
||||||
|
let clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const scheduleClear = () => {
|
||||||
|
if (clearTimer) return; // 이미 예약됨
|
||||||
|
clearTimer = setTimeout(() => {
|
||||||
|
clearTimer = null;
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
}, TOOLTIP_CLEAR_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelClear = () => {
|
||||||
|
if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; }
|
||||||
|
};
|
||||||
|
|
||||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
if (projection !== 'globe') {
|
if (projection !== 'globe') {
|
||||||
|
cancelClear();
|
||||||
clearGlobeTooltip();
|
clearGlobeTooltip();
|
||||||
resetGlobeHoverStates();
|
resetGlobeHoverStates();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (projectionBusyRef.current) {
|
if (projectionBusyRef.current) {
|
||||||
resetGlobeHoverStates();
|
return; // 전환 중에는 기존 상태 유지 (clear하면 깜박임)
|
||||||
clearGlobeTooltip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!map.isStyleLoaded()) {
|
|
||||||
clearDeckGlobeHoverState();
|
|
||||||
clearGlobeTooltip();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidateLayerIds: string[] = [];
|
let candidateLayerIds: string[] = [];
|
||||||
try {
|
try {
|
||||||
candidateLayerIds = [
|
candidateLayerIds = [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
|
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||||
'pair-lines-ml', 'fc-lines-ml',
|
'pair-lines-ml', 'fc-lines-ml',
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
'pair-range-ml',
|
'pair-range-ml',
|
||||||
@ -195,14 +218,18 @@ export function useGlobeInteraction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (candidateLayerIds.length === 0) {
|
if (candidateLayerIds.length === 0) {
|
||||||
resetGlobeHoverStates();
|
scheduleClear();
|
||||||
clearGlobeTooltip();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
|
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
|
||||||
try {
|
try {
|
||||||
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
|
const tolerance = 10;
|
||||||
|
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||||
|
[e.point.x - tolerance, e.point.y - tolerance],
|
||||||
|
[e.point.x + tolerance, e.point.y + tolerance],
|
||||||
|
];
|
||||||
|
rendered = map.queryRenderedFeatures(bbox, { layers: candidateLayerIds }) as unknown as Array<{
|
||||||
properties?: Record<string, unknown> | null;
|
properties?: Record<string, unknown> | null;
|
||||||
layer?: { id?: string };
|
layer?: { id?: string };
|
||||||
}>;
|
}>;
|
||||||
@ -212,6 +239,7 @@ export function useGlobeInteraction(
|
|||||||
|
|
||||||
const priority = [
|
const priority = [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
|
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||||
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
'zones-fill', 'zones-line', 'zones-label',
|
'zones-fill', 'zones-line', 'zones-label',
|
||||||
@ -222,18 +250,23 @@ export function useGlobeInteraction(
|
|||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
if (!first) {
|
if (!first) {
|
||||||
resetGlobeHoverStates();
|
// 피처 없음 — 타일 재빌드 중 일시적 누락일 수 있으므로 지연 clear
|
||||||
clearGlobeTooltip();
|
scheduleClear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 피처 발견 — 지연 clear 취소
|
||||||
|
cancelClear();
|
||||||
|
|
||||||
const layerId = first.layer?.id;
|
const layerId = first.layer?.id;
|
||||||
const props = first.properties || {};
|
const props = first.properties || {};
|
||||||
const isShipLayer =
|
const isShipLayer =
|
||||||
layerId === 'ships-globe' ||
|
layerId === 'ships-globe' ||
|
||||||
layerId === 'ships-globe-lite' ||
|
layerId === 'ships-globe-lite' ||
|
||||||
layerId === 'ships-globe-halo' ||
|
layerId === 'ships-globe-halo' ||
|
||||||
layerId === 'ships-globe-outline';
|
layerId === 'ships-globe-outline' ||
|
||||||
|
layerId === 'ships-globe-alarm-pulse' ||
|
||||||
|
layerId === 'ships-globe-alarm-badge';
|
||||||
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||||
const isFcLayer = layerId === 'fc-lines-ml';
|
const isFcLayer = layerId === 'fc-lines-ml';
|
||||||
const isFleetLayer = layerId === 'fleet-circles-ml';
|
const isFleetLayer = layerId === 'fleet-circles-ml';
|
||||||
@ -277,7 +310,7 @@ export function useGlobeInteraction(
|
|||||||
resetGlobeHoverStates();
|
resetGlobeHoverStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltip = buildGlobeFeatureTooltip(first);
|
const tooltip = buildTooltipRef.current(first);
|
||||||
if (!tooltip) {
|
if (!tooltip) {
|
||||||
if (!isZoneLayer) {
|
if (!isZoneLayer) {
|
||||||
resetGlobeHoverStates();
|
resetGlobeHoverStates();
|
||||||
@ -295,6 +328,7 @@ export function useGlobeInteraction(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onMouseOut = () => {
|
const onMouseOut = () => {
|
||||||
|
cancelClear();
|
||||||
resetGlobeHoverStates();
|
resetGlobeHoverStates();
|
||||||
clearGlobeTooltip();
|
clearGlobeTooltip();
|
||||||
};
|
};
|
||||||
@ -303,13 +337,14 @@ export function useGlobeInteraction(
|
|||||||
map.on('mouseout', onMouseOut);
|
map.on('mouseout', onMouseOut);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelClear();
|
||||||
map.off('mousemove', onMouseMove);
|
map.off('mousemove', onMouseMove);
|
||||||
map.off('mouseout', onMouseOut);
|
map.off('mouseout', onMouseOut);
|
||||||
clearGlobeTooltip();
|
// cleanup에서 tooltip 제거하지 않음 — 의존성 변경(AIS poll 등)으로 effect가
|
||||||
|
// 재실행될 때 tooltip이 사라지는 문제 방지. tooltip은 mousemove/mouseout 이벤트가 처리.
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
projection,
|
projection,
|
||||||
buildGlobeFeatureTooltip,
|
|
||||||
clearGlobeTooltip,
|
clearGlobeTooltip,
|
||||||
clearMapFleetHoverState,
|
clearMapFleetHoverState,
|
||||||
clearDeckHoverPairs,
|
clearDeckHoverPairs,
|
||||||
@ -319,4 +354,9 @@ export function useGlobeInteraction(
|
|||||||
setMapFleetHoverState,
|
setMapFleetHoverState,
|
||||||
setGlobeTooltip,
|
setGlobeTooltip,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 컴포넌트 unmount 시에만 tooltip 제거
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { clearGlobeTooltip(); };
|
||||||
|
}, [clearGlobeTooltip]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,10 @@
|
|||||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
import type maplibregl from 'maplibre-gl';
|
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 { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types';
|
import type { MapProjectionId } from '../types';
|
||||||
import {
|
import { useGlobePairOverlay } from './useGlobePairOverlay';
|
||||||
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
|
import { useGlobeFcFleetOverlay } from './useGlobeFcFleetOverlay';
|
||||||
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';
|
|
||||||
|
|
||||||
export function useGlobeOverlays(
|
export function useGlobeOverlays(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
@ -47,554 +22,24 @@ export function useGlobeOverlays(
|
|||||||
hoveredPairMmsiList: number[];
|
hoveredPairMmsiList: number[];
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
// Pair lines + pair range
|
||||||
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
useGlobePairOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
overlays: opts.overlays,
|
||||||
} = opts;
|
pairLinks: opts.pairLinks,
|
||||||
|
projection: opts.projection,
|
||||||
// Pair lines
|
mapSyncEpoch: opts.mapSyncEpoch,
|
||||||
useEffect(() => {
|
hoveredPairMmsiList: opts.hoveredPairMmsiList,
|
||||||
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> = {
|
// FC lines + fleet circles
|
||||||
type: 'FeatureCollection',
|
useGlobeFcFleetOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||||
features: ranges.map((c) => {
|
overlays: opts.overlays,
|
||||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
fcLinks: opts.fcLinks,
|
||||||
return {
|
fleetCircles: opts.fleetCircles,
|
||||||
type: 'Feature',
|
projection: opts.projection,
|
||||||
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
|
mapSyncEpoch: opts.mapSyncEpoch,
|
||||||
geometry: { type: 'LineString', coordinates: ring },
|
hoveredFleetMmsiList: opts.hoveredFleetMmsiList,
|
||||||
properties: {
|
hoveredFleetOwnerKeyList: opts.hoveredFleetOwnerKeyList,
|
||||||
type: 'pair-range',
|
hoveredPairMmsiList: opts.hoveredPairMmsiList,
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
343
apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts
Normal file
343
apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { useCallback, useEffect, useRef, 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';
|
||||||
|
|
||||||
|
// ── Overlay line width constants ──
|
||||||
|
const PAIR_LINE_W_NORMAL = 2.5;
|
||||||
|
const PAIR_LINE_W_WARN = 3.5;
|
||||||
|
const PAIR_LINE_W_HL = 4.5;
|
||||||
|
const PAIR_RANGE_W_NORMAL = 1.8;
|
||||||
|
const PAIR_RANGE_W_HL = 2.8;
|
||||||
|
|
||||||
|
// ── Breathing animation constants ──
|
||||||
|
const BREATHE_AMP = 2.0;
|
||||||
|
const BREATHE_PERIOD_MS = 1200;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용
|
||||||
|
const paintStateRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// ── Pair lines 데이터 effect ──
|
||||||
|
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
|
||||||
|
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'pair-lines-ml-src';
|
||||||
|
const layerId = 'pair-lines-ml';
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projection !== 'globe') {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((pairLinks?.length ?? 0) === 0) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
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 {
|
||||||
|
return; // 다음 poll에서 재시도
|
||||||
|
}
|
||||||
|
|
||||||
|
const needReorder = !map.getLayer(layerId);
|
||||||
|
if (needReorder) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
|
paint: {
|
||||||
|
'line-color': PAIR_LINE_NORMAL_ML,
|
||||||
|
'line-width': PAIR_LINE_W_NORMAL,
|
||||||
|
'line-opacity': 0,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return; // 다음 poll에서 재시도
|
||||||
|
}
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 즉시 올바른 paint state 적용 — 타이밍 간극으로 opacity:0 고착 방지
|
||||||
|
paintStateRef.current();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
ensure();
|
||||||
|
return () => { stop(); };
|
||||||
|
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
|
// ── Pair range 데이터 effect ──
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'pair-range-ml-src';
|
||||||
|
const layerId = 'pair-range-ml';
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projection !== 'globe') {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return; // 다음 poll에서 재시도
|
||||||
|
}
|
||||||
|
|
||||||
|
const needReorder = !map.getLayer(layerId);
|
||||||
|
if (needReorder) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
layout: {
|
||||||
|
'line-cap': 'round',
|
||||||
|
'line-join': 'round',
|
||||||
|
visibility: 'visible',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': PAIR_RANGE_NORMAL_ML,
|
||||||
|
'line-width': PAIR_RANGE_W_NORMAL,
|
||||||
|
'line-opacity': 0,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return; // 다음 poll에서 재시도
|
||||||
|
}
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
paintStateRef.current();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
ensure();
|
||||||
|
return () => { stop(); };
|
||||||
|
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
|
// ── Pair paint state update (가시성 + 하이라이트 통합) ──
|
||||||
|
// setLayoutProperty(visibility) 대신 setPaintProperty(line-opacity)로 가시성 제어
|
||||||
|
// → style._changed 미트리거 → alarm badge symbol placement 재계산 방지
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const updatePairPaintStates = useCallback(() => {
|
||||||
|
if (projection !== 'globe') return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const active = hoveredPairMmsiList.length >= 2;
|
||||||
|
const pairHighlightExpr = active
|
||||||
|
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// ── Pair lines: 가시성 + 하이라이트 ──
|
||||||
|
const pairLinesVisible = overlays.pairLines || active;
|
||||||
|
try {
|
||||||
|
if (map.getLayer('pair-lines-ml')) {
|
||||||
|
map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0);
|
||||||
|
if (pairLinesVisible) {
|
||||||
|
map.setPaintProperty(
|
||||||
|
'pair-lines-ml', 'line-color',
|
||||||
|
pairHighlightExpr !== false
|
||||||
|
? ['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
|
||||||
|
: ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
||||||
|
);
|
||||||
|
map.setPaintProperty(
|
||||||
|
'pair-lines-ml', 'line-width',
|
||||||
|
pairHighlightExpr !== false
|
||||||
|
? ['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never
|
||||||
|
: ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pair range: 가시성 + 하이라이트 ──
|
||||||
|
const pairRangeVisible = overlays.pairRange || active;
|
||||||
|
try {
|
||||||
|
if (map.getLayer('pair-range-ml')) {
|
||||||
|
map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0);
|
||||||
|
if (pairRangeVisible) {
|
||||||
|
map.setPaintProperty(
|
||||||
|
'pair-range-ml', 'line-color',
|
||||||
|
pairHighlightExpr !== false
|
||||||
|
? ['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
|
||||||
|
: ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
||||||
|
);
|
||||||
|
map.setPaintProperty(
|
||||||
|
'pair-range-ml', 'line-width',
|
||||||
|
pairHighlightExpr !== false
|
||||||
|
? ['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never
|
||||||
|
: PAIR_RANGE_W_NORMAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
kickRepaint(map);
|
||||||
|
}, [projection, hoveredPairMmsiList, overlays.pairLines, overlays.pairRange]);
|
||||||
|
|
||||||
|
// paintStateRef를 최신 콜백으로 유지 — useEffect 내에서만 ref 업데이트 (react-hooks/refs 준수)
|
||||||
|
useEffect(() => {
|
||||||
|
paintStateRef.current = updatePairPaintStates;
|
||||||
|
}, [updatePairPaintStates]);
|
||||||
|
|
||||||
|
// paint state 동기화: 호버/토글/epoch 변경 시 즉시 반영
|
||||||
|
useEffect(() => {
|
||||||
|
updatePairPaintStates();
|
||||||
|
}, [mapSyncEpoch, hoveredPairMmsiList, projection, overlays.pairLines, overlays.pairRange, updatePairPaintStates, pairLinks]);
|
||||||
|
|
||||||
|
// ── Breathing animation for highlighted pair overlays ──
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairHighlightExpr = makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||||
|
try {
|
||||||
|
if (map.getLayer('pair-lines-ml')) {
|
||||||
|
const hlW = PAIR_LINE_W_HL + t * BREATHE_AMP;
|
||||||
|
map.setPaintProperty('pair-lines-ml', 'line-width',
|
||||||
|
['case', pairHighlightExpr, hlW, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never);
|
||||||
|
}
|
||||||
|
if (map.getLayer('pair-range-ml')) {
|
||||||
|
const hlW = PAIR_RANGE_W_HL + t * BREATHE_AMP;
|
||||||
|
map.setPaintProperty('pair-range-ml', 'line-width',
|
||||||
|
['case', pairHighlightExpr, hlW, PAIR_RANGE_W_NORMAL] as never);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [hoveredPairMmsiList, projection]);
|
||||||
|
}
|
||||||
373
apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts
Normal file
373
apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
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_PROP_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 (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
||||||
|
hideHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (epochRef.current !== mapSyncEpoch) {
|
||||||
|
epochRef.current = mapSyncEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureFallbackShipImage(map, imgId);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
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_PROP_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 {
|
||||||
|
guardedSetVisibility(map, haloId, 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(outlineId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: outlineId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_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 {
|
||||||
|
guardedSetVisibility(map, outlineId, '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 {
|
||||||
|
guardedSetVisibility(map, symbolId, '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.12, 2);
|
||||||
|
|
||||||
|
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
try {
|
||||||
|
const layerIds = [symbolId, symbolLiteId, haloId, outlineId, 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge'].filter((id) => map.getLayer(id));
|
||||||
|
let feats: unknown[] = [];
|
||||||
|
if (layerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const tolerance = 10;
|
||||||
|
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||||
|
[e.point.x - tolerance, e.point.y - tolerance],
|
||||||
|
[e.point.x + tolerance, e.point.y + tolerance],
|
||||||
|
];
|
||||||
|
feats = map.queryRenderedFeatures(bbox, { 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]);
|
||||||
|
}
|
||||||
64
apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts
Normal file
64
apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
|
import { onMapStyleReady } from '../lib/mapCore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mercator 모드 선명 라벨 — 레거시 MapLibre 레이어 정리 전용.
|
||||||
|
* 실제 라벨 렌더링은 Deck.gl TextLayer (deckLayerFactories.ts)에서 처리.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { 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 = () => {
|
||||||
|
// Mercator ship labels are now rendered via Deck.gl TextLayer
|
||||||
|
// (see buildMercatorDeckLayers in deckLayerFactories.ts).
|
||||||
|
// Always clean up any stale MapLibre label layer.
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [mapSyncEpoch]);
|
||||||
|
}
|
||||||
693
apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts
Normal file
693
apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts
Normal file
@ -0,0 +1,693 @@
|
|||||||
|
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 { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/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';
|
||||||
|
|
||||||
|
// ── Alarm pulse animation constants ──
|
||||||
|
const ALARM_PULSE_R_MIN = 8;
|
||||||
|
const ALARM_PULSE_R_MAX = 14;
|
||||||
|
const ALARM_PULSE_R_HOVER_MIN = 12;
|
||||||
|
const ALARM_PULSE_R_HOVER_MAX = 18;
|
||||||
|
const ALARM_PULSE_PERIOD_MS = 1500;
|
||||||
|
|
||||||
|
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
|
||||||
|
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;
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
projection, settings, shipData, overlays, legacyHits,
|
||||||
|
selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
|
||||||
|
alarmMmsiMap,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const epochRef = useRef(-1);
|
||||||
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
const prevGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
||||||
|
const prevAlarmGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
||||||
|
|
||||||
|
// 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 alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null;
|
||||||
|
const baseName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
||||||
|
const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
||||||
|
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);
|
||||||
|
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
|
||||||
|
// hover overlay 레이어가 확대 + z-priority를 담당
|
||||||
|
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
||||||
|
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
||||||
|
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
||||||
|
const iconSize14 = clampNumber(0.85 * sizeScale, 0.45, 2.6);
|
||||||
|
const iconSize18 = clampNumber(2.5 * sizeScale, 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,
|
||||||
|
iconSize7,
|
||||||
|
iconSize10,
|
||||||
|
iconSize14,
|
||||||
|
iconSize18,
|
||||||
|
sizeScale,
|
||||||
|
permitted: legacy ? 1 : 0,
|
||||||
|
code: legacy?.shipCode || '',
|
||||||
|
alarmed: alarmKind ? 1 : 0,
|
||||||
|
alarmKind: alarmKind ?? '',
|
||||||
|
alarmBadgeLabel: alarmKind ? ALARM_BADGE[alarmKind].label : '',
|
||||||
|
alarmBadgeColor: alarmKind ? ALARM_BADGE[alarmKind].color : '#000',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}, [shipData, legacyHits, alarmMmsiMap]);
|
||||||
|
|
||||||
|
// Alarm-only GeoJSON — separate source to avoid badge symbol re-placement
|
||||||
|
// when the main ship source updates (position polling)
|
||||||
|
const alarmGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
|
||||||
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) {
|
||||||
|
return { type: 'FeatureCollection', features: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: shipData
|
||||||
|
.filter((t) => alarmMmsiMap.has(t.mmsi))
|
||||||
|
.map((t) => {
|
||||||
|
const alarmKind = alarmMmsiMap.get(t.mmsi)!;
|
||||||
|
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,
|
||||||
|
alarmed: 1,
|
||||||
|
alarmBadgeLabel: ALARM_BADGE[alarmKind].label,
|
||||||
|
alarmBadgeColor: ALARM_BADGE[alarmKind].color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}, [shipData, alarmMmsiMap]);
|
||||||
|
|
||||||
|
// 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 alarmSrcId = 'ships-globe-alarm-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';
|
||||||
|
const pulseId = 'ships-globe-alarm-pulse';
|
||||||
|
const badgeId = 'ships-globe-alarm-badge';
|
||||||
|
|
||||||
|
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||||
|
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||||
|
const hide = () => {
|
||||||
|
for (const id of [badgeId, labelId, symbolId, symbolLiteId, pulseId, 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, pulseId, symbolLiteId, symbolId, badgeId]) {
|
||||||
|
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과 무관하게 항상 준비됨)
|
||||||
|
// 참조 동일성 기반 setData 스킵 — 위치 변경 없는 epoch/설정 변경 시 재전송 방지
|
||||||
|
const geojson = globeShipGeoJson;
|
||||||
|
const geoJsonChanged = geojson !== prevGeoJsonRef.current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) {
|
||||||
|
if (geoJsonChanged) existing.setData(geojson);
|
||||||
|
} else {
|
||||||
|
map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
||||||
|
}
|
||||||
|
prevGeoJsonRef.current = geojson;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alarm source — isolated from main source for stable badge rendering
|
||||||
|
try {
|
||||||
|
const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined;
|
||||||
|
const alarmChanged = alarmGeoJson !== prevAlarmGeoJsonRef.current;
|
||||||
|
if (existingAlarm) {
|
||||||
|
if (alarmChanged) existingAlarm.setData(alarmGeoJson);
|
||||||
|
} else {
|
||||||
|
map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
|
||||||
|
}
|
||||||
|
prevAlarmGeoJsonRef.current = alarmGeoJson;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Alarm source setup failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = undefined;
|
||||||
|
let needReorder = false;
|
||||||
|
const priorityFilter = [
|
||||||
|
'any',
|
||||||
|
['==', ['to-number', ['get', 'permitted'], 0], 1],
|
||||||
|
['==', ['to-number', ['get', 'alarmed'], 0], 1],
|
||||||
|
] as unknown as unknown[];
|
||||||
|
const nonPriorityFilter = [
|
||||||
|
'all',
|
||||||
|
['==', ['to-number', ['get', 'permitted'], 0], 0],
|
||||||
|
['==', ['to-number', ['get', 'alarmed'], 0], 0],
|
||||||
|
] as unknown as unknown[];
|
||||||
|
|
||||||
|
if (!map.getLayer(haloId)) {
|
||||||
|
needReorder = true;
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: haloId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'circle-sort-key': [
|
||||||
|
'case',
|
||||||
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
|
||||||
|
['==', ['get', 'permitted'], 1], 110,
|
||||||
|
['==', ['get', 'alarmed'], 1], 22,
|
||||||
|
20,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||||
|
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
|
'circle-opacity': [
|
||||||
|
'case',
|
||||||
|
['==', ['feature-state', 'selected'], 1], 0.38,
|
||||||
|
['==', ['feature-state', '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)) {
|
||||||
|
needReorder = true;
|
||||||
|
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',
|
||||||
|
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||||
|
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||||
|
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
||||||
|
GLOBE_OUTLINE_OTHER,
|
||||||
|
] as never,
|
||||||
|
'circle-stroke-width': [
|
||||||
|
'case',
|
||||||
|
['==', ['feature-state', 'selected'], 1], 3.4,
|
||||||
|
['==', ['feature-state', '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', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
|
||||||
|
['==', ['get', 'permitted'], 1], 120,
|
||||||
|
['==', ['get', 'alarmed'], 1], 32,
|
||||||
|
30,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship outline layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alarm pulse circle (above outline, below ship icons)
|
||||||
|
// Uses separate alarm source for stable rendering
|
||||||
|
if (!map.getLayer(pulseId)) {
|
||||||
|
needReorder = true;
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: pulseId,
|
||||||
|
type: 'circle',
|
||||||
|
source: alarmSrcId,
|
||||||
|
filter: ['==', ['get', 'alarmed'], 1] as never,
|
||||||
|
layout: { visibility },
|
||||||
|
paint: {
|
||||||
|
'circle-radius': ALARM_PULSE_R_MIN,
|
||||||
|
'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
||||||
|
'circle-opacity': 0.35,
|
||||||
|
'circle-stroke-width': 0,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship alarm pulse layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(symbolLiteId)) {
|
||||||
|
needReorder = true;
|
||||||
|
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)) {
|
||||||
|
needReorder = true;
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: symbolId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: srcId,
|
||||||
|
filter: priorityFilter as never,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'symbol-sort-key': [
|
||||||
|
'case',
|
||||||
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
|
||||||
|
['==', ['get', 'permitted'], 1], 130,
|
||||||
|
['==', ['get', 'alarmed'], 1], 47,
|
||||||
|
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',
|
||||||
|
['==', ['feature-state', 'selected'], 1], 1,
|
||||||
|
['==', ['feature-state', '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'], '']], ''],
|
||||||
|
['==', ['get', 'permitted'], 1],
|
||||||
|
] as unknown as unknown[];
|
||||||
|
|
||||||
|
if (!map.getLayer(labelId)) {
|
||||||
|
needReorder = true;
|
||||||
|
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',
|
||||||
|
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||||
|
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||||
|
'rgba(226,232,240,0.92)',
|
||||||
|
] as never,
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||||
|
'text-halo-width': 1.2,
|
||||||
|
'text-halo-blur': 0.8,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship label layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alarm badge symbol (above labels)
|
||||||
|
// Uses separate alarm source for stable rendering
|
||||||
|
if (!map.getLayer(badgeId)) {
|
||||||
|
needReorder = true;
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: badgeId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: alarmSrcId,
|
||||||
|
filter: ['==', ['get', 'alarmed'], 1] as never,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'text-field': ['get', 'alarmBadgeLabel'] as never,
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-size': 11,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
'text-ignore-placement': true,
|
||||||
|
'text-anchor': 'center',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#ffffff',
|
||||||
|
'text-halo-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
||||||
|
'text-halo-width': 6,
|
||||||
|
'text-translate': [12, -12],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship alarm badge layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||||
|
onGlobeShipsReady?.(true);
|
||||||
|
// needReorder: 새 레이어가 생성된 경우에만 reorder 호출
|
||||||
|
// 매 AIS poll마다 28개 moveLayer → style._changed 방지
|
||||||
|
if (projection === 'globe' && needReorder) {
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
}
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
settings.showShips,
|
||||||
|
overlays.shipLabels,
|
||||||
|
globeShipGeoJson,
|
||||||
|
alarmGeoJson,
|
||||||
|
mapSyncEpoch,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
|
onGlobeShipsReady,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Feature-state로 상호작용 상태(selected/highlighted) 즉시 반영 — setData 없이
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded() || !map.getSource('ships-globe-src')) return;
|
||||||
|
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
const src = 'ships-globe-src';
|
||||||
|
const alarmSrc = 'ships-globe-alarm-src';
|
||||||
|
for (const t of shipData) {
|
||||||
|
if (!isFiniteNumber(t.mmsi)) continue;
|
||||||
|
const id = Math.trunc(t.mmsi);
|
||||||
|
const s = t.mmsi === selectedMmsi ? 1 : 0;
|
||||||
|
const h = isBaseHighlightedMmsi(t.mmsi) ? 1 : 0;
|
||||||
|
try {
|
||||||
|
map.setFeatureState({ source: src, id }, { selected: s, highlighted: h });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (map.getSource(alarmSrc) && alarmMmsiMap) {
|
||||||
|
for (const t of shipData) {
|
||||||
|
if (!alarmMmsiMap.has(t.mmsi)) continue;
|
||||||
|
const id = Math.trunc(t.mmsi);
|
||||||
|
try {
|
||||||
|
map.setFeatureState(
|
||||||
|
{ source: alarmSrc, id },
|
||||||
|
{ selected: t.mmsi === selectedMmsi ? 1 : 0, highlighted: isBaseHighlightedMmsi(t.mmsi) ? 1 : 0 },
|
||||||
|
);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kickRepaint(map);
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [projection, selectedMmsi, isBaseHighlightedMmsi, shipData, alarmMmsiMap]);
|
||||||
|
|
||||||
|
// Alarm pulse breathing animation (rAF)
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || projection !== 'globe' || !alarmMmsiMap || alarmMmsiMap.size === 0) {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||||
|
const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN);
|
||||||
|
const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN);
|
||||||
|
try {
|
||||||
|
if (map.getLayer('ships-globe-alarm-pulse')) {
|
||||||
|
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
|
||||||
|
'case',
|
||||||
|
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
|
||||||
|
hoverR,
|
||||||
|
normalR,
|
||||||
|
] as never);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [projection, alarmMmsiMap]);
|
||||||
|
}
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -108,7 +108,9 @@ export function useNativeMapLayers(
|
|||||||
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
||||||
const hasData = cfg.sources.some((s) => s.data != null);
|
const hasData = cfg.sources.some((s) => s.data != null);
|
||||||
if (!hasData) return;
|
if (!hasData) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
// isStyleLoaded() 가드 제거 — AIS poll의 setData()로 인해
|
||||||
|
// 일시적으로 false가 되어 데이터 업데이트가 스킵되는 문제 방지.
|
||||||
|
// 실패 시 try/catch가 처리하고, 다음 deps 변경 시 자연 재시도.
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 3. Source 생성/업데이트
|
// 3. Source 생성/업데이트
|
||||||
|
|||||||
@ -80,12 +80,17 @@ export function useProjectionToggle(
|
|||||||
};
|
};
|
||||||
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
||||||
|
|
||||||
|
const reorderRafRef = useRef(0);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
const reorderGlobeFeatureLayers = useCallback(() => {
|
const reorderGlobeFeatureLayers = useCallback(() => {
|
||||||
const map = mapRef.current;
|
if (!mapRef.current || projectionRef.current !== 'globe') return;
|
||||||
if (!map || projectionRef.current !== 'globe') return;
|
|
||||||
if (projectionBusyRef.current) return;
|
if (projectionBusyRef.current) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
if (reorderRafRef.current) return; // 이미 스케줄됨 — 프레임당 1회 실행
|
||||||
|
reorderRafRef.current = requestAnimationFrame(() => {
|
||||||
|
reorderRafRef.current = 0;
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m || !m.isStyleLoaded()) return;
|
||||||
|
|
||||||
const ordering = [
|
const ordering = [
|
||||||
'subcables-hitarea',
|
'subcables-hitarea',
|
||||||
@ -106,34 +111,36 @@ export function useProjectionToggle(
|
|||||||
'zones-fill',
|
'zones-fill',
|
||||||
'zones-line',
|
'zones-line',
|
||||||
'zones-label',
|
'zones-label',
|
||||||
|
'fleet-circles-ml',
|
||||||
|
'fc-lines-ml',
|
||||||
|
'pair-range-ml',
|
||||||
|
'pair-lines-ml',
|
||||||
'predict-vectors-outline',
|
'predict-vectors-outline',
|
||||||
'predict-vectors',
|
'predict-vectors',
|
||||||
'predict-vectors-hl-outline',
|
'predict-vectors-hl-outline',
|
||||||
'predict-vectors-hl',
|
'predict-vectors-hl',
|
||||||
'ships-globe-halo',
|
'ships-globe-halo',
|
||||||
'ships-globe-outline',
|
'ships-globe-outline',
|
||||||
|
'ships-globe-alarm-pulse',
|
||||||
'ships-globe-lite',
|
'ships-globe-lite',
|
||||||
'ships-globe',
|
'ships-globe',
|
||||||
'ships-globe-label',
|
'ships-globe-label',
|
||||||
|
'ships-globe-alarm-badge',
|
||||||
'ships-globe-hover-halo',
|
'ships-globe-hover-halo',
|
||||||
'ships-globe-hover-outline',
|
'ships-globe-hover-outline',
|
||||||
'ships-globe-hover',
|
'ships-globe-hover',
|
||||||
'pair-lines-ml',
|
|
||||||
'fc-lines-ml',
|
|
||||||
'pair-range-ml',
|
|
||||||
'fleet-circles-ml-fill',
|
|
||||||
'fleet-circles-ml',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const layerId of ordering) {
|
for (const layerId of ordering) {
|
||||||
try {
|
try {
|
||||||
if (map.getLayer(layerId)) map.moveLayer(layerId);
|
if (m.getLayer(layerId)) m.moveLayer(layerId);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kickRepaint(map);
|
kickRepaint(m);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Projection toggle (mercator <-> globe)
|
// Projection toggle (mercator <-> globe)
|
||||||
@ -215,6 +222,13 @@ export function useProjectionToggle(
|
|||||||
quietMercatorOverlays();
|
quietMercatorOverlays();
|
||||||
} else {
|
} else {
|
||||||
quietGlobeDeckLayer();
|
quietGlobeDeckLayer();
|
||||||
|
quietMercatorOverlays();
|
||||||
|
// Globe custom layer를 맵에서 분리 — setProjection() 중 render 콜백에서
|
||||||
|
// stale WebGL 자원(uniform buffer 등) 참조를 방지
|
||||||
|
const gl = globeDeckLayerRef.current;
|
||||||
|
if (gl?.id) {
|
||||||
|
try { if (map.getLayer(gl.id)) map.removeLayer(gl.id); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -13,10 +13,11 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
|||||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
|
|
||||||
/** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임.
|
/** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임.
|
||||||
* 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 70,000+로 폭증하므로
|
* 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 ~33x로 폭증하므로
|
||||||
* ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음. */
|
* ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음.
|
||||||
|
* 4수역 × 300pts × 33x ≈ 39,600 vertices (< 65535 limit). */
|
||||||
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
|
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
|
||||||
const MAX_PTS = 60;
|
const MAX_PTS = 300;
|
||||||
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
|
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
|
||||||
if (ring.length <= MAX_PTS) return ring;
|
if (ring.length <= MAX_PTS) return ring;
|
||||||
const step = Math.ceil(ring.length / MAX_PTS);
|
const step = Math.ceil(ring.length / MAX_PTS);
|
||||||
|
|||||||
588
apps/web/src/widgets/map3d/lib/deckLayerFactories.ts
Normal file
588
apps/web/src/widgets/map3d/lib/deckLayerFactories.ts
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
||||||
|
import { IconLayer, LineLayer, ScatterplotLayer, TextLayer } 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 { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/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>;
|
||||||
|
alarmTargets?: AisTarget[];
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
|
alarmPulseRadius?: number;
|
||||||
|
alarmPulseHoverRadius?: 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) {
|
||||||
|
const validRanges = ctx.pairRanges.filter((d) => d.center && isFinite(d.center[0]) && isFinite(d.center[1]) && isFinite(d.radiusNm));
|
||||||
|
if (validRanges.length > 0) layers.push(
|
||||||
|
new ScatterplotLayer<PairRangeCircle>({
|
||||||
|
id: 'pair-range',
|
||||||
|
data: validRanges,
|
||||||
|
pickable: true,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
radiusMinPixels: 10,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: () => 1.8,
|
||||||
|
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 ? 3.5 : 2.5),
|
||||||
|
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: () => 2.2,
|
||||||
|
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: () => 2.0,
|
||||||
|
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.8, 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: () => 4.5, 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: () => 3.2, 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: () => 3.0, 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); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─ ship name labels (Mercator) ─ */
|
||||||
|
if (ctx.showShips && ctx.overlays.shipLabels) {
|
||||||
|
const labelData: AisTarget[] = [];
|
||||||
|
for (const t of ctx.shipLayerData) {
|
||||||
|
const isTarget = ctx.legacyHits?.has(t.mmsi) ?? false;
|
||||||
|
const isSelected = ctx.selectedMmsi != null && t.mmsi === ctx.selectedMmsi;
|
||||||
|
const isHighlighted = ctx.shipHighlightSet.has(t.mmsi);
|
||||||
|
if (isTarget || isSelected || isHighlighted) labelData.push(t);
|
||||||
|
}
|
||||||
|
if (labelData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<AisTarget>({
|
||||||
|
id: 'ship-labels',
|
||||||
|
data: labelData,
|
||||||
|
pickable: false,
|
||||||
|
billboard: true,
|
||||||
|
getText: (d) => {
|
||||||
|
const legacy = ctx.legacyHits?.get(d.mmsi);
|
||||||
|
const baseName = (legacy?.shipNameCn || legacy?.shipNameRoman || d.name || '').trim();
|
||||||
|
if (!baseName) return '';
|
||||||
|
const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null;
|
||||||
|
return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
getColor: (d) => {
|
||||||
|
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242];
|
||||||
|
if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 242];
|
||||||
|
return [226, 232, 240, 234];
|
||||||
|
},
|
||||||
|
getSize: 11,
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Noto Sans KR, sans-serif',
|
||||||
|
characterSet: 'auto',
|
||||||
|
getPixelOffset: [0, 16],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [2, 6, 23, 217],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─ alarm pulse + badge ─ */
|
||||||
|
const alarmTargets = ctx.alarmTargets ?? [];
|
||||||
|
const alarmMap = ctx.alarmMmsiMap;
|
||||||
|
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
|
||||||
|
const pulseR = ctx.alarmPulseRadius ?? 8;
|
||||||
|
const pulseHR = ctx.alarmPulseHoverRadius ?? 12;
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'alarm-pulse',
|
||||||
|
data: alarmTargets,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: true,
|
||||||
|
stroked: false,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: (d) => {
|
||||||
|
const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi);
|
||||||
|
return isHover ? pulseHR : pulseR;
|
||||||
|
},
|
||||||
|
getFillColor: (d) => {
|
||||||
|
const kind = alarmMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
updateTriggers: { getRadius: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<AisTarget>({
|
||||||
|
id: 'alarm-badge',
|
||||||
|
data: alarmTargets,
|
||||||
|
pickable: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getText: (d) => {
|
||||||
|
const kind = alarmMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].label : '';
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
getColor: [255, 255, 255, 255],
|
||||||
|
getBackgroundColor: (d) => {
|
||||||
|
const kind = alarmMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
background: true,
|
||||||
|
backgroundPadding: [3, 1],
|
||||||
|
getPixelOffset: [14, -14],
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
getSize: 10,
|
||||||
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Noto Sans KR, sans-serif',
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -49,7 +49,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
|||||||
try {
|
try {
|
||||||
map.off('style.load', runOnce);
|
map.off('style.load', runOnce);
|
||||||
map.off('styledata', runOnce);
|
map.off('styledata', runOnce);
|
||||||
map.off('idle', runOnce);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -57,7 +56,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
|||||||
|
|
||||||
map.on('style.load', runOnce);
|
map.on('style.load', runOnce);
|
||||||
map.on('styledata', runOnce);
|
map.on('styledata', runOnce);
|
||||||
map.on('idle', runOnce);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (fired) return;
|
if (fired) return;
|
||||||
@ -66,7 +64,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
map.off('style.load', runOnce);
|
map.off('style.load', runOnce);
|
||||||
map.off('styledata', runOnce);
|
map.off('styledata', runOnce);
|
||||||
map.off('idle', runOnce);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -83,12 +80,8 @@ export function extractProjectionType(map: maplibregl.Map): MapProjectionId | un
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMapTilerKey(): string | null {
|
// Canonical source: shared/lib/map/mapTilerKey.ts (re-exported for local usage)
|
||||||
const k = import.meta.env.VITE_MAPTILER_KEY;
|
export { getMapTilerKey } from '../../../shared/lib/map/mapTilerKey';
|
||||||
if (typeof k !== 'string') return null;
|
|
||||||
const v = k.trim();
|
|
||||||
return v ? v : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLayerId(value: unknown): string | null {
|
export function getLayerId(value: unknown): string | null {
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
|||||||
@ -41,28 +41,46 @@ export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
|
|||||||
return ['any', ...clauses] as unknown[];
|
return ['any', ...clauses] as unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeGlobeCircleRadiusExpr() {
|
// ── Globe circle radius zoom stops ──
|
||||||
const base3 = 4;
|
// MapLibre 제약: expression 당 zoom-based interpolate는 1개만 허용
|
||||||
const base7 = 6;
|
// → 하나의 interpolate 안에서 각 stop 값을 case로 분기
|
||||||
const base10 = 8;
|
const ZOOM_LEVELS = [3, 7, 10, 14, 18] as const;
|
||||||
const base14 = 12;
|
const BASE_VALUES = [4, 6, 8, 12, 32] as const;
|
||||||
const base18 = 32;
|
const SELECTED_VALUES = [4.6, 6.8, 9.0, 13.5, 36] as const;
|
||||||
|
const HIGHLIGHTED_VALUES = [4.2, 6.2, 8.2, 12.6, 34] as const;
|
||||||
|
|
||||||
return [
|
function buildStopsWithCase(getter: (key: string) => unknown[]) {
|
||||||
'interpolate',
|
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
|
||||||
['linear'],
|
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
|
||||||
['zoom'],
|
stops.push(ZOOM_LEVELS[i]);
|
||||||
3,
|
stops.push([
|
||||||
['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3],
|
'case',
|
||||||
7,
|
['==', getter('selected'), 1], SELECTED_VALUES[i],
|
||||||
['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7],
|
['==', getter('highlighted'), 1], HIGHLIGHTED_VALUES[i],
|
||||||
10,
|
BASE_VALUES[i],
|
||||||
['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10],
|
]);
|
||||||
14,
|
}
|
||||||
['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14],
|
return stops;
|
||||||
18,
|
}
|
||||||
['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18],
|
|
||||||
];
|
/** feature-state 기반 — 메인 선박 레이어 (halo, outline) */
|
||||||
|
export function makeGlobeCircleRadiusExpr() {
|
||||||
|
return buildStopsWithCase((key) => ['feature-state', key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GeoJSON property 기반 — hover overlay 레이어 */
|
||||||
|
export function makeGlobeCircleRadiusPropExpr() {
|
||||||
|
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
|
||||||
|
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
|
||||||
|
stops.push(ZOOM_LEVELS[i]);
|
||||||
|
stops.push([
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], SELECTED_VALUES[i],
|
||||||
|
HIGHLIGHTED_VALUES[i],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return stops;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
|
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
|
||||||
|
export const GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR = makeGlobeCircleRadiusPropExpr() as never;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { SubcableGeoJson } from '../../entities/subcable/model/types';
|
|||||||
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
|
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
|
||||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||||
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
|
||||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||||
|
|
||||||
export type Map3DSettings = {
|
export type Map3DSettings = {
|
||||||
@ -69,6 +69,8 @@ export interface Map3DProps {
|
|||||||
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
||||||
onCloseTrackMenu?: () => void;
|
onCloseTrackMenu?: () => void;
|
||||||
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
|
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
|
||||||
|
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashSeg = {
|
export type DashSeg = {
|
||||||
|
|||||||
@ -1,64 +1,133 @@
|
|||||||
type Props = {
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
total: number;
|
total: number;
|
||||||
fishing: number;
|
fishing: number;
|
||||||
transit: number;
|
transit: number;
|
||||||
pairLinks: number;
|
pairLinks: number;
|
||||||
alarms: number;
|
alarms: number;
|
||||||
pollingStatus: "idle" | "loading" | "ready" | "error";
|
|
||||||
lastFetchMinutes: number | null;
|
|
||||||
clock: string;
|
clock: string;
|
||||||
adminMode?: boolean;
|
adminMode?: boolean;
|
||||||
onLogoClick?: () => void;
|
onLogoClick?: () => void;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
};
|
theme?: "dark" | "light";
|
||||||
|
onToggleTheme?: () => void;
|
||||||
|
isSidebarOpen?: boolean;
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout }: Props) {
|
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
|
||||||
const statusColor =
|
|
||||||
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
|
|
||||||
return (
|
return (
|
||||||
<div className="topbar">
|
<>
|
||||||
<div className="logo" onClick={onLogoClick} style={{ cursor: onLogoClick ? "pointer" : undefined }} title={adminMode ? "ADMIN" : undefined}>
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
🛰 <span>WING</span> 조업감시·선단연관 {adminMode ? <span style={{ fontSize: 10, color: "#F59E0B" }}>(ADMIN)</span> : null}
|
<b className="text-xs text-wing-text">{total}</b>척
|
||||||
</div>
|
</div>
|
||||||
<div className="stats">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
<div className="stat">
|
조업 <b className="text-wing-success">{fishing}</b>
|
||||||
DATA <b style={{ color: "#22C55E" }}>API</b>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
POLL{" "}
|
항해 <b className="text-wing-accent">{transit}</b>
|
||||||
<b style={{ color: statusColor }}>
|
|
||||||
{pollingStatus.toUpperCase()}
|
|
||||||
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
|
|
||||||
</b>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
전체 <b>{total}</b>척
|
쌍연결 <b className="text-wing-warning">{pairLinks}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
경고 <b className="text-wing-danger">{alarms}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
</>
|
||||||
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
|
||||||
|
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-full relative z-[1000]">
|
||||||
|
<div className="flex h-[44px] items-center gap-2.5 border-b border-wing-border bg-wing-surface px-3.5">
|
||||||
|
{/* 햄버거 메뉴 (모바일) */}
|
||||||
|
{onMenuToggle && (
|
||||||
|
<button
|
||||||
|
className="flex cursor-pointer items-center justify-center rounded border border-wing-border bg-transparent p-1 text-wing-muted transition-colors hover:border-wing-accent hover:text-wing-text md:hidden"
|
||||||
|
onClick={onMenuToggle}
|
||||||
|
aria-label={isSidebarOpen ? "메뉴 닫기" : "메뉴 열기"}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
{isSidebarOpen ? (
|
||||||
|
<>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로고 */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold${onLogoClick ? " cursor-pointer" : ""}`}
|
||||||
|
onClick={onLogoClick}
|
||||||
|
title={adminMode ? "ADMIN" : undefined}
|
||||||
|
>
|
||||||
|
<span className="text-wing-accent">WING</span>
|
||||||
|
<span className="hidden sm:inline">조업감시·선단연관</span>
|
||||||
|
{adminMode ? <span className="text-[10px] text-wing-warning">(ADMIN)</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
|
||||||
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
{/* 데스크톱: 인라인 통계 */}
|
||||||
|
<div className="ml-auto hidden items-center gap-3.5 md:flex">
|
||||||
|
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
|
||||||
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
{/* 항상 표시: 시계 + 테마 + 사용자 */}
|
||||||
</div>
|
<div className="ml-auto flex shrink-0 items-center gap-2.5 md:ml-2.5">
|
||||||
</div>
|
<span className="whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</span>
|
||||||
<div className="time">{clock}</div>
|
{onToggleTheme && (
|
||||||
|
<button
|
||||||
|
className="cursor-pointer rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
title={theme === "dark" ? "라이트 모드로 전환" : "다크 모드로 전환"}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? "Light" : "Dark"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{userName && (
|
{userName && (
|
||||||
<div className="topbar-user">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<span className="topbar-user__name">{userName}</span>
|
<span className="hidden whitespace-nowrap text-[10px] font-medium text-wing-text sm:inline">{userName}</span>
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<button className="topbar-user__logout" onClick={onLogout}>
|
<button
|
||||||
|
className="cursor-pointer whitespace-nowrap rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||||
|
onClick={onLogout}
|
||||||
|
>
|
||||||
로그아웃
|
로그아웃
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 통계 바 (펼침 시) — 레이아웃 흐름에 포함, 지도 영역 밀어내기 */}
|
||||||
|
{isStatsOpen && (
|
||||||
|
<div className="flex items-center justify-center gap-4 border-b border-wing-border bg-wing-surface px-3.5 py-2 md:hidden">
|
||||||
|
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모바일 통계 토글 탭 — topbar 하단 우측에 걸침 */}
|
||||||
|
<button
|
||||||
|
className="absolute bottom-0 right-3.5 z-10 translate-y-full cursor-pointer rounded-b-md border border-t-0 border-wing-border bg-wing-surface px-2 py-0.5 text-[8px] text-wing-muted transition-colors hover:text-wing-text md:hidden"
|
||||||
|
onClick={() => setIsStatsOpen((v) => !v)}
|
||||||
|
aria-label={isStatsOpen ? "통계 닫기" : "통계 열기"}
|
||||||
|
>
|
||||||
|
{isStatsOpen ? "▴" : "▾ 통계"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { TextInput } from "@wing/ui";
|
||||||
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
@ -12,6 +14,26 @@ type Props = {
|
|||||||
onClearHover?: () => void;
|
onClearHover?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isFiniteNumber(x: unknown): x is number {
|
||||||
|
return typeof x === "number" && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRIP_RE = /[\s\-.,_]/g;
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s.replace(STRIP_RE, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(v: DerivedLegacyVessel, nq: string): boolean {
|
||||||
|
if (normalize(v.permitNo).includes(nq)) return true;
|
||||||
|
if (normalize(v.name).includes(nq)) return true;
|
||||||
|
if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true;
|
||||||
|
if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true;
|
||||||
|
if (v.legacy.callSign && normalize(v.legacy.callSign).includes(nq)) return true;
|
||||||
|
if (normalize(String(v.mmsi)).includes(nq)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function VesselList({
|
export function VesselList({
|
||||||
vessels,
|
vessels,
|
||||||
selectedMmsi,
|
selectedMmsi,
|
||||||
@ -21,6 +43,8 @@ export function VesselList({
|
|||||||
onHoverMmsi,
|
onHoverMmsi,
|
||||||
onClearHover,
|
onClearHover,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
|
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||||
onToggleHighlightMmsi(mmsi);
|
onToggleHighlightMmsi(mmsi);
|
||||||
@ -29,16 +53,22 @@ export function VesselList({
|
|||||||
onSelectMmsi(mmsi);
|
onSelectMmsi(mmsi);
|
||||||
};
|
};
|
||||||
|
|
||||||
function isFiniteNumber(x: unknown): x is number {
|
const sorted = useMemo(() => {
|
||||||
return typeof x === "number" && Number.isFinite(x);
|
const nq = searchQuery.length >= 2 ? normalize(searchQuery) : "";
|
||||||
}
|
const filtered = nq ? vessels.filter((v) => matchesQuery(v, nq)) : vessels;
|
||||||
|
return filtered.slice().sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1));
|
||||||
const sorted = vessels
|
}, [vessels, searchQuery]);
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
|
|
||||||
.slice(0, 80);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="mb-1.5 shrink-0 px-1">
|
||||||
|
<TextInput
|
||||||
|
placeholder="검색: 등록번호 / 선박명 / MMSI"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-5 w-full py-0 text-[9px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="vlist">
|
<div className="vlist">
|
||||||
{sorted.map((v) => {
|
{sorted.map((v) => {
|
||||||
const meta = VESSEL_TYPES[v.shipCode];
|
const meta = VESSEL_TYPES[v.shipCode];
|
||||||
@ -77,5 +107,6 @@ export function VesselList({
|
|||||||
})}
|
})}
|
||||||
{sorted.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>(표시할 대상 선박 없음)</div> : null}
|
{sorted.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>(표시할 대상 선박 없음)</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
@ -15,7 +16,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
|
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react()],
|
plugins: [tailwindcss(), react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// deck.gl (via loaders.gl) contains a few Node-only helper modules.
|
// deck.gl (via loaders.gl) contains a few Node-only helper modules.
|
||||||
|
|||||||
646
package-lock.json
generated
646
package-lock.json
generated
@ -6,7 +6,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "wing-fleet-dashboard",
|
"name": "wing-fleet-dashboard",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*"
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
@ -38,6 +39,7 @@
|
|||||||
"@maptiler/weather": "^3.1.1",
|
"@maptiler/weather": "^3.1.1",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
"@stomp/stompjs": "^7.2.1",
|
"@stomp/stompjs": "^7.2.1",
|
||||||
|
"@wing/ui": "*",
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@ -46,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@ -54,6 +57,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
@ -2473,6 +2477,278 @@
|
|||||||
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
|
"enhanced-resolve": "^5.18.3",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.30.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@emnapi/wasi-threads": "^1.1.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.0",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/vite": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/node": "4.1.18",
|
||||||
|
"@tailwindcss/oxide": "4.1.18",
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^5.2.0 || ^6 || ^7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@turf/boolean-clockwise": {
|
"node_modules/@turf/boolean-clockwise": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz",
|
||||||
@ -2970,6 +3246,10 @@
|
|||||||
"resolved": "apps/api",
|
"resolved": "apps/api",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@wing/ui": {
|
||||||
|
"resolved": "packages/ui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@wing/web": {
|
"node_modules/@wing/web": {
|
||||||
"resolved": "apps/web",
|
"resolved": "apps/web",
|
||||||
"link": true
|
"link": true
|
||||||
@ -3315,6 +3595,15 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/codepage": {
|
"node_modules/codepage": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
@ -3487,6 +3776,16 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/draco3d": {
|
"node_modules/draco3d": {
|
||||||
"version": "1.5.7",
|
"version": "1.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||||
@ -3506,6 +3805,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||||
|
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@ -4174,6 +4487,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/h3-js": {
|
"node_modules/h3-js": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz",
|
||||||
@ -4350,6 +4670,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-base64": {
|
"node_modules/js-base64": {
|
||||||
"version": "3.7.8",
|
"version": "3.7.8",
|
||||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
||||||
@ -4548,6 +4878,267 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-x64": "1.30.2",
|
||||||
|
"lightningcss-freebsd-x64": "1.30.2",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-musl": "1.30.2",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@ -4603,6 +5194,16 @@
|
|||||||
"integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==",
|
"integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/maplibre-gl": {
|
"node_modules/maplibre-gl": {
|
||||||
"version": "5.18.0",
|
"version": "5.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz",
|
||||||
@ -5469,6 +6070,37 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/texture-compressor": {
|
"node_modules/texture-compressor": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
|
||||||
@ -5967,6 +6599,18 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/ui": {
|
||||||
|
"name": "@wing/ui",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^3.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wing-fleet-dashboard",
|
"name": "wing-fleet-dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["apps/*"],
|
"workspaces": ["apps/*", "packages/*"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:web & npm run dev:api",
|
"dev": "npm run dev:web & npm run dev:api",
|
||||||
"dev:web": "npm -w @wing/web run dev",
|
"dev:web": "npm -w @wing/web run dev",
|
||||||
|
|||||||
18
packages/ui/package.json
Normal file
18
packages/ui/package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@wing/ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./theme/tokens.css": "./src/theme/tokens.css"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^3.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/ui/src/components/Badge.tsx
Normal file
27
packages/ui/src/components/Badge.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
|
variant?: 'default' | 'accent' | 'danger' | 'warning' | 'success' | 'muted';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-[5px] border px-1.5 py-px text-[8px] font-extrabold leading-none tracking-wide',
|
||||||
|
variant === 'default' && 'border-white/8 bg-wing-muted/22 text-white',
|
||||||
|
variant === 'accent' && 'border-wing-accent/70 bg-wing-accent/22 text-white',
|
||||||
|
variant === 'danger' && 'border-wing-danger/70 bg-wing-danger/22 text-white',
|
||||||
|
variant === 'warning' && 'border-wing-warning/70 bg-wing-warning/22 text-white',
|
||||||
|
variant === 'success' && 'border-wing-success/70 bg-wing-success/22 text-white',
|
||||||
|
variant === 'muted' && 'border-wing-border/90 bg-wing-card/55 text-wing-muted font-bold',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
packages/ui/src/components/Button.tsx
Normal file
30
packages/ui/src/components/Button.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'ghost' | 'primary' | 'danger';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant = 'ghost', size = 'sm', className, children, ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded border transition-all duration-150 select-none whitespace-nowrap',
|
||||||
|
size === 'sm' && 'px-1.5 py-0.5 text-[9px]',
|
||||||
|
size === 'md' && 'px-2.5 py-1.5 text-xs rounded-lg',
|
||||||
|
variant === 'ghost' &&
|
||||||
|
'border-wing-border bg-transparent text-wing-muted hover:text-wing-text hover:border-wing-accent',
|
||||||
|
variant === 'primary' &&
|
||||||
|
'border-wing-accent bg-wing-accent text-white hover:brightness-110',
|
||||||
|
variant === 'danger' &&
|
||||||
|
'border-wing-danger bg-transparent text-wing-danger hover:bg-wing-danger/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
packages/ui/src/components/IconButton.tsx
Normal file
27
packages/ui/src/components/IconButton.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
active?: boolean;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({ active, size = 'md', className, children, ...props }: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 cursor-pointer items-center justify-center rounded border p-0 transition-all duration-150 select-none',
|
||||||
|
'border-wing-border bg-wing-glass text-wing-muted backdrop-blur-lg',
|
||||||
|
'hover:text-wing-text hover:border-wing-accent',
|
||||||
|
size === 'sm' && 'h-[22px] w-[22px] rounded text-sm',
|
||||||
|
size === 'md' && 'h-[29px] w-[29px] rounded text-base',
|
||||||
|
active && 'text-wing-accent border-wing-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
packages/ui/src/components/ListItem.tsx
Normal file
26
packages/ui/src/components/ListItem.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface ListItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
selected?: boolean;
|
||||||
|
highlighted?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListItem({ selected, highlighted, className, children, ...props }: ListItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-1 text-[10px] transition-colors duration-100 select-none',
|
||||||
|
'hover:bg-wing-card',
|
||||||
|
selected && 'bg-[var(--wing-select-bg)] border-[var(--wing-select-border)]',
|
||||||
|
highlighted && !selected && 'bg-[var(--wing-highlight-bg)] border border-[var(--wing-highlight-border)]',
|
||||||
|
selected && highlighted && 'bg-gradient-to-r from-[var(--wing-select-bg)] to-[var(--wing-highlight-bg)] border-[var(--wing-select-border)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/ui/src/components/Panel.tsx
Normal file
24
packages/ui/src/components/Panel.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface PanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
glass?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Panel({ glass = true, className, children, ...props }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-wing-border p-3',
|
||||||
|
glass
|
||||||
|
? 'bg-wing-glass backdrop-blur-lg'
|
||||||
|
: 'bg-wing-surface',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
packages/ui/src/components/Section.tsx
Normal file
38
packages/ui/src/components/Section.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface SectionProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||||
|
title: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
contentClassName?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ title, actions, defaultOpen = true, className, contentClassName, children, ...props }: SectionProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('border-b border-wing-border', className)} {...props}>
|
||||||
|
<div
|
||||||
|
className="shrink-0 flex cursor-pointer items-center justify-between gap-2 px-3 py-2.5 select-none"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-[1.5px] text-wing-muted">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{actions && (
|
||||||
|
<span onClick={(e) => e.stopPropagation()} className="flex items-center gap-1.5">
|
||||||
|
{actions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={cn('px-3 pb-2.5', contentClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
packages/ui/src/components/TextInput.tsx
Normal file
18
packages/ui/src/components/TextInput.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
export function TextInput({ className, ...props }: TextInputProps) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-md border border-wing-border bg-wing-card/75 px-2 py-1.5 text-[10px] text-wing-text outline-none',
|
||||||
|
'placeholder:text-wing-muted/90',
|
||||||
|
'focus:border-wing-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/ui/src/components/ToggleButton.tsx
Normal file
24
packages/ui/src/components/ToggleButton.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface ToggleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
on?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleButton({ on, className, children, ...props }: ToggleButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded border px-1.5 py-0.5 text-[8px] transition-all duration-150 select-none',
|
||||||
|
on
|
||||||
|
? 'border-wing-accent bg-wing-accent text-white'
|
||||||
|
: 'border-wing-border bg-wing-card text-wing-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
packages/ui/src/components/index.ts
Normal file
8
packages/ui/src/components/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export { Button } from './Button.tsx';
|
||||||
|
export { IconButton } from './IconButton.tsx';
|
||||||
|
export { ToggleButton } from './ToggleButton.tsx';
|
||||||
|
export { Badge } from './Badge.tsx';
|
||||||
|
export { Panel } from './Panel.tsx';
|
||||||
|
export { Section } from './Section.tsx';
|
||||||
|
export { ListItem } from './ListItem.tsx';
|
||||||
|
export { TextInput } from './TextInput.tsx';
|
||||||
2
packages/ui/src/index.ts
Normal file
2
packages/ui/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { cn } from './utils/cn.ts';
|
||||||
|
export * from './components/index.ts';
|
||||||
87
packages/ui/src/theme/tokens.css
Normal file
87
packages/ui/src/theme/tokens.css
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/* ── Wing Design Tokens ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Dark theme (default) */
|
||||||
|
:root,
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--wing-bg: #020617;
|
||||||
|
--wing-surface: #0f172a;
|
||||||
|
--wing-card: #1e293b;
|
||||||
|
--wing-border: #1e3a5f;
|
||||||
|
--wing-text: #e2e8f0;
|
||||||
|
--wing-muted: #64748b;
|
||||||
|
--wing-accent: #3b82f6;
|
||||||
|
--wing-danger: #ef4444;
|
||||||
|
--wing-warning: #f59e0b;
|
||||||
|
--wing-success: #22c55e;
|
||||||
|
--wing-glass: rgba(15, 23, 42, 0.92);
|
||||||
|
--wing-glass-dense: rgba(15, 23, 42, 0.95);
|
||||||
|
--wing-overlay: rgba(2, 6, 23, 0.42);
|
||||||
|
--wing-card-alpha: rgba(30, 41, 59, 0.55);
|
||||||
|
--wing-subtle: rgba(255, 255, 255, 0.03);
|
||||||
|
--wing-select-bg: rgba(14, 234, 255, 0.16);
|
||||||
|
--wing-select-border: rgba(14, 234, 255, 0.55);
|
||||||
|
--wing-highlight-bg: rgba(245, 158, 11, 0.16);
|
||||||
|
--wing-highlight-border: rgba(245, 158, 11, 0.4);
|
||||||
|
|
||||||
|
/* Legacy aliases (backward compatibility) */
|
||||||
|
--bg: var(--wing-bg);
|
||||||
|
--panel: var(--wing-surface);
|
||||||
|
--card: var(--wing-card);
|
||||||
|
--border: var(--wing-border);
|
||||||
|
--text: var(--wing-text);
|
||||||
|
--muted: var(--wing-muted);
|
||||||
|
--accent: var(--wing-accent);
|
||||||
|
--crit: var(--wing-danger);
|
||||||
|
--high: var(--wing-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
[data-theme='light'] {
|
||||||
|
--wing-bg: #e2e8f0;
|
||||||
|
--wing-surface: #ffffff;
|
||||||
|
--wing-card: #f1f5f9;
|
||||||
|
--wing-border: #94a3b8;
|
||||||
|
--wing-text: #0f172a;
|
||||||
|
--wing-muted: #64748b;
|
||||||
|
--wing-accent: #2563eb;
|
||||||
|
--wing-danger: #dc2626;
|
||||||
|
--wing-warning: #d97706;
|
||||||
|
--wing-success: #16a34a;
|
||||||
|
--wing-glass: rgba(255, 255, 255, 0.92);
|
||||||
|
--wing-glass-dense: rgba(255, 255, 255, 0.95);
|
||||||
|
--wing-overlay: rgba(0, 0, 0, 0.25);
|
||||||
|
--wing-card-alpha: rgba(226, 232, 240, 0.6);
|
||||||
|
--wing-subtle: rgba(0, 0, 0, 0.03);
|
||||||
|
--wing-select-bg: rgba(14, 182, 210, 0.14);
|
||||||
|
--wing-select-border: rgba(14, 182, 210, 0.5);
|
||||||
|
--wing-highlight-bg: rgba(217, 119, 6, 0.14);
|
||||||
|
--wing-highlight-border: rgba(217, 119, 6, 0.4);
|
||||||
|
|
||||||
|
--bg: var(--wing-bg);
|
||||||
|
--panel: var(--wing-surface);
|
||||||
|
--card: var(--wing-card);
|
||||||
|
--border: var(--wing-border);
|
||||||
|
--text: var(--wing-text);
|
||||||
|
--muted: var(--wing-muted);
|
||||||
|
--accent: var(--wing-accent);
|
||||||
|
--crit: var(--wing-danger);
|
||||||
|
--high: var(--wing-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tailwind Theme Mapping ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-wing-bg: var(--wing-bg);
|
||||||
|
--color-wing-surface: var(--wing-surface);
|
||||||
|
--color-wing-card: var(--wing-card);
|
||||||
|
--color-wing-border: var(--wing-border);
|
||||||
|
--color-wing-text: var(--wing-text);
|
||||||
|
--color-wing-muted: var(--wing-muted);
|
||||||
|
--color-wing-accent: var(--wing-accent);
|
||||||
|
--color-wing-danger: var(--wing-danger);
|
||||||
|
--color-wing-warning: var(--wing-warning);
|
||||||
|
--color-wing-success: var(--wing-success);
|
||||||
|
--color-wing-glass: var(--wing-glass);
|
||||||
|
|
||||||
|
--font-sans: 'Noto Sans KR', sans-serif;
|
||||||
|
}
|
||||||
6
packages/ui/src/utils/cn.ts
Normal file
6
packages/ui/src/utils/cn.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
19
packages/ui/tsconfig.json
Normal file
19
packages/ui/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user