Compare commits

..

No commits in common. "develop" and "feature/refactor-cleanup" have entirely different histories.

64개의 변경된 파일1388개의 추가작업 그리고 3013개의 파일을 삭제

파일 보기

@ -4,9 +4,6 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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>
</head>
<body>

파일 보기

@ -12,23 +12,21 @@
"dependencies": {
"@deck.gl/aggregation-layers": "^9.2.7",
"@deck.gl/core": "^9.2.7",
"@deck.gl/extensions": "^9.2.7",
"@deck.gl/geo-layers": "^9.2.7",
"@deck.gl/layers": "^9.2.7",
"@deck.gl/mapbox": "^9.2.7",
"@maptiler/weather": "^3.1.1",
"@react-oauth/google": "^0.13.4",
"@stomp/stompjs": "^7.2.1",
"@wing/ui": "*",
"maplibre-gl": "^5.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^7.13.0",
"@deck.gl/extensions": "^9.2.7",
"@stomp/stompjs": "^7.2.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -37,7 +35,6 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"

파일 보기

@ -1,13 +1,12 @@
/* ── Wing Fleet Dashboard Style Entry Point ─────────────────── */
@import "tailwindcss";
@import "@wing/ui/theme/tokens.css";
@source "../../../../packages/ui/src/**/*.{ts,tsx}";
@import "./styles/base.css";
@import "./styles/layout.css";
/* Components (layout/topbar/toggles → inline Tailwind + @wing/ui) */
/* Components */
@import "./styles/components/topbar.css";
@import "./styles/components/panels.css";
@import "./styles/components/toggles.css";
@import "./styles/components/speed.css";
@import "./styles/components/vessel-list.css";
@import "./styles/components/ais-list.css";

파일 보기

@ -1,6 +1,23 @@
/* Google Fonts: loaded via index.html <link> */
/* CSS Reset: handled by Tailwind preflight */
/* CSS Variables: defined in @wing/ui/theme/tokens.css */
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap");
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #020617;
--panel: #0f172a;
--card: #1e293b;
--border: #1e3a5f;
--text: #e2e8f0;
--muted: #64748b;
--accent: #3b82f6;
--crit: #ef4444;
--high: #f59e0b;
}
html,
body {

파일 보기

@ -5,13 +5,13 @@
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--wing-card-alpha);
background: rgba(30, 41, 59, 0.75);
color: var(--text);
outline: none;
}
.ais-q::placeholder {
color: var(--wing-muted);
color: rgba(100, 116, 139, 0.9);
}
.ais-mode {
@ -55,8 +55,8 @@
}
.ais-row:hover {
background: var(--wing-card-alpha);
border-color: var(--wing-border);
background: rgba(30, 41, 59, 0.6);
border-color: rgba(30, 58, 95, 0.8);
}
.ais-row.sel {
@ -118,8 +118,8 @@
.ais-badge.pn {
color: var(--muted);
background: var(--wing-card-alpha);
border-color: var(--wing-border);
background: rgba(30, 41, 59, 0.55);
border-color: rgba(30, 58, 95, 0.9);
font-weight: 700;
}
@ -155,5 +155,5 @@
.ais-ts {
font-size: 9px;
color: var(--wing-muted);
color: rgba(100, 116, 139, 0.9);
}

파일 보기

@ -36,7 +36,7 @@
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--wing-card-alpha);
background: rgba(30, 41, 59, 0.55);
color: var(--text);
font-size: 8px;
font-weight: 700;
@ -58,7 +58,7 @@
padding: 6px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--wing-glass-dense);
background: rgba(15, 23, 42, 0.98);
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
}
@ -90,6 +90,6 @@
.alarm-filter__sep {
height: 1px;
background: var(--wing-border);
background: rgba(30, 58, 95, 0.85);
margin: 4px 0;
}

파일 보기

@ -5,7 +5,7 @@
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--wing-bg) 0%, var(--wing-surface) 50%, var(--wing-bg) 100%);
background: linear-gradient(135deg, #020617 0%, #0f172a 50%, #020617 100%);
}
.auth-card {

파일 보기

@ -5,7 +5,7 @@
bottom: 44px;
right: 12px;
z-index: 800;
background: var(--wing-glass);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
@ -41,7 +41,7 @@
top: 12px;
right: 12px;
z-index: 800;
background: var(--wing-glass-dense);
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
@ -54,7 +54,7 @@
justify-content: space-between;
font-size: 10px;
padding: 2px 0;
border-bottom: 1px solid var(--wing-subtle);
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.map-info .il {
@ -72,13 +72,13 @@
display: flex;
align-items: center;
justify-content: center;
background: var(--wing-overlay);
background: rgba(2, 6, 23, 0.42);
pointer-events: auto;
}
.map-loader-overlay__panel {
width: min(72vw, 320px);
background: var(--wing-glass-dense);
background: rgba(15, 23, 42, 0.94);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
@ -185,7 +185,7 @@
.maplibregl-ctrl-group {
border: 1px solid var(--border) !important;
background: var(--wing-glass) !important;
background: rgba(15, 23, 42, 0.92) !important;
backdrop-filter: blur(8px);
}
@ -197,20 +197,14 @@
border-top: 1px solid var(--border) !important;
}
:root .maplibregl-ctrl-group button span,
[data-theme='dark'] .maplibregl-ctrl-group button span {
.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;
background: rgba(15, 23, 42, 0.75) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
border-radius: 8px;

파일 보기

@ -9,7 +9,7 @@
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--wing-glass);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
@ -37,7 +37,7 @@
top: 10px;
left: 48px;
z-index: 850;
background: var(--wing-glass-dense);
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
@ -148,7 +148,7 @@
bottom: 44px;
left: 10px;
z-index: 800;
background: var(--wing-glass);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;

파일 보기

@ -1,3 +1,24 @@
.sb {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.sb-t {
font-size: 9px;
font-weight: 700;
color: var(--muted);
letter-spacing: 1.5px;
text-transform: uppercase;
margin-bottom: 6px;
}
.sb-t-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.relation-sort {
display: flex;
align-items: center;

파일 보기

@ -53,7 +53,7 @@
/* Fleet network */
.fleet-card {
background: var(--wing-card-alpha);
background: rgba(30, 41, 59, 0.8);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;

파일 보기

@ -0,0 +1,75 @@
/* Type grid */
.tg {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
}
.tb {
background: var(--card);
border: 1px solid transparent;
border-radius: 5px;
padding: 4px;
cursor: pointer;
text-align: center;
transition: all 0.15s;
user-select: none;
}
.tb:hover {
border-color: var(--border);
}
.tb.on {
border-color: var(--accent);
background: rgba(59, 130, 246, 0.1);
}
.tb .c {
font-size: 11px;
font-weight: 800;
}
.tb .n {
font-size: 8px;
color: var(--muted);
}
/* Toggles */
.tog {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.tog.tog-map {
/* Keep "지도 표시 설정" buttons in a predictable 2-row layout (4 columns). */
gap: 4px;
}
.tog.tog-map .tog-btn {
flex: 1 1 calc(25% - 4px);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tog-btn {
font-size: 8px;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.tog-btn.on {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}

파일 보기

@ -0,0 +1,84 @@
.topbar {
grid-column: 1/-1;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 14px;
gap: 10px;
z-index: 1000;
}
.topbar .logo {
font-size: 14px;
font-weight: 800;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.topbar .logo span {
color: var(--accent);
}
.topbar .stats {
display: flex;
gap: 14px;
margin-left: auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.topbar .stat {
font-size: 10px;
color: var(--muted);
display: flex;
align-items: center;
gap: 4px;
}
.topbar .stat b {
color: var(--text);
font-size: 12px;
}
.topbar .time {
font-size: 10px;
color: var(--accent);
font-weight: 600;
margin-left: 10px;
white-space: nowrap;
}
.topbar-user {
display: flex;
align-items: center;
gap: 8px;
margin-left: 10px;
flex-shrink: 0;
}
.topbar-user__name {
font-size: 10px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
}
.topbar-user__logout {
font-size: 9px;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 6px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.topbar-user__logout:hover {
color: var(--text);
border-color: var(--accent);
}

파일 보기

@ -9,7 +9,7 @@
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--wing-glass);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
@ -73,7 +73,7 @@
}
.wo-panel {
background: var(--wing-glass-dense);
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
@ -123,7 +123,7 @@
padding: 6px 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--wing-subtle);
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
@ -329,7 +329,7 @@
/* ── Weather Legend ── */
.wo-legend {
background: var(--wing-glass);
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 6px;

파일 보기

@ -9,7 +9,7 @@
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--wing-glass);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
@ -37,7 +37,7 @@
top: 130px;
left: 48px;
z-index: 850;
background: var(--wing-glass-dense);
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
@ -87,7 +87,7 @@
padding: 6px 8px;
margin-bottom: 6px;
border-radius: 0 4px 4px 0;
background: var(--wing-subtle);
background: rgba(255, 255, 255, 0.03);
transition: background 0.15s;
}

파일 보기

@ -0,0 +1,30 @@
.app {
display: grid;
grid-template-columns: 310px 1fr;
grid-template-rows: 44px 1fr;
height: 100vh;
}
.sidebar {
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.map-area {
position: relative;
background: #010610;
}
@media (max-width: 920px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: 44px 1fr;
}
.sidebar {
display: none;
}
}

파일 보기

@ -1,216 +0,0 @@
/**
* / (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,21 +88,3 @@ export const LEGACY_ALARM_KIND_LABEL: Record<LegacyAlarmKind, string> = {
ais_stale: "AIS 지연",
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,4 +1,3 @@
import { ToggleButton } from '@wing/ui';
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
type Props = {
@ -14,11 +13,11 @@ export function Map3DSettingsToggles({ value, onToggle }: Props) {
];
return (
<div className="flex flex-wrap gap-0.75 mb-1.5">
<div className="tog">
{items.map((t) => (
<ToggleButton key={t.id} on={value[t.id]} onClick={() => onToggle(t.id)}>
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
{t.label}
</ToggleButton>
</div>
))}
</div>
);

파일 보기

@ -146,7 +146,8 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
className={`tog-btn${autoGradient ? ' on' : ''}`}
style={{ fontSize: 8, padding: '1px 5px', marginLeft: 8 }}
onClick={toggleAutoGradient}
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
>
@ -175,11 +176,11 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
{/* ── Depth font size ───────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="flex flex-wrap gap-0.75">
<div className="tog" style={{ gap: 3 }}>
{FONT_SIZES.map((fs) => (
<div
key={fs.value}
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'}`}
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
onClick={() => update('depthFontSize', fs.value)}
>
{fs.label}

파일 보기

@ -1,5 +1,3 @@
import { ToggleButton } from '@wing/ui';
export type MapToggleState = {
pairLines: boolean;
pairRange: boolean;
@ -29,16 +27,11 @@ export function MapToggles({ value, onToggle }: Props) {
];
return (
<div className="flex flex-wrap gap-1">
<div className="tog tog-map">
{items.map((t) => (
<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"
>
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
{t.label}
</ToggleButton>
</div>
))}
</div>
);

파일 보기

@ -1,6 +1,7 @@
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants';
import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../shared/lib/map/mapConstants';
import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache';
import { getShipKindColor } from '../lib/adapters';
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
@ -138,21 +139,20 @@ export function createDynamicTrackLayers(options: {
if (showVirtualShip) {
layers.push(
new ScatterplotLayer<CurrentVesselPosition>({
new IconLayer<CurrentVesselPosition>({
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
data: currentPositions,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => d.position,
getFillColor: (d) => {
getSize: 22,
sizeUnits: 'pixels',
getAngle: (d) => -d.heading,
getColor: (d) => {
const base = getShipKindColor(d.shipKindCode);
return [base[0], base[1], base[2], 230] as [number, number, number, number];
return [base[0], base[1], base[2], 245] 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,
pickable: true,
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
@ -183,7 +183,6 @@ export function createDynamicTrackLayers(options: {
getAlignmentBaseline: 'center',
getPixelOffset: [14, 0],
fontFamily: 'Malgun Gothic, Arial, sans-serif',
fontSettings: { sdf: true },
outlineColor: [2, 6, 23, 220],
outlineWidth: 2,
parameters: DEPTH_DISABLED_PARAMS,

파일 보기

@ -9,26 +9,26 @@ type Props = {
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) {
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
return (
<div className="grid grid-cols-3 gap-0.75">
<div className={`col-span-full ${TB} ${allOn ? TB_ON : TB_OFF}`} onClick={onToggleAll}>
<div className="text-[11px] font-extrabold text-wing-accent"></div>
<div className="text-[8px] text-wing-muted">{totalCount}</div>
<div className="tg">
<div className={`tb ${allOn ? "on" : ""}`} onClick={onToggleAll} style={{ gridColumn: "1/-1" }}>
<div className="c" style={{ color: "var(--accent)" }}>
</div>
<div className="n">{totalCount}</div>
</div>
{VESSEL_TYPE_ORDER.map((code) => {
const t = VESSEL_TYPES[code];
const cnt = countsByType[code] ?? 0;
return (
<div key={code} className={`${TB} ${enabled[code] ? TB_ON : TB_OFF}`} onClick={() => onToggle(code)}>
<div className="text-[11px] font-extrabold" style={{ color: t.color }}>{code}</div>
<div className="text-[8px] text-wing-muted">{cnt}</div>
<div key={code} className={`tb ${enabled[code] ? "on" : ""}`} onClick={() => onToggle(code)}>
<div className="c" style={{ color: t.color }}>
{code}
</div>
<div className="n">{cnt}</div>
</div>
);
})}

파일 보기

@ -1,9 +1,8 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { useAuth } from "../../shared/auth";
import { useTheme } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
import { ALARM_KIND_PRIORITY, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
@ -36,7 +35,6 @@ import {
deriveLegacyVessels,
filterByShipCodes,
} from "../../features/legacyDashboard/model/derive";
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";
@ -60,10 +58,7 @@ function useLegacyIndex(data: LegacyVesselDataset | null) {
export function DashboardPage() {
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();
@ -144,19 +139,11 @@ export function DashboardPage() {
// ── Derived data ──
const targetsInScope = useMemo(() => {
const base = (!useViewportFilter || !viewBbox)
? targets
: 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]);
if (!useViewportFilter || !viewBbox) return targets;
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
}, [targets, useViewportFilter, viewBbox]);
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 legacyHits = useMemo(() => buildLegacyHitMap(targetsInScope, legacyIndex), [targetsInScope, legacyIndex]);
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
@ -205,17 +192,6 @@ export function DashboardPage() {
return alarms.filter((a) => enabled.has(a.kind));
}, [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 fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
@ -249,6 +225,8 @@ export function DashboardPage() {
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKinds.length}/${LEGACY_ALARM_KINDS.length}`;
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
if (!mmsis.length) return;
const members = mmsis
@ -267,27 +245,23 @@ export function DashboardPage() {
// ── Render ──
return (
<div className="grid h-screen grid-cols-[310px_1fr] grid-rows-[44px_1fr] max-md:grid-cols-[1fr] max-md:grid-rows-[auto_1fr]">
<div className="app">
<Topbar
total={legacyVesselsAll.length}
fishing={fishingCount}
transit={transitCount}
pairLinks={pairLinksAll.length}
alarms={alarms.length}
pollingStatus={snapshot.status}
lastFetchMinutes={snapshot.lastFetchMinutes}
clock={clock}
adminMode={adminMode}
onLogoClick={onLogoClick}
userName={user?.name}
onLogout={logout}
theme={theme}
onToggleTheme={toggleTheme}
isSidebarOpen={isSidebarOpen}
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
/>
<DashboardSidebar
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
state={state}
legacyVesselsAll={legacyVesselsAll}
legacyVesselsFiltered={legacyVesselsFiltered}
@ -298,6 +272,8 @@ export function DashboardPage() {
filteredAlarms={filteredAlarms}
alarms={alarms}
alarmKindCounts={alarmKindCounts}
allAlarmKindsEnabled={allAlarmKindsEnabled}
alarmFilterSummary={alarmFilterSummary}
speedPanelType={speedPanelType}
onFleetContextMenu={handleFleetContextMenu}
snapshot={snapshot}
@ -309,7 +285,7 @@ export function DashboardPage() {
legacyIndex={legacyIndex}
/>
<div className="relative bg-[#010610]">
<div className="map-area">
{showMapLoader ? (
<div className="map-loader-overlay" role="status" aria-live="polite">
<div className="map-loader-overlay__panel">
@ -369,7 +345,6 @@ export function DashboardPage() {
onCloseTrackMenu={handleCloseTrackMenu}
onOpenTrackMenu={handleOpenTrackMenu}
onMapReady={handleMapReady}
alarmMmsiMap={alarmMmsiMap}
/>
<GlobalTrackReplayPanel />
<WeatherPanel

파일 보기

@ -1,5 +1,3 @@
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';
@ -29,9 +27,8 @@ function fmtBbox(b: Bbox | null) {
}
interface DashboardSidebarProps {
isOpen: boolean;
onClose: () => void;
state: ReturnType<typeof useDashboardState>;
// Derived data
legacyVesselsAll: DerivedLegacyVessel[];
legacyVesselsFiltered: DerivedLegacyVessel[];
legacyCounts: Record<VesselTypeCode, number>;
@ -41,8 +38,11 @@ interface DashboardSidebarProps {
filteredAlarms: LegacyAlarm[];
alarms: LegacyAlarm[];
alarmKindCounts: Record<LegacyAlarmKind, number>;
allAlarmKindsEnabled: boolean;
alarmFilterSummary: string;
speedPanelType: VesselTypeCode;
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
// Data fetching (admin panels)
snapshot: AisPollingSnapshot;
legacyError: string | null;
legacyData: LegacyVesselDataset | null;
@ -53,8 +53,6 @@ interface DashboardSidebarProps {
}
export function DashboardSidebar({
isOpen,
onClose,
state,
legacyVesselsAll,
legacyVesselsFiltered,
@ -65,6 +63,8 @@ export function DashboardSidebar({
filteredAlarms,
alarms,
alarmKindCounts,
allAlarmKindsEnabled,
alarmFilterSummary,
speedPanelType,
onFleetContextMenu,
snapshot,
@ -93,112 +93,88 @@ export function DashboardSidebar({
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 className="sidebar">
<div className="sb">
<div className="sb-t"> </div>
<div className="tog">
<div
className={`tog-btn ${showTargets ? 'on' : ''}`}
onClick={() => {
setShowTargets((v) => {
const next = !v;
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
return next;
});
}}
title="레거시(CN permit) 대상 선박 표시"
>
</div>
<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>
<div className={`tog-btn ${showOthers ? 'on' : ''}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
AIS
</div>
</div>
<TypeFilterGrid
enabled={typeEnabled}
totalCount={legacyVesselsAll.length}
countsByType={legacyCounts}
onToggle={(code) => {
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
}}
onToggleAll={() => {
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
const nextVal = !allOn;
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
setTypeEnabled({
PT: nextVal, 'PT-S': nextVal, GN: nextVal, OT: nextVal, PS: nextVal, FC: nextVal,
});
}}
/>
</div>
<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>
<div className="sb">
<div className="sb-t" style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ flex: 1 }} />
<div
className={`tog-btn ${projection === 'globe' ? 'on' : ''}${isProjectionToggleDisabled ? ' disabled' : ''}`}
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영: 드래그로 회전, 휠로 확대/축소'}
style={{ fontSize: 9, padding: '2px 8px', opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? 'not-allowed' : 'pointer' }}
>
3D
</div>
</div>
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
</div>
<Section title="속도 프로파일" defaultOpen={false} className="md:shrink-0">
<SpeedProfilePanel selectedType={speedPanelType} />
</Section>
<div className="sb">
<div className="sb-t"> </div>
<SpeedProfilePanel selectedType={speedPanelType} />
</div>
<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"
>
<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}
@ -222,185 +198,233 @@ export function DashboardSidebar({
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
onContextMenuFleet={onFleetContextMenu}
/>
</Section>
</div>
</div>
<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>
<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>
<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">
<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} 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 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} />
</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>
</div>
</>
{adminMode ? (
<>
<div className="sb">
<div className="sb-t">ADMIN · AIS Target Polling</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div>
<b style={{ color: snapshot.status === 'ready' ? '#22C55E' : snapshot.status === 'error' ? '#EF4444' : '#F59E0B' }}>
{snapshot.status.toUpperCase()}
</b>
{snapshot.error ? <span style={{ marginLeft: 6, color: '#EF4444' }}>{snapshot.error}</span> : null}
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}> fetch</div>
<div>
{fmtIsoFull(snapshot.lastFetchAt)}{' '}
<span style={{ color: 'var(--muted)', fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span>
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
{legacyError ? (
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
) : (
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>( scope)</div>
<div>
<b style={{ color: '#F59E0B' }}>{legacyVesselsAll.length}</b>{' '}
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
</div>
)}
</div>
<div className="sb">
<div className="sb-t">ADMIN · Viewport / BBox</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}> View BBox</div>
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
onClick={() => setUseViewportFilter((v) => !v)}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)',
background: useViewportFilter ? 'rgba(59,130,246,.18)' : 'var(--card)',
color: 'var(--text)', cursor: 'pointer',
}}
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
>
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setUseApiBbox((v) => {
const next = !v;
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
if (!next) setApiBbox(undefined);
return next;
});
}}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)',
background: useApiBbox ? 'rgba(245,158,11,.14)' : 'var(--card)',
color: viewBbox ? 'var(--text)' : 'var(--muted)',
cursor: viewBbox ? 'pointer' : 'not-allowed',
}}
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
disabled={!viewBbox}
>
API bbox {useApiBbox ? 'ON' : 'OFF'}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setApiBbox(fmtBbox(viewBbox));
setUseApiBbox(true);
}}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)',
background: 'var(--card)',
color: viewBbox ? 'var(--text)' : 'var(--muted)',
cursor: viewBbox ? 'pointer' : 'not-allowed',
}}
disabled={!viewBbox}
title="현재 view bbox로 API bbox를 갱신"
>
bbox=viewport
</button>
</div>
<div style={{ marginTop: 8, color: 'var(--muted)', fontSize: 10 }}>
: <b style={{ color: 'var(--text)' }}>{targetsInScope.length}</b> / :{' '}
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Map (Extras)</div>
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}> WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div className="sb-t">ADMIN · AIS Targets (All)</div>
<AisTargetList
targets={targetsInScope}
selectedMmsi={selectedMmsi}
onSelectMmsi={setSelectedMmsi}
legacyIndex={legacyIndex}
/>
</div>
<div className="sb" style={{ maxHeight: 130, overflowY: 'auto' }}>
<div className="sb-t">ADMIN · </div>
{zonesError ? (
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
) : (
<div style={{ fontSize: 11, color: 'var(--muted)' }}>
{zones ? `loaded (${zones.features.length} features)` : 'loading...'}
</div>
)}
</div>
</>
) : null}
</div>
);
}

파일 보기

@ -1,2 +1 @@
export { usePersistedState } from './usePersistedState';
export { useTheme } from './useTheme';

파일 보기

@ -1,47 +0,0 @@
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,121 +1,114 @@
import { useState } from 'react';
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";
export function MapLegend() {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="map-legend">
<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>
<div className="lt"></div>
{ZONE_IDS.map((z) => (
<div key={z} className="li">
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
{ZONE_META[z].name}
</div>
))}
{isOpen && (
<>
<div className="lt" style={{ marginTop: 6 }}></div>
{ZONE_IDS.map((z) => (
<div key={z} className="li">
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
{ZONE_META[z].name}
</div>
))}
<div className="lt" style={{ marginTop: 8 }}>
AIS ()
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
1 SOG &lt; 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
SOG &lt; 1 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
SOG unknown
</div>
<div className="lt" style={{ marginTop: 8 }}> AIS ()</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
1 SOG &lt; 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
SOG &lt; 1 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
SOG unknown
</div>
<div className="lt" style={{ marginTop: 8 }}>
CN Permit()
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
PT (ring + )
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
PT-S
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
GN
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
OT 1
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
PS
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
FC
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
C21
</div>
<div className="lt" style={{ marginTop: 8 }}>CN Permit()</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
PT
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
PT-S
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
GN
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
OT 1
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
PS
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
FC
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
C21
</div>
<div className="lt" style={{ marginTop: 8 }}>
(3D)
</div>
<div className="li" style={{ color: "var(--muted)" }}>
Hexagon: 화면 AIS
</div>
<div className="lt" style={{ marginTop: 8 }}>(3D)</div>
<div className="li" style={{ color: "var(--muted)" }}>
Hexagon: 화면 AIS
</div>
<div className="lt" style={{ marginTop: 8 }}></div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
PTPT-S ()
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
(&gt;3NM)
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
FC (dashed)
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
FC ()
</div>
<div className="li">
<div
style={{
width: 20,
height: 2,
borderRadius: 1,
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
}}
/>
(15)
</div>
</>
)}
<div className="lt" style={{ marginTop: 8 }}>
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
PTPT-S ()
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
(&gt;3NM)
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
FC (dashed)
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
FC ()
</div>
<div className="li">
<div
style={{
width: 20,
height: 2,
borderRadius: 1,
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
}}
/>
(15)
</div>
</div>
);
}

파일 보기

@ -81,7 +81,6 @@ export function Map3D({
onCloseTrackMenu,
onOpenTrackMenu,
onMapReady,
alarmMmsiMap,
}: Props) {
// ── Shared refs ──────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement | null>(null);
@ -94,7 +93,6 @@ export function Map3D({
const projectionBusyRef = useRef(false);
const deckHoverRafRef = useRef<number | null>(null);
const deckHoverHasHitRef = useRef(false);
const mapInitiatedSelectRef = useRef(false);
useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]);
useEffect(() => { projectionRef.current = projection; }, [projection]);
@ -239,15 +237,12 @@ export function Map3D({
return out;
}, [highlightedMmsiSetForShips, selectedMmsi]);
// Globe: 직접 호버/선택된 선박만 hover overlay에 포함
// 선단/쌍 멤버는 feature-state(outline 색상)로 하이라이트 → hover overlay 불필요
// → alarm badge 레이어 가림 방지
const shipHoverOverlaySet = useMemo(
() =>
projection === 'globe'
? mergeNumberSets(shipHighlightSet, hoveredDeckMmsiSetRef)
? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet)
: shipHighlightSet,
[projection, shipHighlightSet, hoveredDeckMmsiSetRef],
[projection, highlightedMmsiSetCombined, shipHighlightSet],
);
const shipOverlayLayerData = useMemo(() => {
@ -277,13 +272,6 @@ export function Map3D({
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(
(info: unknown, allowMultiSelect = false) => {
const obj = info as {
@ -299,12 +287,12 @@ export function Map3D({
return;
}
if (!allowMultiSelect && selectedMmsi === mmsi) {
onMapSelectMmsi(null);
onSelectMmsi(null);
return;
}
onMapSelectMmsi(mmsi);
onSelectMmsi(mmsi);
},
[hasAuxiliarySelectModifier, onMapSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
[hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
@ -573,9 +561,9 @@ export function Map3D({
{
projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
onGlobeShipsReady, alarmMmsiMap,
onGlobeShipsReady,
},
);
@ -608,8 +596,8 @@ export function Map3D({
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap,
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, projectionRef,
},
);
@ -644,30 +632,22 @@ export function Map3D({
e.preventDefault();
if (!onOpenTrackMenu) return;
const map = mapRef.current;
if (!map || projectionBusyRef.current) return;
if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return;
let mmsi: number | null = null;
// Globe/Mercator 공통: MapLibre 레이어에서 bbox 쿼리 (호버 상태 무관)
let shipLayerIds: string[] = [];
try {
shipLayerIds = projectionRef.current === 'globe'
? [
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
].filter((id) => map.getLayer(id))
: [];
} catch { /* ignore */ }
if (projectionRef.current === 'globe') {
// Globe: MapLibre 네이티브 레이어에서 쿼리
const point: [number, number] = [e.offsetX, e.offsetY];
const shipLayerIds = [
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
].filter((id) => map.getLayer(id));
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[] = [];
try {
features = map.queryRenderedFeatures(bbox, { layers: shipLayerIds });
if (shipLayerIds.length > 0) {
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
}
} catch { /* ignore */ }
if (features.length > 0) {
@ -675,10 +655,8 @@ export function Map3D({
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
}
}
// Mercator fallback: Deck.gl 호버 상태에서 MMSI 참조
if (mmsi == null && projectionRef.current !== 'globe') {
} else {
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
const hovered = hoveredDeckMmsiRef.current;
if (hovered.length > 0) mmsi = hovered[0];
}
@ -695,7 +673,7 @@ export function Map3D({
useFlyTo(
mapRef, projectionRef,
{ selectedMmsi, shipData, mapInitiatedSelectRef, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
);
// Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출

파일 보기

@ -1,10 +1,8 @@
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import { useEffect, useMemo, type MutableRefObject } from 'react';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { type PickingInfo } from '@deck.gl/core';
import type { AisTarget } from '../../../entities/aisTarget/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 { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
@ -68,7 +66,7 @@ export function useDeckLayers(
onSelectMmsi: (mmsi: number | null) => void;
onToggleHighlightMmsi?: (mmsi: number) => void;
ensureMercatorOverlay: () => MapboxOverlay | null;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
projectionRef: MutableRefObject<MapProjectionId>;
},
) {
const {
@ -81,7 +79,7 @@ export function useDeckLayers(
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap,
ensureMercatorOverlay, projectionRef,
} = opts;
const legacyTargets = useMemo(() => {
@ -101,14 +99,6 @@ export function useDeckLayers(
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
}, [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
useEffect(() => {
const map = mapRef.current;
@ -157,16 +147,11 @@ export function useDeckLayers(
onSelectMmsi,
onToggleHighlightMmsi,
onDeckSelectOrHighlight,
alarmTargets,
alarmMmsiMap,
alarmPulseRadius: 8,
alarmPulseHoverRadius: 12,
});
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
mercatorLayersRef.current = normalizedLayers;
const deckProps = {
layers: normalizedLayers,
getTooltip: (info: PickingInfo) => {
@ -218,6 +203,12 @@ export function useDeckLayers(
return;
}
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);
}
}
},
};
@ -248,7 +239,6 @@ export function useDeckLayers(
overlays.pairLines,
overlays.fcLines,
overlays.fleetCircles,
overlays.shipLabels,
settings.showDensity,
settings.showShips,
trackReplayDeckLayers,
@ -262,73 +252,8 @@ export function useDeckLayers(
toFleetMmsiList,
touchDeckHoverState,
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
useEffect(() => {
const map = mapRef.current;

파일 보기

@ -1,5 +1,6 @@
import { useEffect, useRef, type MutableRefObject } from 'react';
import { useEffect, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import { onMapStyleReady } from '../lib/mapCore';
import type { MapProjectionId } from '../types';
export function useFlyTo(
@ -8,62 +9,53 @@ export function useFlyTo(
opts: {
selectedMmsi: number | null;
shipData: { mmsi: number; lon: number; lat: number }[];
/** true일 때 selectedMmsi fly-to 스킵 (지도 클릭 선택 시) */
mapInitiatedSelectRef: MutableRefObject<boolean>;
fleetFocusId: string | number | undefined;
fleetFocusLon: number | undefined;
fleetFocusLat: number | undefined;
fleetFocusZoom: number | undefined;
},
) {
const { selectedMmsi, shipData, mapInitiatedSelectRef, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
// shipData를 ref로 — 의존성에서 제외하여 AIS poll마다 재실행 방지
const shipDataRef = useRef(shipData);
useEffect(() => { shipDataRef.current = shipData; }, [shipData]);
// 패널(좌측 목록)에서 선택 시 해당 선박 위치로 즉시 이동
useEffect(() => {
// 지도 내부 클릭에서 발생한 선택이면 스킵
if (mapInitiatedSelectRef.current) {
mapInitiatedSelectRef.current = false;
return;
}
const map = mapRef.current;
if (!map || selectedMmsi == null) return;
const target = shipDataRef.current.find((t) => t.mmsi === selectedMmsi);
if (!target || !Number.isFinite(target.lon) || !Number.isFinite(target.lat)) return;
try {
const flyOpts = { center: [target.lon, target.lat] as [number, number], duration: 400 };
if (projectionRef.current === 'globe') {
map.flyTo(flyOpts);
} else {
map.easeTo(flyOpts);
}
} catch {
// ignore — style not ready 등
if (!map) return;
if (!selectedMmsi) return;
const t = shipData.find((x) => x.mmsi === selectedMmsi);
if (!t) return;
const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
if (projectionRef.current === 'globe') {
map.flyTo(flyOpts);
} else {
map.easeTo(flyOpts);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMmsi]);
}, [selectedMmsi, shipData]);
// 선단 포커스 이동
useEffect(() => {
const map = mapRef.current;
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
return;
const lon = fleetFocusLon;
const lat = fleetFocusLat;
const zoom = fleetFocusZoom ?? 10;
try {
const flyOpts = { center: [fleetFocusLon, fleetFocusLat] as [number, number], zoom: fleetFocusZoom ?? 10, duration: 500 };
const apply = () => {
const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
if (projectionRef.current === 'globe') {
map.flyTo(flyOpts);
} else {
map.easeTo(flyOpts);
}
} catch {
// ignore
};
if (map.isStyleLoaded()) {
apply();
return;
}
const stop = onMapStyleReady(map, apply);
return () => {
stop();
};
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
}

파일 보기

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import { useCallback, useEffect, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types';
@ -7,6 +7,7 @@ import type { DashSeg, MapProjectionId } from '../types';
import {
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
FLEET_FILL_ML_HL,
FLEET_LINE_ML, FLEET_LINE_ML_HL,
} from '../constants';
import { makeUniqueSorted } from '../lib/setUtils';
@ -21,22 +22,13 @@ import {
} from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
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 제거) */
/** Globe FC lines + fleet circles 오버레이 */
export function useGlobeFcFleetOverlay(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
overlays: MapToggleState;
@ -53,14 +45,8 @@ export function useGlobeFcFleetOverlay(
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 변경)에서 자연스럽게 재시도
// FC lines
useEffect(() => {
const map = mapRef.current;
if (!map) return;
@ -68,11 +54,17 @@ export function useGlobeFcFleetOverlay(
const srcId = 'fc-lines-ml-src';
const layerId = 'fc-lines-ml';
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
if (projection !== 'globe') {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
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;
}
@ -81,9 +73,7 @@ export function useGlobeFcFleetOverlay(
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 */ }
remove();
return;
}
@ -107,12 +97,12 @@ export function useGlobeFcFleetOverlay(
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에서 재시도
} catch (e) {
console.warn('FC lines source setup failed:', e);
return;
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
@ -121,46 +111,74 @@ export function useGlobeFcFleetOverlay(
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,
'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,
} as unknown as LayerSpecification,
undefined,
);
} catch {
return; // 다음 poll에서 재시도
} catch (e) {
console.warn('FC lines layer add failed:', e);
}
reorderGlobeFeatureLayers();
} else {
guardedSetVisibility(map, layerId, 'visible');
}
paintStateRef.current();
reorderGlobeFeatureLayers();
kickRepaint(map);
};
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
return () => {
stop();
};
}, [
projection,
overlays.fcLines,
fcLinks,
hoveredPairMmsiList,
hoveredFleetMmsiList,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
// ── Fleet circles 데이터 effect (stroke only — fill 제거) ──
// Fleet circles
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fleet-circles-ml-src';
const fillSrcId = 'fleet-circles-ml-fill-src';
const layerId = 'fleet-circles-ml';
const fillLayerId = 'fleet-circles-ml-fill';
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
guardedSetVisibility(map, fillLayerId, 'none');
};
const ensure = () => {
if (projection !== 'globe' || (fleetCircles?.length ?? 0) === 0) {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
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',
@ -176,21 +194,47 @@ export function useGlobeFcFleetOverlay(
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 {
return; // 다음 poll에서 재시도
} catch (e) {
console.warn('Fleet circles source setup failed:', e);
return;
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
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(
{
@ -199,34 +243,66 @@ export function useGlobeFcFleetOverlay(
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,
'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 {
return; // 다음 poll에서 재시도
} catch (e) {
console.warn('Fleet circles layer add failed:', e);
}
reorderGlobeFeatureLayers();
} else {
guardedSetVisibility(map, layerId, 'visible');
}
paintStateRef.current();
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, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
return () => {
stop();
};
}, [
projection,
overlays.fleetCircles,
fleetCircles,
hoveredFleetOwnerKeyList,
hoveredFleetMmsiList,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
// ── FC + Fleet paint state update (가시성 + 하이라이트 통합) ──
// FC + Fleet paint state updates
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const updateFcFleetPaintStates = useCallback(() => {
if (projection !== 'globe') return;
if (projection !== 'globe' || projectionBusyRef.current) return;
const map = mapRef.current;
if (!map) return;
if (!map || !map.isStyleLoaded()) return;
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
@ -243,29 +319,16 @@ export function useGlobeFcFleetOverlay(
? (['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,
);
}
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
@ -273,85 +336,21 @@ export function useGlobeFcFleetOverlay(
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,
);
}
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]);
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);
if (!map) return;
const stop = onMapStyleReady(map, updateFcFleetPaintStates);
updateFcFleetPaintStates();
return () => {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
stop();
};
}, [hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection]);
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]);
}

파일 보기

@ -14,10 +14,6 @@ import {
} from '../lib/tooltips';
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
// setData() 후 타일 재빌드 중 queryRenderedFeatures가 일시적으로 빈 배열을 반환.
// 즉시 clear 대신 딜레이를 두어 깜박임 방지.
const TOOLTIP_CLEAR_DELAY_MS = 150;
export function useGlobeInteraction(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
@ -61,7 +57,7 @@ export function useGlobeInteraction(
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
const map = mapRef.current;
if (!map) return;
if (!map || !map.isStyleLoaded()) return;
if (!mapTooltipRef.current) {
mapTooltipRef.current = new maplibregl.Popup({
closeButton: false,
@ -78,12 +74,6 @@ export function useGlobeInteraction(
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(
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
if (!feature) return null;
@ -146,14 +136,17 @@ export function useGlobeInteraction(
[legacyHits, shipByMmsi],
);
useEffect(() => {
buildTooltipRef.current = buildGlobeFeatureTooltip;
}, [buildGlobeFeatureTooltip]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const clearDeckGlobeHoverState = () => {
clearDeckHoverMmsi();
clearDeckHoverPairs();
setHoveredZoneId((prev) => (prev === null ? prev : null));
clearMapFleetHoverState();
};
const resetGlobeHoverStates = () => {
clearDeckHoverMmsi();
clearDeckHoverPairs();
@ -162,52 +155,36 @@ export function useGlobeInteraction(
};
const normalizeMmsiList = (value: unknown): number[] => {
let arr = value;
// MapLibre는 GeoJSON 배열 프로퍼티를 JSON 문자열로 반환할 수 있음
if (typeof arr === 'string') {
try { arr = JSON.parse(arr); } catch { return []; }
}
if (!Array.isArray(arr)) return [];
if (!Array.isArray(value)) return [];
const out: number[] = [];
for (const n of arr) {
for (const n of value) {
const m = toIntMmsi(n);
if (m != null) out.push(m);
}
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) => {
if (projection !== 'globe') {
cancelClear();
clearGlobeTooltip();
resetGlobeHoverStates();
return;
}
if (projectionBusyRef.current) {
return; // 전환 중에는 기존 상태 유지 (clear하면 깜박임)
resetGlobeHoverStates();
clearGlobeTooltip();
return;
}
if (!map.isStyleLoaded()) {
clearDeckGlobeHoverState();
clearGlobeTooltip();
return;
}
let candidateLayerIds: string[] = [];
try {
candidateLayerIds = [
'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',
'fleet-circles-ml',
'pair-range-ml',
@ -218,18 +195,14 @@ export function useGlobeInteraction(
}
if (candidateLayerIds.length === 0) {
scheduleClear();
resetGlobeHoverStates();
clearGlobeTooltip();
return;
}
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
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],
];
rendered = map.queryRenderedFeatures(bbox, { layers: candidateLayerIds }) as unknown as Array<{
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
properties?: Record<string, unknown> | null;
layer?: { id?: string };
}>;
@ -239,7 +212,6 @@ export function useGlobeInteraction(
const priority = [
'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',
'fleet-circles-ml',
'zones-fill', 'zones-line', 'zones-label',
@ -250,23 +222,18 @@ export function useGlobeInteraction(
| undefined;
if (!first) {
// 피처 없음 — 타일 재빌드 중 일시적 누락일 수 있으므로 지연 clear
scheduleClear();
resetGlobeHoverStates();
clearGlobeTooltip();
return;
}
// 피처 발견 — 지연 clear 취소
cancelClear();
const layerId = first.layer?.id;
const props = first.properties || {};
const isShipLayer =
layerId === 'ships-globe' ||
layerId === 'ships-globe-lite' ||
layerId === 'ships-globe-halo' ||
layerId === 'ships-globe-outline' ||
layerId === 'ships-globe-alarm-pulse' ||
layerId === 'ships-globe-alarm-badge';
layerId === 'ships-globe-outline';
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
const isFcLayer = layerId === 'fc-lines-ml';
const isFleetLayer = layerId === 'fleet-circles-ml';
@ -310,7 +277,7 @@ export function useGlobeInteraction(
resetGlobeHoverStates();
}
const tooltip = buildTooltipRef.current(first);
const tooltip = buildGlobeFeatureTooltip(first);
if (!tooltip) {
if (!isZoneLayer) {
resetGlobeHoverStates();
@ -328,7 +295,6 @@ export function useGlobeInteraction(
};
const onMouseOut = () => {
cancelClear();
resetGlobeHoverStates();
clearGlobeTooltip();
};
@ -337,14 +303,13 @@ export function useGlobeInteraction(
map.on('mouseout', onMouseOut);
return () => {
cancelClear();
map.off('mousemove', onMouseMove);
map.off('mouseout', onMouseOut);
// cleanup에서 tooltip 제거하지 않음 — 의존성 변경(AIS poll 등)으로 effect가
// 재실행될 때 tooltip이 사라지는 문제 방지. tooltip은 mousemove/mouseout 이벤트가 처리.
clearGlobeTooltip();
};
}, [
projection,
buildGlobeFeatureTooltip,
clearGlobeTooltip,
clearMapFleetHoverState,
clearDeckHoverPairs,
@ -354,9 +319,4 @@ export function useGlobeInteraction(
setMapFleetHoverState,
setGlobeTooltip,
]);
// 컴포넌트 unmount 시에만 tooltip 제거
useEffect(() => {
return () => { clearGlobeTooltip(); };
}, [clearGlobeTooltip]);
}

파일 보기

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import { useCallback, useEffect, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { PairLink } from '../../../features/legacyDashboard/model/types';
@ -14,22 +14,12 @@ 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;
import { guardedSetVisibility } from '../lib/layerHelpers';
/** Globe pair lines + pair range 오버레이 */
export function useGlobePairOverlay(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
overlays: MapToggleState;
@ -40,14 +30,8 @@ export function useGlobePairOverlay(
},
) {
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 변경)에서 자연스럽게 재시도
// Pair lines
useEffect(() => {
const map = mapRef.current;
if (!map) return;
@ -55,17 +39,16 @@ export function useGlobePairOverlay(
const srcId = 'pair-lines-ml-src';
const layerId = 'pair-lines-ml';
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
};
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 */ }
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;
}
@ -89,12 +72,12 @@ export function useGlobePairOverlay(
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에서 재시도
} catch (e) {
console.warn('Pair lines source setup failed:', e);
return;
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
@ -103,31 +86,44 @@ export function useGlobePairOverlay(
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,
'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,
} as unknown as LayerSpecification,
undefined,
);
} catch {
return; // 다음 poll에서 재시도
} catch (e) {
console.warn('Pair lines layer add failed:', e);
}
reorderGlobeFeatureLayers();
} else {
guardedSetVisibility(map, layerId, 'visible');
}
// 즉시 올바른 paint state 적용 — 타이밍 간극으로 opacity:0 고착 방지
paintStateRef.current();
reorderGlobeFeatureLayers();
kickRepaint(map);
};
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
return () => {
stop();
};
}, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
// ── Pair range 데이터 effect ──
// Pair range
useEffect(() => {
const map = mapRef.current;
if (!map) return;
@ -135,11 +131,16 @@ export function useGlobePairOverlay(
const srcId = 'pair-range-ml-src';
const layerId = 'pair-range-ml';
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
if (projection !== 'globe') {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
const pairHoverActive = hoveredPairMmsiList.length >= 2;
if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) {
remove();
return;
}
@ -156,9 +157,7 @@ export function useGlobePairOverlay(
});
}
if (ranges.length === 0) {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
remove();
return;
}
@ -176,6 +175,7 @@ export function useGlobePairOverlay(
aMmsi: c.aMmsi,
bMmsi: c.bMmsi,
distanceNm: c.distanceNm,
highlighted: 0,
},
};
}),
@ -185,159 +185,100 @@ export function useGlobePairOverlay(
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에서 재시도
} catch (e) {
console.warn('Pair range source setup failed:', e);
return;
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: {
'line-cap': 'round',
'line-join': 'round',
visibility: 'visible',
},
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,
'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 {
return; // 다음 poll에서 재시도
} catch (e) {
console.warn('Pair range layer add failed:', e);
}
reorderGlobeFeatureLayers();
} else {
guardedSetVisibility(map, layerId, 'visible');
}
paintStateRef.current();
kickRepaint(map);
};
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
return () => {
stop();
};
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
// ── Pair paint state update (가시성 + 하이라이트 통합) ──
// setLayoutProperty(visibility) 대신 setPaintProperty(line-opacity)로 가시성 제어
// → style._changed 미트리거 → alarm badge symbol placement 재계산 방지
// Pair paint state updates
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const updatePairPaintStates = useCallback(() => {
if (projection !== 'globe') return;
if (projection !== 'globe' || projectionBusyRef.current) return;
const map = mapRef.current;
if (!map) return;
if (!map || !map.isStyleLoaded()) return;
const active = hoveredPairMmsiList.length >= 2;
const pairHighlightExpr = active
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
? 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,
);
}
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
}
// ── 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,
);
}
map.setPaintProperty(
'pair-range-ml', 'line-color',
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
);
map.setPaintProperty(
'pair-range-ml', 'line-width',
['case', pairHighlightExpr, 1.6, 1.0] as never,
);
}
} catch {
// ignore
}
}, [projection, hoveredPairMmsiList]);
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);
if (!map) return;
const stop = onMapStyleReady(map, updatePairPaintStates);
updatePairPaintStates();
return () => {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
stop();
};
}, [hoveredPairMmsiList, projection]);
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
}

파일 보기

@ -9,7 +9,7 @@ import {
DEG2RAD,
} from '../constants';
import { isFiniteNumber } from '../lib/setUtils';
import { GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR } from '../lib/mlExpressions';
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils';
import { ensureFallbackShipImage } from '../lib/globeShipIcon';
@ -19,7 +19,7 @@ import { guardedSetVisibility } from '../lib/layerHelpers';
/** Globe 호버 오버레이 + 클릭 선택 */
export function useGlobeShipHover(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
projection: MapProjectionId;
@ -62,6 +62,9 @@ export function useGlobeShipHover(
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
hideHover();
return;
@ -71,9 +74,7 @@ export function useGlobeShipHover(
epochRef.current = mapSyncEpoch;
}
try {
ensureFallbackShipImage(map, imgId);
} catch { /* ignore */ }
ensureFallbackShipImage(map, imgId);
if (!map.hasImage(imgId)) {
return;
}
@ -165,7 +166,7 @@ export function useGlobeShipHover(
] as never,
},
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
@ -180,7 +181,7 @@ export function useGlobeShipHover(
console.warn('Ship hover halo layer add failed:', e);
}
} else {
guardedSetVisibility(map, haloId, 'visible');
map.setLayoutProperty(haloId, 'visibility', 'visible');
}
if (!map.getLayer(outlineId)) {
@ -191,7 +192,7 @@ export function useGlobeShipHover(
type: 'circle',
source: srcId,
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-color': [
'case',
@ -221,7 +222,7 @@ export function useGlobeShipHover(
console.warn('Ship hover outline layer add failed:', e);
}
} else {
guardedSetVisibility(map, outlineId, 'visible');
map.setLayoutProperty(outlineId, 'visibility', 'visible');
}
if (!map.getLayer(symbolId)) {
@ -266,7 +267,7 @@ export function useGlobeShipHover(
console.warn('Ship hover symbol layer add failed:', e);
}
} else {
guardedSetVisibility(map, symbolId, 'visible');
map.setLayoutProperty(symbolId, 'visibility', 'visible');
}
if (needReorder) {
@ -300,20 +301,15 @@ export function useGlobeShipHover(
const symbolLiteId = 'ships-globe-lite';
const haloId = 'ships-globe-halo';
const outlineId = 'ships-globe-outline';
const clickedRadiusDeg2 = Math.pow(0.12, 2);
const clickedRadiusDeg2 = Math.pow(0.08, 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));
const layerIds = [symbolId, symbolLiteId, haloId, outlineId].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[];
feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[];
} catch {
feats = [];
}

파일 보기

@ -1,19 +1,16 @@
import { useEffect, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import type { LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { Map3DSettings, MapProjectionId } from '../types';
import { onMapStyleReady } from '../lib/mapCore';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
/**
* Mercator MapLibre .
* Deck.gl TextLayer (deckLayerFactories.ts) .
*/
/** Mercator 모드 선명 라벨 (허가 선박 + 선택/하이라이트) */
export function useGlobeShipLabels(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
projectionBusyRef: MutableRefObject<boolean>,
opts: {
projection: MapProjectionId;
settings: Map3DSettings;
@ -23,10 +20,12 @@ export function useGlobeShipLabels(
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
selectedMmsi: number | null;
mapSyncEpoch: number;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
},
) {
const { mapSyncEpoch } = opts;
const {
projection, settings, shipData, shipHighlightSet,
overlays, legacyHits, selectedMmsi, mapSyncEpoch,
} = opts;
useEffect(() => {
const map = mapRef.current;
@ -49,16 +48,117 @@ export function useGlobeShipLabels(
};
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 (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
remove();
if (projection !== 'mercator' || !settings.showShips) {
remove();
return;
}
const visibility = overlays.shipLabels ? 'visible' : 'none';
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
for (const t of shipData) {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const isTarget = !!legacy;
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
const isPinnedHighlight = shipHighlightSet.has(t.mmsi);
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim();
if (!labelName) continue;
features.push({
type: 'Feature',
id: `ship-label-${t.mmsi}`,
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
labelName,
selected: isSelected ? 1 : 0,
highlighted: isPinnedHighlight ? 1 : 0,
permitted: isTarget ? 1 : 0,
},
});
}
const fc: GeoJSON.FeatureCollection<GeoJSON.Point> = { type: 'FeatureCollection', features };
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Ship label source setup failed:', e);
return;
}
const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[];
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'symbol',
source: srcId,
minzoom: 7,
filter: filter as never,
layout: {
visibility,
'symbol-placement': 'point',
'text-field': ['get', 'labelName'] as never,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
'text-anchor': 'top',
'text-offset': [0, 1.1],
'text-padding': 2,
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': [
'case',
['==', ['get', 'selected'], 1],
'rgba(14,234,255,0.95)',
['==', ['get', 'highlighted'], 1],
'rgba(245,158,11,0.95)',
'rgba(226,232,240,0.92)',
] as never,
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2,
'text-halo-blur': 0.8,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Ship label layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', visibility);
} catch {
// ignore
}
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [mapSyncEpoch]);
}, [
projection,
settings.showShips,
overlays.shipLabels,
shipData,
legacyHits,
selectedMmsi,
shipHighlightSet,
mapSyncEpoch,
]);
}

파일 보기

@ -3,7 +3,6 @@ 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 {
@ -27,14 +26,7 @@ import {
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) */
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label) */
export function useGlobeShipLayers(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
@ -49,19 +41,14 @@ export function useGlobeShipLayers(
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로 캐싱
@ -70,9 +57,7 @@ export function useGlobeShipLayers(
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 labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
@ -85,13 +70,16 @@ export function useGlobeShipLayers(
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);
const selected = t.mmsi === selectedMmsi;
const highlighted = isBaseHighlightedMmsi(t.mmsi);
const selectedScale = selected ? 1.08 : 1;
const highlightScale = highlighted ? 1.06 : 1;
const iconScale = selected ? selectedScale : highlightScale;
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
return {
type: 'Feature' as const,
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
@ -108,50 +96,21 @@ export function useGlobeShipLayers(
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3,
iconSize7,
iconSize10,
iconSize14,
iconSize18,
iconSize3: iconSize3 * iconScale,
iconSize7: iconSize7 * iconScale,
iconSize10: iconSize10 * iconScale,
iconSize14: iconSize14 * iconScale,
iconSize18: iconSize18 * iconScale,
sizeScale,
selected: selected ? 1 : 0,
highlighted: highlighted ? 1 : 0,
permitted: legacy ? 1 : 0,
code: legacy?.shipCode || '',
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]);
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
// Ships in globe mode
useEffect(() => {
@ -161,19 +120,16 @@ export function useGlobeShipLayers(
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]) {
for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) {
guardedSetVisibility(map, id, 'none');
}
};
@ -202,7 +158,7 @@ export function useGlobeShipLayers(
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
if (changed) {
for (const id of [haloId, outlineId, pulseId, symbolLiteId, symbolId, badgeId]) {
for (const id of [haloId, outlineId, symbolLiteId, symbolId]) {
guardedSetVisibility(map, id, visibility);
}
if (projection === 'globe') kickRepaint(map);
@ -229,52 +185,32 @@ export function useGlobeShipLayers(
}
// 사전 계산된 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;
if (existing) existing.setData(geojson);
else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Ship source setup failed:', e);
return;
}
// 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],
['==', ['to-number', ['get', 'selected'], 0], 1],
['==', ['to-number', ['get', 'highlighted'], 0], 1],
] as unknown as unknown[];
const nonPriorityFilter = [
'all',
['==', ['to-number', ['get', 'permitted'], 0], 0],
['==', ['to-number', ['get', 'alarmed'], 0], 0],
['==', ['to-number', ['get', 'selected'], 0], 0],
['==', ['to-number', ['get', 'highlighted'], 0], 0],
] as unknown as unknown[];
if (!map.getLayer(haloId)) {
needReorder = true;
try {
map.addLayer(
{
@ -285,9 +221,11 @@ export function useGlobeShipLayers(
visibility,
'circle-sort-key': [
'case',
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
['==', ['get', 'permitted'], 1], 110,
['==', ['get', 'alarmed'], 1], 22,
['==', ['get', 'selected'], 1], 60,
['==', ['get', 'highlighted'], 1], 55,
20,
] as never,
},
@ -296,8 +234,8 @@ export function useGlobeShipLayers(
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'circle-opacity': [
'case',
['==', ['feature-state', 'selected'], 1], 0.38,
['==', ['feature-state', 'highlighted'], 1], 0.34,
['==', ['get', 'selected'], 1], 0.38,
['==', ['get', 'highlighted'], 1], 0.34,
0.16,
] as never,
},
@ -310,7 +248,6 @@ export function useGlobeShipLayers(
}
if (!map.getLayer(outlineId)) {
needReorder = true;
try {
map.addLayer(
{
@ -322,15 +259,15 @@ export function useGlobeShipLayers(
'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', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
] as never,
'circle-stroke-width': [
'case',
['==', ['feature-state', 'selected'], 1], 3.4,
['==', ['feature-state', 'highlighted'], 1], 2.7,
['==', ['get', 'selected'], 1], 3.4,
['==', ['get', 'highlighted'], 1], 2.7,
['==', ['get', 'permitted'], 1], 1.8,
0.7,
] as never,
@ -340,9 +277,11 @@ export function useGlobeShipLayers(
visibility,
'circle-sort-key': [
'case',
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
['==', ['get', 'permitted'], 1], 120,
['==', ['get', 'alarmed'], 1], 32,
['==', ['get', 'selected'], 1], 70,
['==', ['get', 'highlighted'], 1], 65,
30,
] as never,
},
@ -354,34 +293,7 @@ export function useGlobeShipLayers(
}
}
// 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(
{
@ -451,7 +363,6 @@ export function useGlobeShipLayers(
}
if (!map.getLayer(symbolId)) {
needReorder = true;
try {
map.addLayer(
{
@ -463,9 +374,11 @@ export function useGlobeShipLayers(
visibility,
'symbol-sort-key': [
'case',
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
['==', ['get', 'permitted'], 1], 130,
['==', ['get', 'alarmed'], 1], 47,
['==', ['get', 'selected'], 1], 80,
['==', ['get', 'highlighted'], 1], 75,
45,
] as never,
'icon-image': [
@ -497,8 +410,8 @@ export function useGlobeShipLayers(
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': [
'case',
['==', ['feature-state', 'selected'], 1], 1,
['==', ['feature-state', 'highlighted'], 1], 0.95,
['==', ['get', 'selected'], 1], 1,
['==', ['get', 'highlighted'], 1], 0.95,
['==', ['get', 'permitted'], 1], 0.93,
0.9,
] as never,
@ -514,11 +427,15 @@ export function useGlobeShipLayers(
const labelFilter = [
'all',
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
['==', ['get', 'permitted'], 1],
[
'any',
['==', ['get', 'permitted'], 1],
['==', ['get', 'selected'], 1],
['==', ['get', 'highlighted'], 1],
],
] as unknown as unknown[];
if (!map.getLayer(labelId)) {
needReorder = true;
try {
map.addLayer(
{
@ -542,8 +459,8 @@ export function useGlobeShipLayers(
paint: {
'text-color': [
'case',
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
'rgba(226,232,240,0.92)',
] as never,
'text-halo-color': 'rgba(2,6,23,0.85)',
@ -558,45 +475,9 @@ export function useGlobeShipLayers(
}
}
// 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) {
if (projection === 'globe') {
reorderGlobeFeatureLayers();
}
kickRepaint(map);
@ -611,83 +492,10 @@ export function useGlobeShipLayers(
settings.showShips,
overlays.shipLabels,
globeShipGeoJson,
alarmGeoJson,
selectedMmsi,
isBaseHighlightedMmsi,
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]);
}

파일 보기

@ -2,7 +2,6 @@ import 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 { useGlobeShipLabels } from './useGlobeShipLabels';
@ -32,7 +31,6 @@ export function useGlobeShips(
isBaseHighlightedMmsi: (mmsi: number) => boolean;
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
onGlobeShipsReady?: (ready: boolean) => void;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
},
) {
// Mercator 모드 선명 라벨
@ -45,7 +43,6 @@ export function useGlobeShips(
legacyHits: opts.legacyHits,
selectedMmsi: opts.selectedMmsi,
mapSyncEpoch: opts.mapSyncEpoch,
alarmMmsiMap: opts.alarmMmsiMap,
});
// Globe 모드 선박 아이콘 레이어
@ -59,7 +56,6 @@ export function useGlobeShips(
isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi,
mapSyncEpoch: opts.mapSyncEpoch,
onGlobeShipsReady: opts.onGlobeShipsReady,
alarmMmsiMap: opts.alarmMmsiMap,
});
// Globe 호버 오버레이 + 클릭 선택

파일 보기

@ -108,9 +108,7 @@ export function useNativeMapLayers(
// 2. 데이터가 있는 source가 하나도 없으면 종료
const hasData = cfg.sources.some((s) => s.data != null);
if (!hasData) return;
// isStyleLoaded() 가드 제거 — AIS poll의 setData()로 인해
// 일시적으로 false가 되어 데이터 업데이트가 스킵되는 문제 방지.
// 실패 시 try/catch가 처리하고, 다음 deps 변경 시 자연 재시도.
if (!map.isStyleLoaded()) return;
try {
// 3. Source 생성/업데이트

파일 보기

@ -80,67 +80,60 @@ export function useProjectionToggle(
};
}, [clearProjectionBusyTimer, endProjectionLoading]);
const reorderRafRef = useRef(0);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const reorderGlobeFeatureLayers = useCallback(() => {
if (!mapRef.current || projectionRef.current !== 'globe') return;
const map = mapRef.current;
if (!map || projectionRef.current !== 'globe') return;
if (projectionBusyRef.current) return;
if (reorderRafRef.current) return; // 이미 스케줄됨 — 프레임당 1회 실행
reorderRafRef.current = requestAnimationFrame(() => {
reorderRafRef.current = 0;
const m = mapRef.current;
if (!m || !m.isStyleLoaded()) return;
if (!map.isStyleLoaded()) return;
const ordering = [
'subcables-hitarea',
'subcables-casing',
'subcables-line',
'subcables-glow',
'subcables-points',
'subcables-label',
'vessel-track-line',
'vessel-track-line-hitarea',
'vessel-track-arrow',
'vessel-track-pts',
'vessel-track-pts-highlight',
'track-replay-globe-path',
'track-replay-globe-points',
'track-replay-globe-virtual-ship',
'track-replay-globe-virtual-label',
'zones-fill',
'zones-line',
'zones-label',
'fleet-circles-ml',
'fc-lines-ml',
'pair-range-ml',
'pair-lines-ml',
'predict-vectors-outline',
'predict-vectors',
'predict-vectors-hl-outline',
'predict-vectors-hl',
'ships-globe-halo',
'ships-globe-outline',
'ships-globe-alarm-pulse',
'ships-globe-lite',
'ships-globe',
'ships-globe-label',
'ships-globe-alarm-badge',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
];
const ordering = [
'subcables-hitarea',
'subcables-casing',
'subcables-line',
'subcables-glow',
'subcables-points',
'subcables-label',
'vessel-track-line',
'vessel-track-line-hitarea',
'vessel-track-arrow',
'vessel-track-pts',
'vessel-track-pts-highlight',
'track-replay-globe-path',
'track-replay-globe-points',
'track-replay-globe-virtual-ship',
'track-replay-globe-virtual-label',
'zones-fill',
'zones-line',
'zones-label',
'predict-vectors-outline',
'predict-vectors',
'predict-vectors-hl-outline',
'predict-vectors-hl',
'ships-globe-halo',
'ships-globe-outline',
'ships-globe-lite',
'ships-globe',
'ships-globe-label',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
'pair-lines-ml',
'fc-lines-ml',
'pair-range-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml',
];
for (const layerId of ordering) {
try {
if (m.getLayer(layerId)) m.moveLayer(layerId);
} catch {
// ignore
}
for (const layerId of ordering) {
try {
if (map.getLayer(layerId)) map.moveLayer(layerId);
} catch {
// ignore
}
}
kickRepaint(m);
});
kickRepaint(map);
}, []);
// Projection toggle (mercator <-> globe)
@ -222,13 +215,6 @@ export function useProjectionToggle(
quietMercatorOverlays();
} else {
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 {

파일 보기

@ -13,11 +13,10 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { guardedSetVisibility } from '../lib/layerHelpers';
/** Globe tessellation vertex 65535 .
* ( 2100+ vertex) globe에서 ~33x로
* ring당 maxPts개로 . centroid에는 .
* 4 × 300pts × 33x 39,600 vertices (< 65535 limit). */
* ( 2100+ vertex) globe에서 70,000+
* ring당 maxPts개로 . centroid에는 . */
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
const MAX_PTS = 300;
const MAX_PTS = 60;
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
if (ring.length <= MAX_PTS) return ring;
const step = Math.ceil(ring.length / MAX_PTS);

파일 보기

@ -1,9 +1,8 @@
import { HexagonLayer } from '@deck.gl/aggregation-layers';
import { IconLayer, LineLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { 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';
@ -81,10 +80,6 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect
showShips: boolean;
selectedMmsi: number | null;
shipHighlightSet: Set<number>;
alarmTargets?: AisTarget[];
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
alarmPulseRadius?: number;
alarmPulseHoverRadius?: number;
}
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
@ -123,11 +118,10 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
/* ─ 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(
layers.push(
new ScatterplotLayer<PairRangeCircle>({
id: 'pair-range',
data: validRanges,
data: ctx.pairRanges,
pickable: true,
billboard: false,
parameters: overlayParams,
@ -137,7 +131,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
getRadius: (d) => d.radiusNm * 1852,
radiusMinPixels: 10,
lineWidthUnits: 'pixels',
getLineWidth: () => 1.8,
getLineWidth: () => 1,
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
getPosition: (d) => d.center,
onHover: (info) => {
@ -174,7 +168,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
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),
getWidth: (d) => (d.warn ? 2.2 : 1.4),
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
@ -210,7 +204,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
getWidth: () => 2.2,
getWidth: () => 1.3,
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
@ -251,7 +245,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
lineWidthUnits: 'pixels',
getLineWidth: () => 2.0,
getLineWidth: () => 1.1,
getLineColor: () => FLEET_RANGE_LINE_DECK,
getPosition: (d) => d.center,
onHover: (info) => {
@ -409,17 +403,17 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
/* ─ 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 }));
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
}
if (ctx.pairLinksInteractive.length > 0) {
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 4.5, widthUnits: 'pixels' }));
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
}
if (ctx.fcLinesInteractive.length > 0) {
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 3.2, widthUnits: 'pixels' }));
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
}
if (ctx.fleetCirclesInteractive.length > 0) {
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
}
/* ─ legacy overlay (highlight/selected) ─ */
@ -432,103 +426,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
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;
}

파일 보기

@ -49,6 +49,7 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
try {
map.off('style.load', runOnce);
map.off('styledata', runOnce);
map.off('idle', runOnce);
} catch {
// ignore
}
@ -56,6 +57,7 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
map.on('style.load', runOnce);
map.on('styledata', runOnce);
map.on('idle', runOnce);
return () => {
if (fired) return;
@ -64,6 +66,7 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
if (!map) return;
map.off('style.load', runOnce);
map.off('styledata', runOnce);
map.off('idle', runOnce);
} catch {
// ignore
}

파일 보기

@ -41,46 +41,28 @@ export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
return ['any', ...clauses] as unknown[];
}
// ── Globe circle radius zoom stops ──
// MapLibre 제약: expression 당 zoom-based interpolate는 1개만 허용
// → 하나의 interpolate 안에서 각 stop 값을 case로 분기
const ZOOM_LEVELS = [3, 7, 10, 14, 18] as const;
const BASE_VALUES = [4, 6, 8, 12, 32] as const;
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;
function buildStopsWithCase(getter: (key: string) => unknown[]) {
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
stops.push(ZOOM_LEVELS[i]);
stops.push([
'case',
['==', getter('selected'), 1], SELECTED_VALUES[i],
['==', getter('highlighted'), 1], HIGHLIGHTED_VALUES[i],
BASE_VALUES[i],
]);
}
return stops;
}
/** feature-state 기반 — 메인 선박 레이어 (halo, outline) */
export function makeGlobeCircleRadiusExpr() {
return buildStopsWithCase((key) => ['feature-state', key]);
}
const base3 = 4;
const base7 = 6;
const base10 = 8;
const base14 = 12;
const base18 = 32;
/** 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;
return [
'interpolate',
['linear'],
['zoom'],
3,
['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3],
7,
['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7],
10,
['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],
18,
['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18],
];
}
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 { ZonesGeoJson } from '../../entities/zone/api/useZones';
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
import type { MapStyleSettings } from '../../features/mapSettings/types';
export type Map3DSettings = {
@ -69,8 +69,6 @@ export interface Map3DProps {
onRequestTrack?: (mmsi: number, minutes: number) => void;
onCloseTrackMenu?: () => void;
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
}
export type DashSeg = {

파일 보기

@ -1,133 +1,64 @@
import { useState } from "react";
interface Props {
type Props = {
total: number;
fishing: number;
transit: number;
pairLinks: number;
alarms: number;
pollingStatus: "idle" | "loading" | "ready" | "error";
lastFetchMinutes: number | null;
clock: string;
adminMode?: boolean;
onLogoClick?: () => void;
userName?: string;
onLogout?: () => void;
theme?: "dark" | "light";
onToggleTheme?: () => void;
isSidebarOpen?: boolean;
onMenuToggle?: () => void;
}
};
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout }: Props) {
const statusColor =
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
return (
<>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-xs text-wing-text">{total}</b>
<div className="topbar">
<div className="logo" onClick={onLogoClick} style={{ cursor: onLogoClick ? "pointer" : undefined }} title={adminMode ? "ADMIN" : undefined}>
🛰 <span>WING</span> · {adminMode ? <span style={{ fontSize: 10, color: "#F59E0B" }}>(ADMIN)</span> : null}
</div>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-wing-success">{fishing}</b>
</div>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-wing-accent">{transit}</b>
</div>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-wing-warning">{pairLinks}</b>
</div>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-wing-danger">{alarms}</b>
</div>
</>
);
}
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 className="stats">
<div className="stat">
DATA <b style={{ color: "#22C55E" }}>API</b>
</div>
{/* 데스크톱: 인라인 통계 */}
<div className="ml-auto hidden items-center gap-3.5 md:flex">
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
<div className="stat">
POLL{" "}
<b style={{ color: statusColor }}>
{pollingStatus.toUpperCase()}
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
</b>
</div>
{/* 항상 표시: 시계 + 테마 + 사용자 */}
<div className="ml-auto flex shrink-0 items-center gap-2.5 md:ml-2.5">
<span className="whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</span>
{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"}
<div className="stat">
<b>{total}</b>
</div>
<div className="stat">
<b style={{ color: "#22C55E" }}>{fishing}</b>
</div>
<div className="stat">
<b style={{ color: "#3B82F6" }}>{transit}</b>
</div>
<div className="stat">
<b style={{ color: "#F59E0B" }}>{pairLinks}</b>
</div>
<div className="stat">
<b style={{ color: "#EF4444" }}>{alarms}</b>
</div>
</div>
<div className="time">{clock}</div>
{userName && (
<div className="topbar-user">
<span className="topbar-user__name">{userName}</span>
{onLogout && (
<button className="topbar-user__logout" onClick={onLogout}>
</button>
)}
{userName && (
<div className="flex shrink-0 items-center gap-2">
<span className="hidden whitespace-nowrap text-[10px] font-medium text-wing-text sm:inline">{userName}</span>
{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>
)}
</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,5 +1,3 @@
import { useMemo, useState } from "react";
import { TextInput } from "@wing/ui";
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import type { MouseEvent } from "react";
@ -14,26 +12,6 @@ type Props = {
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({
vessels,
selectedMmsi,
@ -43,8 +21,6 @@ export function VesselList({
onHoverMmsi,
onClearHover,
}: Props) {
const [searchQuery, setSearchQuery] = useState("");
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
onToggleHighlightMmsi(mmsi);
@ -53,23 +29,17 @@ export function VesselList({
onSelectMmsi(mmsi);
};
const sorted = useMemo(() => {
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));
}, [vessels, searchQuery]);
function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x);
}
const sorted = vessels
.slice()
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
.slice(0, 80);
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) => {
const meta = VESSEL_TYPES[v.shipCode];
const primarySegs = meta.speedProfile.filter((s) => s.primary);
@ -106,7 +76,6 @@ export function VesselList({
);
})}
{sorted.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>( )</div> : null}
</div>
</div>
);
}

파일 보기

@ -1,4 +1,3 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite";
@ -16,7 +15,7 @@ export default defineConfig(({ mode }) => {
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
return {
plugins: [tailwindcss(), react()],
plugins: [react()],
resolve: {
alias: {
// deck.gl (via loaders.gl) contains a few Node-only helper modules.

646
package-lock.json generated
파일 보기

@ -6,8 +6,7 @@
"": {
"name": "wing-fleet-dashboard",
"workspaces": [
"apps/*",
"packages/*"
"apps/*"
],
"devDependencies": {
"xlsx": "^0.18.5"
@ -39,7 +38,6 @@
"@maptiler/weather": "^3.1.1",
"@react-oauth/google": "^0.13.4",
"@stomp/stompjs": "^7.2.1",
"@wing/ui": "*",
"maplibre-gl": "^5.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@ -48,7 +46,6 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -57,7 +54,6 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
@ -2477,278 +2473,6 @@
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
"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": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz",
@ -3246,10 +2970,6 @@
"resolved": "apps/api",
"link": true
},
"node_modules/@wing/ui": {
"resolved": "packages/ui",
"link": true
},
"node_modules/@wing/web": {
"resolved": "apps/web",
"link": true
@ -3595,15 +3315,6 @@
"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": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
@ -3776,16 +3487,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": {
"version": "1.5.7",
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
@ -3805,20 +3506,6 @@
"dev": true,
"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": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@ -4487,13 +4174,6 @@
"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": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz",
@ -4670,16 +4350,6 @@
"dev": true,
"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": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
@ -4878,267 +4548,6 @@
],
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -5194,16 +4603,6 @@
"integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==",
"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": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz",
@ -6070,37 +5469,6 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
@ -6599,18 +5967,6 @@
"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",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"workspaces": ["apps/*"],
"scripts": {
"dev": "npm run dev:web & npm run dev:api",
"dev:web": "npm -w @wing/web run dev",

파일 보기

@ -1,18 +0,0 @@
{
"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"
}
}

파일 보기

@ -1,27 +0,0 @@
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>
);
}

파일 보기

@ -1,30 +0,0 @@
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>
);
}

파일 보기

@ -1,27 +0,0 @@
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>
);
}

파일 보기

@ -1,26 +0,0 @@
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>
);
}

파일 보기

@ -1,24 +0,0 @@
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>
);
}

파일 보기

@ -1,38 +0,0 @@
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>
);
}

파일 보기

@ -1,18 +0,0 @@
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}
/>
);
}

파일 보기

@ -1,24 +0,0 @@
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>
);
}

파일 보기

@ -1,8 +0,0 @@
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';

파일 보기

@ -1,2 +0,0 @@
export { cn } from './utils/cn.ts';
export * from './components/index.ts';

파일 보기

@ -1,87 +0,0 @@
/* ── 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;
}

파일 보기

@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

파일 보기

@ -1,19 +0,0 @@
{
"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"]
}