From c2ca830ef00edfee7d031a650b06340bf5b4eab9 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:20:47 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=8D=B0=EB=93=9C=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entities/vessel/lib/filter.ts (미사용 필터 유틸) - entities/vessel/model/mockFleet.ts (미사용 mock 데이터) - shared/lib/color/hexToRgb.ts (MapSettingsPanel 로컬 중복) Co-Authored-By: Claude Opus 4.6 --- apps/web/src/entities/vessel/lib/filter.ts | 34 --- .../src/entities/vessel/model/mockFleet.ts | 278 ------------------ apps/web/src/shared/lib/color/hexToRgb.ts | 7 - 3 files changed, 319 deletions(-) delete mode 100644 apps/web/src/entities/vessel/lib/filter.ts delete mode 100644 apps/web/src/entities/vessel/model/mockFleet.ts delete mode 100644 apps/web/src/shared/lib/color/hexToRgb.ts diff --git a/apps/web/src/entities/vessel/lib/filter.ts b/apps/web/src/entities/vessel/lib/filter.ts deleted file mode 100644 index 516c0dc..0000000 --- a/apps/web/src/entities/vessel/lib/filter.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Vessel, VesselTypeCode } from "../model/types"; - -export function isTrawl(code: VesselTypeCode) { - return code === "PT" || code === "PT-S"; -} - -export function filterVesselsForMap(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) { - if (!selectedType) return vessels; - - return vessels.filter((v) => { - if (v.code === selectedType) return true; - - // PT and PT-S should be shown together - if (selectedType === "PT" && v.code === "PT-S") return true; - if (selectedType === "PT-S" && v.code === "PT") return true; - - // FC interacts with trawl; show trawl when FC selected and FC when trawl selected - if (selectedType === "FC" && (v.code === "PT" || v.code === "PT-S")) return true; - if ((selectedType === "PT" || selectedType === "PT-S") && v.code === "FC") return true; - - return false; - }); -} - -export function filterVesselsForList(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) { - if (!selectedType) return vessels; - return vessels.filter((v) => { - if (v.code === selectedType) return true; - if (selectedType === "PT" && v.code === "PT-S") return true; - if (selectedType === "PT-S" && v.code === "PT") return true; - return false; - }); -} - diff --git a/apps/web/src/entities/vessel/model/mockFleet.ts b/apps/web/src/entities/vessel/model/mockFleet.ts deleted file mode 100644 index 8e86e37..0000000 --- a/apps/web/src/entities/vessel/model/mockFleet.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { ZoneId } from "../../zone/model/meta"; -import { haversineNm } from "../../../shared/lib/geo/haversineNm"; -import { VESSEL_TYPES } from "./meta"; -import type { FleetOwner, FleetState, TrawlPair, Vessel, VesselTypeCode } from "./types"; - -const SURNAMES = ["张", "王", "李", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "林", "何", "高", "罗"]; -const REGIONS = ["荣成", "石岛", "烟台", "威海", "日照", "青岛", "连云港", "舟山", "象山", "大连"]; - -const ZONE_BOUNDS: Record = { - "1": { lon: [128.85, 131.70], lat: [36.16, 38.25] }, - "2": { lon: [126.00, 128.90], lat: [32.18, 34.35] }, - "3": { lon: [124.12, 126.06], lat: [33.13, 35.00] }, - "4": { lon: [124.33, 125.85], lat: [35.00, 37.00] }, -}; - -function rnd(a: number, b: number) { - return a + Math.random() * (b - a); -} - -function pick(arr: readonly T[]) { - return arr[Math.floor(Math.random() * arr.length)]; -} - -function randomPointInZone(zone: ZoneId) { - const b = ZONE_BOUNDS[zone]; - // Small margin to avoid sitting exactly on the edge. - const lat = rnd(b.lat[0] + 0.05, b.lat[1] - 0.05); - const lon = rnd(b.lon[0] + 0.05, b.lon[1] - 0.05); - return { lat, lon }; -} - -function makePermit(id: number, suffix: "A" | "B") { - return `C21-${10000 + id}${suffix}`; -} - -export function createMockFleetState(): FleetState { - const vessels: Vessel[] = []; - const owners: FleetOwner[] = []; - const ptPairs: TrawlPair[] = []; - - let vid = 1; - - // PT pairs: PT count == PT-S count, treat as "pair count". - for (let i = 0; i < VESSEL_TYPES.PT.count; i++) { - const owner = `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "海产", "水产", "船务"])}${pick(["有限公司", "合作社", ""])}`; - const region = pick(REGIONS); - const zone = pick(["2", "3"]); - const { lat, lon } = randomPointInZone(zone); - - const isFishing = Math.random() < 0.55; - const sp = isFishing ? rnd(2.5, 4.5) : rnd(6, 11); - const crs = rnd(0, 360); - - const pairDist = isFishing ? rnd(0.2, 1.2) : rnd(0, 0.3); // NM (rough) - const pairAngle = rnd(0, 360); - const lat2 = lat + (pairDist / 60) * Math.cos((pairAngle * Math.PI) / 180); - const lon2 = lon + ((pairDist / 60) * Math.sin((pairAngle * Math.PI) / 180)) / Math.cos((lat * Math.PI) / 180); - - const permitBase = vid; - const ptId = vid++; - const ptsId = vid++; - - const pt: Vessel = { - id: ptId, - permit: makePermit(permitBase, "A"), - code: "PT", - color: VESSEL_TYPES.PT.color, - lat, - lon, - speed: Number(sp.toFixed(1)), - course: Number(crs.toFixed(0)), - state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중", - zone, - isFishing, - owner, - region, - pairId: null, - pairDistNm: Number(pairDist.toFixed(2)), - nearVesselIds: [], - }; - - const pts: Vessel = { - id: ptsId, - permit: makePermit(permitBase, "B"), - code: "PT-S", - color: VESSEL_TYPES["PT-S"].color, - lat: Number(lat2.toFixed(4)), - lon: Number(lon2.toFixed(4)), - speed: Number((sp + rnd(-0.3, 0.3)).toFixed(1)), - course: Number((crs + rnd(-10, 10)).toFixed(0)), - state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중", - zone, - isFishing, - owner, - region, - pairId: null, - pairDistNm: pt.pairDistNm, - nearVesselIds: [], - }; - - pt.pairId = pts.id; - pts.pairId = pt.id; - - vessels.push(pt, pts); - ptPairs.push({ mainId: pt.id, subId: pts.id, owner, region }); - owners.push({ name: owner, region, vessels: [pt.id, pts.id], type: "trawl" }); - } - - // GN vessels - for (let i = 0; i < VESSEL_TYPES.GN.count; i++) { - const attachToOwner = Math.random() < 0.3 ? owners[Math.floor(Math.random() * owners.length)] : null; - const owner = attachToOwner ? attachToOwner.name : `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "水产"])}有限公司`; - const region = attachToOwner ? attachToOwner.region : pick(REGIONS); - const zone = pick(["2", "3", "4"]); - const { lat, lon } = randomPointInZone(zone); - - const isFishing = Math.random() < 0.5; - const sp = isFishing ? rnd(0.5, 2) : rnd(5, 10); - - const id = vid++; - const v: Vessel = { - id, - permit: makePermit(id, "A"), - code: "GN", - color: VESSEL_TYPES.GN.color, - lat, - lon, - speed: Number(sp.toFixed(1)), - course: Number(rnd(0, 360).toFixed(0)), - state: isFishing ? pick(["표류", "투망", "양망"]) : sp < 1 ? "정박" : "항해중", - zone, - isFishing, - owner, - region, - pairId: null, - pairDistNm: null, - nearVesselIds: [], - }; - - vessels.push(v); - if (attachToOwner) attachToOwner.vessels.push(v.id); - else owners.push({ name: owner, region, vessels: [v.id], type: "gn" }); - } - - // OT - for (let i = 0; i < VESSEL_TYPES.OT.count; i++) { - const owner = `${pick(SURNAMES)}${pick(SURNAMES)}远洋渔业`; - const region = pick(REGIONS); - const zone = pick(["2", "3"]); - const { lat, lon } = randomPointInZone(zone); - const isFishing = Math.random() < 0.5; - const sp = isFishing ? rnd(2.5, 5) : rnd(5, 10); - const id = vid++; - const v: Vessel = { - id, - permit: makePermit(id, "A"), - code: "OT", - color: VESSEL_TYPES.OT.color, - lat, - lon, - speed: Number(sp.toFixed(1)), - course: Number(rnd(0, 360).toFixed(0)), - state: isFishing ? "조업중" : "항해중", - zone, - isFishing, - owner, - region, - pairId: null, - pairDistNm: null, - nearVesselIds: [], - }; - vessels.push(v); - owners.push({ name: owner, region, vessels: [v.id], type: "ot" }); - } - - // PS - for (let i = 0; i < VESSEL_TYPES.PS.count; i++) { - const owner = `${pick(SURNAMES)}${pick(SURNAMES)}水产`; - const region = pick(REGIONS); - const zone = pick(["1", "2", "3", "4"]); - const { lat, lon } = randomPointInZone(zone); - const isFishing = Math.random() < 0.5; - const sp = isFishing ? rnd(0.3, 1.5) : rnd(5, 9); - const id = vid++; - const v: Vessel = { - id, - permit: makePermit(id, "A"), - code: "PS", - color: VESSEL_TYPES.PS.color, - lat, - lon, - speed: Number(sp.toFixed(1)), - course: Number(rnd(0, 360).toFixed(0)), - state: isFishing ? pick(["위망", "채낚기"]) : "항해중", - zone, - isFishing, - owner, - region, - pairId: null, - pairDistNm: null, - nearVesselIds: [], - }; - vessels.push(v); - owners.push({ name: owner, region, vessels: [v.id], type: "ps" }); - } - - // FC — assigned to trawl owners (positioned near PT) - const trawlOwners = owners.filter((o) => o.type === "trawl"); - for (let i = 0; i < VESSEL_TYPES.FC.count; i++) { - const oi = i < trawlOwners.length ? trawlOwners[i] : pick(trawlOwners); - - const refId = oi.vessels.find((id) => vessels[id - 1]?.code === "PT") ?? oi.vessels[0]; - const ref = vessels[refId - 1]; - - const zone = pick(["2", "3"]); - const lat = ref.lat + rnd(-0.2, 0.2); - const lon = ref.lon + rnd(-0.2, 0.2); - - const isNear = Math.random() < 0.4; - const sp = isNear ? rnd(0.5, 1.5) : rnd(5, 9); - - const nearVesselIds = isNear ? oi.vessels.filter((id) => vessels[id - 1]?.code !== "FC").slice(0, 2) : []; - - const v: Vessel = { - id: vid, - permit: makePermit(vid, "A"), - code: "FC", - color: VESSEL_TYPES.FC.color, - lat, - lon, - speed: Number(sp.toFixed(1)), - course: Number(rnd(0, 360).toFixed(0)), - state: isNear ? "환적" : "항해중", - zone, - isFishing: isNear, // kept from prototype: treat "환적" as fishing-like activity - owner: oi.name, - region: oi.region, - pairId: null, - pairDistNm: null, - nearVesselIds, - }; - vid += 1; - vessels.push(v); - oi.vessels.push(v.id); - } - - // Ensure initial pair distances are consistent with actual coordinates. - for (const p of ptPairs) { - const a = vessels[p.mainId - 1]; - const b = vessels[p.subId - 1]; - const d = haversineNm(a.lat, a.lon, b.lat, b.lon); - a.pairDistNm = d; - b.pairDistNm = d; - } - - return { vessels, owners, ptPairs }; -} - -export function tickMockFleetState(state: FleetState) { - for (const v of state.vessels) { - v.lat += (0.5 - Math.random()) * 0.003; - v.lon += (0.5 - Math.random()) * 0.003; - v.speed = Math.max(0, Number((v.speed + (0.5 - Math.random()) * 0.4).toFixed(1))); - v.course = Number(((v.course + (0.5 - Math.random()) * 6 + 360) % 360).toFixed(0)); - } - - for (const p of state.ptPairs) { - const a = state.vessels[p.mainId - 1]; - const b = state.vessels[p.subId - 1]; - const d = haversineNm(a.lat, a.lon, b.lat, b.lon); - a.pairDistNm = d; - b.pairDistNm = d; - } -} - -export function isVesselCode(code: string): code is VesselTypeCode { - return code === "PT" || code === "PT-S" || code === "GN" || code === "OT" || code === "PS" || code === "FC"; -} diff --git a/apps/web/src/shared/lib/color/hexToRgb.ts b/apps/web/src/shared/lib/color/hexToRgb.ts deleted file mode 100644 index 9c636ad..0000000 --- a/apps/web/src/shared/lib/color/hexToRgb.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function hexToRgb(hex: string): [number, number, number] { - const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()); - if (!m) return [255, 255, 255]; - const n = parseInt(m[1], 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; -} - -- 2.45.2 From e2dc927ad2fcdae1988d3b4d476673f553226f00 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:35:03 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor(map3d):=20useGlobeShips=20977?= =?UTF-8?q?=EC=A4=84=20=E2=86=92=20=EC=84=9C=EB=B8=8C=ED=9B=85=203+1?= =?UTF-8?q?=EA=B0=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGlobeShipLabels: Mercator 선명 라벨 - useGlobeShipLayers: Globe 선박 아이콘 레이어 + GeoJSON - useGlobeShipHover: Globe 호버 오버레이 + 클릭 선택 - useGlobeShips: 오케스트레이터 (기존 호출부 호환) Co-Authored-By: Claude Opus 4.6 --- .../widgets/map3d/hooks/useGlobeShipHover.ts | 369 +++++++ .../widgets/map3d/hooks/useGlobeShipLabels.ts | 164 +++ .../widgets/map3d/hooks/useGlobeShipLayers.ts | 501 +++++++++ .../src/widgets/map3d/hooks/useGlobeShips.ts | 984 +----------------- 4 files changed, 1075 insertions(+), 943 deletions(-) create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts new file mode 100644 index 0000000..0c4c195 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts @@ -0,0 +1,369 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { + GLOBE_ICON_HEADING_OFFSET_DEG, + DEG2RAD, +} from '../constants'; +import { isFiniteNumber } from '../lib/setUtils'; +import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils'; +import { ensureFallbackShipImage } from '../lib/globeShipIcon'; +import { clampNumber } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; + +/** Globe 호버 오버레이 + 클릭 선택 */ +export function useGlobeShipHover( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + shipLayerData: AisTarget[]; + shipHoverOverlaySet: Set; + legacyHits: Map | null | undefined; + selectedMmsi: number | null; + mapSyncEpoch: number; + onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; + targets: AisTarget[]; + hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; + }, +) { + const { + projection, settings, shipLayerData, shipHoverOverlaySet, legacyHits, + selectedMmsi, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, + targets, hasAuxiliarySelectModifier, + } = opts; + + const epochRef = useRef(-1); + const hoverSignatureRef = useRef(''); + + // Globe hover overlay ships + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = 'ship-globe-icon'; + const srcId = 'ships-globe-hover-src'; + const haloId = 'ships-globe-hover-halo'; + const outlineId = 'ships-globe-hover-outline'; + const symbolId = 'ships-globe-hover'; + + const hideHover = () => { + for (const id of [symbolId, outlineId, haloId]) { + guardedSetVisibility(map, id, 'none'); + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { + hideHover(); + return; + } + + if (epochRef.current !== mapSyncEpoch) { + epochRef.current = mapSyncEpoch; + } + + ensureFallbackShipImage(map, imgId); + if (!map.hasImage(imgId)) { + return; + } + + const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); + if (hovered.length === 0) { + hideHover(); + return; + } + const hoverSignature = hovered + .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) + .join('|'); + const hasHoverSource = map.getSource(srcId) != null; + const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); + if (hoverSignature === hoverSignatureRef.current && hasHoverSource && hasHoverLayers) { + return; + } + hoverSignatureRef.current = hoverSignature; + const needReorder = !hasHoverSource || !hasHoverLayers; + + const hoverGeojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: hovered.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const hull = clampNumber( + (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, + 50, + 420, + ); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const scale = selected ? 1.16 : 1.1; + return { + type: 'Feature', + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || '', + cog: heading, + heading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), + iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), + iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1), + iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0), + iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0), + selected: selected ? 1 : 0, + permitted: legacy ? 1 : 0, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(hoverGeojson); + else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Ship hover source setup failed:', e); + return; + } + + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: 'circle', + source: srcId, + layout: { + visibility: 'visible', + 'circle-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 120, + ['==', ['get', 'permitted'], 1], 115, + 110, + ] as never, + }, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', + 'rgba(245,158,11,1)', + ] as never, + 'circle-opacity': 0.42, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover halo layer add failed:', e); + } + } else { + map.setLayoutProperty(haloId, 'visibility', 'visible'); + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: 'circle', + source: srcId, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': 'rgba(0,0,0,0)', + 'circle-stroke-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + 'rgba(245,158,11,0.95)', + ] as never, + 'circle-stroke-width': [ + 'case', + ['==', ['get', 'selected'], 1], 3.8, + 2.2, + ] as never, + 'circle-stroke-opacity': 0.9, + }, + layout: { + visibility: 'visible', + 'circle-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 121, + ['==', ['get', 'permitted'], 1], 116, + 111, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover outline layer add failed:', e); + } + } else { + map.setLayoutProperty(outlineId, 'visibility', 'visible'); + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: 'symbol', + source: srcId, + layout: { + visibility: 'visible', + 'symbol-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 122, + ['==', ['get', 'permitted'], 1], 117, + 112, + ] as never, + 'icon-image': imgId, + 'icon-size': [ + 'interpolate', ['linear'], ['zoom'], + 3, ['to-number', ['get', 'iconSize3'], 0.35], + 7, ['to-number', ['get', 'iconSize7'], 0.45], + 10, ['to-number', ['get', 'iconSize10'], 0.58], + 14, ['to-number', ['get', 'iconSize14'], 0.85], + 18, ['to-number', ['get', 'iconSize18'], 2.5], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': ['to-number', ['get', 'heading'], 0], + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': 1, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover symbol layer add failed:', e); + } + } else { + map.setLayoutProperty(symbolId, 'visibility', 'visible'); + } + + if (needReorder) { + reorderGlobeFeatureLayers(); + } + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + shipLayerData, + legacyHits, + shipHoverOverlaySet, + selectedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Globe ship click selection + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projection !== 'globe' || !settings.showShips) return; + + const symbolId = 'ships-globe'; + const symbolLiteId = 'ships-globe-lite'; + const haloId = 'ships-globe-halo'; + const outlineId = 'ships-globe-outline'; + const clickedRadiusDeg2 = Math.pow(0.08, 2); + + const onClick = (e: maplibregl.MapMouseEvent) => { + try { + const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id)); + let feats: unknown[] = []; + if (layerIds.length > 0) { + try { + feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; + } catch { + feats = []; + } + } + const f = feats?.[0]; + const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< + string, + unknown + >; + const mmsi = Number(props.mmsi); + if (Number.isFinite(mmsi)) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(mmsi); + return; + } + onSelectMmsi(mmsi); + return; + } + + const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; + const cosLat = Math.cos(clicked.lat * DEG2RAD); + let bestMmsi: number | null = null; + let bestD2 = Number.POSITIVE_INFINITY; + for (const t of targets) { + if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; + const dLon = (clicked.lon - t.lon) * cosLat; + const dLat = clicked.lat - t.lat; + const d2 = dLon * dLon + dLat * dLat; + if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { + bestD2 = d2; + bestMmsi = t.mmsi; + } + } + if (bestMmsi != null) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(bestMmsi); + return; + } + onSelectMmsi(bestMmsi); + return; + } + } catch { + // ignore + } + onSelectMmsi(null); + }; + + map.on('click', onClick); + return () => { + try { + map.off('click', onClick); + } catch { + // ignore + } + }; + }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts new file mode 100644 index 0000000..7fbd70a --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts @@ -0,0 +1,164 @@ +import { useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; + +/** Mercator 모드 선명 라벨 (허가 선박 + 선택/하이라이트) */ +export function useGlobeShipLabels( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + shipData: AisTarget[]; + shipHighlightSet: Set; + overlays: MapToggleState; + legacyHits: Map | null | undefined; + selectedMmsi: number | null; + mapSyncEpoch: number; + }, +) { + const { + projection, settings, shipData, shipHighlightSet, + overlays, legacyHits, selectedMmsi, mapSyncEpoch, + } = opts; + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'ship-labels-src'; + const layerId = 'ship-labels'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== 'mercator' || !settings.showShips) { + remove(); + return; + } + + const visibility = overlays.shipLabels ? 'visible' : 'none'; + + const features: GeoJSON.Feature[] = []; + 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 = { type: 'FeatureCollection', features }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Ship label source setup failed:', e); + return; + } + + const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[]; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'symbol', + source: srcId, + minzoom: 7, + filter: filter as never, + layout: { + visibility, + 'symbol-placement': 'point', + 'text-field': ['get', 'labelName'] as never, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, + 'text-anchor': 'top', + 'text-offset': [0, 1.1], + 'text-padding': 2, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': [ + 'case', + ['==', ['get', 'selected'], 1], + 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], + 'rgba(245,158,11,0.95)', + 'rgba(226,232,240,0.92)', + ] as never, + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Ship label layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', visibility); + } catch { + // ignore + } + } + + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + overlays.shipLabels, + shipData, + legacyHits, + selectedMmsi, + shipHighlightSet, + mapSyncEpoch, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts new file mode 100644 index 0000000..ece38f3 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -0,0 +1,501 @@ +import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { + ANCHORED_SHIP_ICON_ID, + GLOBE_ICON_HEADING_OFFSET_DEG, + GLOBE_OUTLINE_PERMITTED, + GLOBE_OUTLINE_OTHER, +} from '../constants'; +import { isFiniteNumber } from '../lib/setUtils'; +import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { + isAnchoredShip, + getDisplayHeading, + getGlobeBaseShipColor, +} from '../lib/shipUtils'; +import { + buildFallbackGlobeAnchoredShipIcon, + ensureFallbackShipImage, +} from '../lib/globeShipIcon'; +import { clampNumber } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; + +/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label) */ +export function useGlobeShipLayers( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + shipData: AisTarget[]; + overlays: MapToggleState; + legacyHits: Map | null | undefined; + selectedMmsi: number | null; + isBaseHighlightedMmsi: (mmsi: number) => boolean; + mapSyncEpoch: number; + onGlobeShipsReady?: (ready: boolean) => void; + }, +) { + const { + projection, settings, shipData, overlays, legacyHits, + selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady, + } = opts; + + const epochRef = useRef(-1); + + // Globe GeoJSON을 projection과 무관하게 항상 사전 계산 + // Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱 + const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection => { + return { + type: 'FeatureCollection', + features: shipData.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || ''; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading }); + const shipHeading = isAnchored ? 0 : heading; + const hull = clampNumber( + (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, + 50, 420, + ); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const highlighted = isBaseHighlightedMmsi(t.mmsi); + const selectedScale = selected ? 1.08 : 1; + const highlightScale = highlighted ? 1.06 : 1; + const iconScale = selected ? selectedScale : highlightScale; + const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); + const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); + const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8); + const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6); + const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0); + return { + type: 'Feature' as const, + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || '', + labelName, + cog: shipHeading, + heading: shipHeading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + isAnchored: isAnchored ? 1 : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: iconSize3 * iconScale, + iconSize7: iconSize7 * iconScale, + iconSize10: iconSize10 * iconScale, + iconSize14: iconSize14 * iconScale, + iconSize18: iconSize18 * iconScale, + sizeScale, + selected: selected ? 1 : 0, + highlighted: highlighted ? 1 : 0, + permitted: legacy ? 1 : 0, + code: legacy?.shipCode || '', + }, + }; + }), + }; + }, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]); + + // Ships in globe mode + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = 'ship-globe-icon'; + const anchoredImgId = ANCHORED_SHIP_ICON_ID; + const srcId = 'ships-globe-src'; + const haloId = 'ships-globe-halo'; + const outlineId = 'ships-globe-outline'; + const symbolLiteId = 'ships-globe-lite'; + const symbolId = 'ships-globe'; + const labelId = 'ships-globe-label'; + + // 레이어를 제거하지 않고 visibility만 'none'으로 설정 + // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) + const hide = () => { + for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) { + guardedSetVisibility(map, id, 'none'); + } + }; + + const ensureImage = () => { + ensureFallbackShipImage(map, imgId); + ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); + if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; + kickRepaint(map); + }; + + const ensure = () => { + if (!settings.showShips) { + hide(); + onGlobeShipsReady?.(false); + return; + } + + // 빠른 visibility 토글 — projectionBusy 중에도 실행 + // guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출 + // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지 + const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; + const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; + if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) { + const changed = + map.getLayoutProperty(symbolId, 'visibility') !== visibility || + map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility; + if (changed) { + for (const id of [haloId, outlineId, symbolLiteId, symbolId]) { + guardedSetVisibility(map, id, visibility); + } + if (projection === 'globe') kickRepaint(map); + } + guardedSetVisibility(map, labelId, labelVisibility); + } + + // 데이터 업데이트는 projectionBusy 중에는 차단 + if (projectionBusyRef.current) { + // 레이어가 이미 존재하면 ready 상태 유지 + if (map.getLayer(symbolId)) onGlobeShipsReady?.(true); + return; + } + if (!map.isStyleLoaded()) return; + + if (epochRef.current !== mapSyncEpoch) { + epochRef.current = mapSyncEpoch; + } + + try { + ensureImage(); + } catch (e) { + console.warn('Ship icon image setup failed:', e); + } + + // 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨) + const geojson = globeShipGeoJson; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(geojson); + else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Ship source setup failed:', e); + return; + } + + const before = undefined; + const priorityFilter = [ + 'any', + ['==', ['to-number', ['get', 'permitted'], 0], 1], + ['==', ['to-number', ['get', 'selected'], 0], 1], + ['==', ['to-number', ['get', 'highlighted'], 0], 1], + ] as unknown as unknown[]; + const nonPriorityFilter = [ + 'all', + ['==', ['to-number', ['get', 'permitted'], 0], 0], + ['==', ['to-number', ['get', 'selected'], 0], 0], + ['==', ['to-number', ['get', 'highlighted'], 0], 0], + ] as unknown as unknown[]; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: 'circle', + source: srcId, + layout: { + visibility, + 'circle-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, + ['==', ['get', 'permitted'], 1], 110, + ['==', ['get', 'selected'], 1], 60, + ['==', ['get', 'highlighted'], 1], 55, + 20, + ] as never, + }, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'circle-opacity': [ + 'case', + ['==', ['get', 'selected'], 1], 0.38, + ['==', ['get', 'highlighted'], 1], 0.34, + 0.16, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship halo layer add failed:', e); + } + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: 'circle', + source: srcId, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': 'rgba(0,0,0,0)', + 'circle-stroke-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, + GLOBE_OUTLINE_OTHER, + ] as never, + 'circle-stroke-width': [ + 'case', + ['==', ['get', 'selected'], 1], 3.4, + ['==', ['get', 'highlighted'], 1], 2.7, + ['==', ['get', 'permitted'], 1], 1.8, + 0.7, + ] as never, + 'circle-stroke-opacity': 0.85, + }, + layout: { + visibility, + 'circle-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, + ['==', ['get', 'permitted'], 1], 120, + ['==', ['get', 'selected'], 1], 70, + ['==', ['get', 'highlighted'], 1], 65, + 30, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship outline layer add failed:', e); + } + } + + if (!map.getLayer(symbolLiteId)) { + try { + map.addLayer( + { + id: symbolLiteId, + type: 'symbol', + source: srcId, + minzoom: 6.5, + filter: nonPriorityFilter as never, + layout: { + visibility, + 'symbol-sort-key': 40 as never, + 'icon-image': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + anchoredImgId, + imgId, + ] as never, + 'icon-size': [ + 'interpolate', + ['linear'], + ['zoom'], + 6.5, + ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45], + 8, + ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62], + 10, + ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72], + 14, + ['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78], + 18, + ['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + 0, + ['to-number', ['get', 'heading'], 0], + ] as never, + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': [ + 'interpolate', + ['linear'], + ['zoom'], + 6.5, + 0.16, + 8, + 0.34, + 11, + 0.54, + 14, + 0.68, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship lite symbol layer add failed:', e); + } + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: 'symbol', + source: srcId, + filter: priorityFilter as never, + layout: { + visibility, + 'symbol-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, + ['==', ['get', 'permitted'], 1], 130, + ['==', ['get', 'selected'], 1], 80, + ['==', ['get', 'highlighted'], 1], 75, + 45, + ] as never, + 'icon-image': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + anchoredImgId, + imgId, + ] as never, + 'icon-size': [ + 'interpolate', ['linear'], ['zoom'], + 3, ['to-number', ['get', 'iconSize3'], 0.35], + 7, ['to-number', ['get', 'iconSize7'], 0.45], + 10, ['to-number', ['get', 'iconSize10'], 0.58], + 14, ['to-number', ['get', 'iconSize14'], 0.85], + 18, ['to-number', ['get', 'iconSize18'], 2.5], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0, + ['to-number', ['get', 'heading'], 0], + ] as never, + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': [ + 'case', + ['==', ['get', 'selected'], 1], 1, + ['==', ['get', 'highlighted'], 1], 0.95, + ['==', ['get', 'permitted'], 1], 0.93, + 0.9, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship symbol layer add failed:', e); + } + } + + const labelFilter = [ + 'all', + ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], + [ + 'any', + ['==', ['get', 'permitted'], 1], + ['==', ['get', 'selected'], 1], + ['==', ['get', 'highlighted'], 1], + ], + ] as unknown as unknown[]; + + if (!map.getLayer(labelId)) { + try { + map.addLayer( + { + id: labelId, + type: 'symbol', + source: srcId, + minzoom: 7, + filter: labelFilter as never, + layout: { + visibility: labelVisibility, + 'symbol-placement': 'point', + 'text-field': ['get', 'labelName'] as never, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, + 'text-anchor': 'top', + 'text-offset': [0, 1.1], + 'text-padding': 2, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + 'rgba(226,232,240,0.92)', + ] as never, + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Ship label layer add failed:', e); + } + } + + // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) + onGlobeShipsReady?.(true); + if (projection === 'globe') { + reorderGlobeFeatureLayers(); + } + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + overlays.shipLabels, + globeShipGeoJson, + selectedMmsi, + isBaseHighlightedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + onGlobeShipsReady, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index 8cbc4ee..d0105c4 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -1,31 +1,12 @@ -import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import type { MutableRefObject } from 'react'; import type maplibregl from 'maplibre-gl'; -import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { Map3DSettings, MapProjectionId } from '../types'; -import { - ANCHORED_SHIP_ICON_ID, - GLOBE_ICON_HEADING_OFFSET_DEG, - GLOBE_OUTLINE_PERMITTED, - GLOBE_OUTLINE_OTHER, - DEG2RAD, -} from '../constants'; -import { isFiniteNumber } from '../lib/setUtils'; -import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; -import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; -import { - isAnchoredShip, - getDisplayHeading, - getGlobeBaseShipColor, -} from '../lib/shipUtils'; -import { - buildFallbackGlobeAnchoredShipIcon, - ensureFallbackShipImage, -} from '../lib/globeShipIcon'; -import { clampNumber } from '../lib/geometry'; -import { guardedSetVisibility } from '../lib/layerHelpers'; +import { useGlobeShipLabels } from './useGlobeShipLabels'; +import { useGlobeShipLayers } from './useGlobeShipLayers'; +import { useGlobeShipHover } from './useGlobeShipHover'; export function useGlobeShips( mapRef: MutableRefObject, @@ -52,926 +33,43 @@ export function useGlobeShips( onGlobeShipsReady?: (ready: boolean) => void; }, ) { - const { - projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, - shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, - overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, - onGlobeShipsReady, - } = opts; + // Mercator 모드 선명 라벨 + useGlobeShipLabels(mapRef, projectionBusyRef, { + projection: opts.projection, + settings: opts.settings, + shipData: opts.shipData, + shipHighlightSet: opts.shipHighlightSet, + overlays: opts.overlays, + legacyHits: opts.legacyHits, + selectedMmsi: opts.selectedMmsi, + mapSyncEpoch: opts.mapSyncEpoch, + }); - const globeShipsEpochRef = useRef(-1); - const globeHoverShipSignatureRef = useRef(''); + // Globe 모드 선박 아이콘 레이어 + useGlobeShipLayers(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { + projection: opts.projection, + settings: opts.settings, + shipData: opts.shipData, + overlays: opts.overlays, + legacyHits: opts.legacyHits, + selectedMmsi: opts.selectedMmsi, + isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi, + mapSyncEpoch: opts.mapSyncEpoch, + onGlobeShipsReady: opts.onGlobeShipsReady, + }); - // Globe GeoJSON을 projection과 무관하게 항상 사전 계산 - // Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱 - const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection => { - return { - type: 'FeatureCollection', - features: shipData.map((t) => { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || ''; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); - const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading }); - const shipHeading = isAnchored ? 0 : heading; - const hull = clampNumber( - (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, - 50, 420, - ); - const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - const selected = t.mmsi === selectedMmsi; - const highlighted = isBaseHighlightedMmsi(t.mmsi); - const selectedScale = selected ? 1.08 : 1; - const highlightScale = highlighted ? 1.06 : 1; - const iconScale = selected ? selectedScale : highlightScale; - const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); - const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); - const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8); - const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6); - const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0); - return { - type: 'Feature' as const, - ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), - geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || '', - labelName, - cog: shipHeading, - heading: shipHeading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - isAnchored: isAnchored ? 1 : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, - }), - iconSize3: iconSize3 * iconScale, - iconSize7: iconSize7 * iconScale, - iconSize10: iconSize10 * iconScale, - iconSize14: iconSize14 * iconScale, - iconSize18: iconSize18 * iconScale, - sizeScale, - selected: selected ? 1 : 0, - highlighted: highlighted ? 1 : 0, - permitted: legacy ? 1 : 0, - code: legacy?.shipCode || '', - }, - }; - }), - }; - }, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]); - - // Ship name labels in mercator - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'ship-labels-src'; - const layerId = 'ship-labels'; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.removeLayer(layerId); - } catch { - // ignore - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== 'mercator' || !settings.showShips) { - remove(); - return; - } - - const visibility = overlays.shipLabels ? 'visible' : 'none'; - - const features: GeoJSON.Feature[] = []; - 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 = { type: 'FeatureCollection', features }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Ship label source setup failed:', e); - return; - } - - const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[]; - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'symbol', - source: srcId, - minzoom: 7, - filter: filter as never, - layout: { - visibility, - 'symbol-placement': 'point', - 'text-field': ['get', 'labelName'] as never, - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, - 'text-anchor': 'top', - 'text-offset': [0, 1.1], - 'text-padding': 2, - 'text-allow-overlap': false, - 'text-ignore-placement': false, - }, - paint: { - 'text-color': [ - 'case', - ['==', ['get', 'selected'], 1], - 'rgba(14,234,255,0.95)', - ['==', ['get', 'highlighted'], 1], - 'rgba(245,158,11,0.95)', - 'rgba(226,232,240,0.92)', - ] as never, - 'text-halo-color': 'rgba(2,6,23,0.85)', - 'text-halo-width': 1.2, - 'text-halo-blur': 0.8, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Ship label layer add failed:', e); - } - } else { - try { - map.setLayoutProperty(layerId, 'visibility', visibility); - } catch { - // ignore - } - } - - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - overlays.shipLabels, - shipData, - legacyHits, - selectedMmsi, - shipHighlightSet, - mapSyncEpoch, - ]); - - // Ships in globe mode - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const imgId = 'ship-globe-icon'; - const anchoredImgId = ANCHORED_SHIP_ICON_ID; - const srcId = 'ships-globe-src'; - const haloId = 'ships-globe-halo'; - const outlineId = 'ships-globe-outline'; - const symbolLiteId = 'ships-globe-lite'; - const symbolId = 'ships-globe'; - const labelId = 'ships-globe-label'; - - // 레이어를 제거하지 않고 visibility만 'none'으로 설정 - // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) - const hide = () => { - for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) { - guardedSetVisibility(map, id, 'none'); - } - }; - - const ensureImage = () => { - ensureFallbackShipImage(map, imgId); - ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); - if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; - kickRepaint(map); - }; - - const ensure = () => { - if (!settings.showShips) { - hide(); - onGlobeShipsReady?.(false); - return; - } - - // 빠른 visibility 토글 — projectionBusy 중에도 실행 - // guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출 - // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지 - const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; - const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; - if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) { - const changed = - map.getLayoutProperty(symbolId, 'visibility') !== visibility || - map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility; - if (changed) { - for (const id of [haloId, outlineId, symbolLiteId, symbolId]) { - guardedSetVisibility(map, id, visibility); - } - if (projection === 'globe') kickRepaint(map); - } - guardedSetVisibility(map, labelId, labelVisibility); - } - - // 데이터 업데이트는 projectionBusy 중에는 차단 - if (projectionBusyRef.current) { - // 레이어가 이미 존재하면 ready 상태 유지 - if (map.getLayer(symbolId)) onGlobeShipsReady?.(true); - return; - } - if (!map.isStyleLoaded()) return; - - if (globeShipsEpochRef.current !== mapSyncEpoch) { - globeShipsEpochRef.current = mapSyncEpoch; - } - - try { - ensureImage(); - } catch (e) { - console.warn('Ship icon image setup failed:', e); - } - - // 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨) - const geojson = globeShipGeoJson; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(geojson); - else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Ship source setup failed:', e); - return; - } - - const before = undefined; - const priorityFilter = [ - 'any', - ['==', ['to-number', ['get', 'permitted'], 0], 1], - ['==', ['to-number', ['get', 'selected'], 0], 1], - ['==', ['to-number', ['get', 'highlighted'], 0], 1], - ] as unknown as unknown[]; - const nonPriorityFilter = [ - 'all', - ['==', ['to-number', ['get', 'permitted'], 0], 0], - ['==', ['to-number', ['get', 'selected'], 0], 0], - ['==', ['to-number', ['get', 'highlighted'], 0], 0], - ] as unknown as unknown[]; - - if (!map.getLayer(haloId)) { - try { - map.addLayer( - { - id: haloId, - type: 'circle', - source: srcId, - layout: { - visibility, - 'circle-sort-key': [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, - ['==', ['get', 'permitted'], 1], 110, - ['==', ['get', 'selected'], 1], 60, - ['==', ['get', 'highlighted'], 1], 55, - 20, - ] as never, - }, - paint: { - 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - 'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, - 'circle-opacity': [ - 'case', - ['==', ['get', 'selected'], 1], 0.38, - ['==', ['get', 'highlighted'], 1], 0.34, - 0.16, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship halo layer add failed:', e); - } - } - // halo: data-driven expressions are static — visibility handled by fast toggle above - - if (!map.getLayer(outlineId)) { - try { - map.addLayer( - { - id: outlineId, - type: 'circle', - source: srcId, - paint: { - 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - 'circle-color': 'rgba(0,0,0,0)', - 'circle-stroke-color': [ - 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', - ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', - ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, - GLOBE_OUTLINE_OTHER, - ] as never, - 'circle-stroke-width': [ - 'case', - ['==', ['get', 'selected'], 1], 3.4, - ['==', ['get', 'highlighted'], 1], 2.7, - ['==', ['get', 'permitted'], 1], 1.8, - 0.7, - ] as never, - 'circle-stroke-opacity': 0.85, - }, - layout: { - visibility, - 'circle-sort-key': [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, - ['==', ['get', 'permitted'], 1], 120, - ['==', ['get', 'selected'], 1], 70, - ['==', ['get', 'highlighted'], 1], 65, - 30, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship outline layer add failed:', e); - } - } - // outline: data-driven expressions are static — visibility handled by fast toggle - - if (!map.getLayer(symbolLiteId)) { - try { - map.addLayer( - { - id: symbolLiteId, - type: 'symbol', - source: srcId, - minzoom: 6.5, - filter: nonPriorityFilter as never, - layout: { - visibility, - 'symbol-sort-key': 40 as never, - 'icon-image': [ - 'case', - ['==', ['to-number', ['get', 'isAnchored'], 0], 1], - anchoredImgId, - imgId, - ] as never, - 'icon-size': [ - 'interpolate', - ['linear'], - ['zoom'], - 6.5, - ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45], - 8, - ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62], - 10, - ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72], - 14, - ['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78], - 18, - ['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78], - ] as unknown as number[], - 'icon-allow-overlap': true, - 'icon-ignore-placement': true, - 'icon-anchor': 'center', - 'icon-rotate': [ - 'case', - ['==', ['to-number', ['get', 'isAnchored'], 0], 1], - 0, - ['to-number', ['get', 'heading'], 0], - ] as never, - 'icon-rotation-alignment': 'map', - 'icon-pitch-alignment': 'map', - }, - paint: { - 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, - 'icon-opacity': [ - 'interpolate', - ['linear'], - ['zoom'], - 6.5, - 0.16, - 8, - 0.34, - 11, - 0.54, - 14, - 0.68, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship lite symbol layer add failed:', e); - } - } - // lite symbol: lower LOD for non-priority vessels in low zoom - - if (!map.getLayer(symbolId)) { - try { - map.addLayer( - { - id: symbolId, - type: 'symbol', - source: srcId, - filter: priorityFilter as never, - layout: { - visibility, - 'symbol-sort-key': [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, - ['==', ['get', 'permitted'], 1], 130, - ['==', ['get', 'selected'], 1], 80, - ['==', ['get', 'highlighted'], 1], 75, - 45, - ] as never, - 'icon-image': [ - 'case', - ['==', ['to-number', ['get', 'isAnchored'], 0], 1], - anchoredImgId, - imgId, - ] as never, - 'icon-size': [ - 'interpolate', ['linear'], ['zoom'], - 3, ['to-number', ['get', 'iconSize3'], 0.35], - 7, ['to-number', ['get', 'iconSize7'], 0.45], - 10, ['to-number', ['get', 'iconSize10'], 0.58], - 14, ['to-number', ['get', 'iconSize14'], 0.85], - 18, ['to-number', ['get', 'iconSize18'], 2.5], - ] as unknown as number[], - 'icon-allow-overlap': true, - 'icon-ignore-placement': true, - 'icon-anchor': 'center', - 'icon-rotate': [ - 'case', - ['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0, - ['to-number', ['get', 'heading'], 0], - ] as never, - 'icon-rotation-alignment': 'map', - 'icon-pitch-alignment': 'map', - }, - paint: { - 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, - 'icon-opacity': [ - 'case', - ['==', ['get', 'selected'], 1], 1, - ['==', ['get', 'highlighted'], 1], 0.95, - ['==', ['get', 'permitted'], 1], 0.93, - 0.9, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship symbol layer add failed:', e); - } - } - // symbol: data-driven expressions are static — visibility handled by fast toggle - - const labelFilter = [ - 'all', - ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], - [ - 'any', - ['==', ['get', 'permitted'], 1], - ['==', ['get', 'selected'], 1], - ['==', ['get', 'highlighted'], 1], - ], - ] as unknown as unknown[]; - - if (!map.getLayer(labelId)) { - try { - map.addLayer( - { - id: labelId, - type: 'symbol', - source: srcId, - minzoom: 7, - filter: labelFilter as never, - layout: { - visibility: labelVisibility, - 'symbol-placement': 'point', - 'text-field': ['get', 'labelName'] as never, - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, - 'text-anchor': 'top', - 'text-offset': [0, 1.1], - 'text-padding': 2, - 'text-allow-overlap': false, - 'text-ignore-placement': false, - }, - paint: { - 'text-color': [ - 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', - ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', - 'rgba(226,232,240,0.92)', - ] as never, - 'text-halo-color': 'rgba(2,6,23,0.85)', - 'text-halo-width': 1.2, - 'text-halo-blur': 0.8, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Ship label layer add failed:', e); - } - } - // label: filter/text-field are static — visibility handled by fast toggle - - // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) - onGlobeShipsReady?.(true); - if (projection === 'globe') { - reorderGlobeFeatureLayers(); - } - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - overlays.shipLabels, - globeShipGeoJson, - selectedMmsi, - isBaseHighlightedMmsi, - mapSyncEpoch, - reorderGlobeFeatureLayers, - onGlobeShipsReady, - ]); - - // Globe hover overlay ships - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const imgId = 'ship-globe-icon'; - const srcId = 'ships-globe-hover-src'; - const haloId = 'ships-globe-hover-halo'; - const outlineId = 'ships-globe-hover-outline'; - const symbolId = 'ships-globe-hover'; - - const hideHover = () => { - for (const id of [symbolId, outlineId, haloId]) { - guardedSetVisibility(map, id, 'none'); - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { - hideHover(); - return; - } - - if (globeShipsEpochRef.current !== mapSyncEpoch) { - globeShipsEpochRef.current = mapSyncEpoch; - } - - ensureFallbackShipImage(map, imgId); - if (!map.hasImage(imgId)) { - return; - } - - const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); - if (hovered.length === 0) { - hideHover(); - return; - } - const hoverSignature = hovered - .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) - .join('|'); - const hasHoverSource = map.getSource(srcId) != null; - const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); - if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) { - return; - } - globeHoverShipSignatureRef.current = hoverSignature; - const needReorder = !hasHoverSource || !hasHoverLayers; - - const hoverGeojson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: hovered.map((t) => { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); - const hull = clampNumber( - (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, - 50, - 420, - ); - const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - const selected = t.mmsi === selectedMmsi; - const scale = selected ? 1.16 : 1.1; - return { - type: 'Feature', - ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), - geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || '', - cog: heading, - heading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, - }), - iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), - iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), - iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1), - iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0), - iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0), - selected: selected ? 1 : 0, - permitted: legacy ? 1 : 0, - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(hoverGeojson); - else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Ship hover source setup failed:', e); - return; - } - - const before = undefined; - - if (!map.getLayer(haloId)) { - try { - map.addLayer( - { - id: haloId, - type: 'circle', - source: srcId, - layout: { - visibility: 'visible', - 'circle-sort-key': [ - 'case', - ['==', ['get', 'selected'], 1], 120, - ['==', ['get', 'permitted'], 1], 115, - 110, - ] as never, - }, - paint: { - 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - 'circle-color': [ - 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', - 'rgba(245,158,11,1)', - ] as never, - 'circle-opacity': 0.42, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship hover halo layer add failed:', e); - } - } else { - map.setLayoutProperty(haloId, 'visibility', 'visible'); - } - - if (!map.getLayer(outlineId)) { - try { - map.addLayer( - { - id: outlineId, - type: 'circle', - source: srcId, - paint: { - 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - 'circle-color': 'rgba(0,0,0,0)', - 'circle-stroke-color': [ - 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', - 'rgba(245,158,11,0.95)', - ] as never, - 'circle-stroke-width': [ - 'case', - ['==', ['get', 'selected'], 1], 3.8, - 2.2, - ] as never, - 'circle-stroke-opacity': 0.9, - }, - layout: { - visibility: 'visible', - 'circle-sort-key': [ - 'case', - ['==', ['get', 'selected'], 1], 121, - ['==', ['get', 'permitted'], 1], 116, - 111, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship hover outline layer add failed:', e); - } - } else { - map.setLayoutProperty(outlineId, 'visibility', 'visible'); - } - - if (!map.getLayer(symbolId)) { - try { - map.addLayer( - { - id: symbolId, - type: 'symbol', - source: srcId, - layout: { - visibility: 'visible', - 'symbol-sort-key': [ - 'case', - ['==', ['get', 'selected'], 1], 122, - ['==', ['get', 'permitted'], 1], 117, - 112, - ] as never, - 'icon-image': imgId, - 'icon-size': [ - 'interpolate', ['linear'], ['zoom'], - 3, ['to-number', ['get', 'iconSize3'], 0.35], - 7, ['to-number', ['get', 'iconSize7'], 0.45], - 10, ['to-number', ['get', 'iconSize10'], 0.58], - 14, ['to-number', ['get', 'iconSize14'], 0.85], - 18, ['to-number', ['get', 'iconSize18'], 2.5], - ] as unknown as number[], - 'icon-allow-overlap': true, - 'icon-ignore-placement': true, - 'icon-anchor': 'center', - 'icon-rotate': ['to-number', ['get', 'heading'], 0], - 'icon-rotation-alignment': 'map', - 'icon-pitch-alignment': 'map', - }, - paint: { - 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, - 'icon-opacity': 1, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship hover symbol layer add failed:', e); - } - } else { - map.setLayoutProperty(symbolId, 'visibility', 'visible'); - } - - if (needReorder) { - reorderGlobeFeatureLayers(); - } - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - shipLayerData, - legacyHits, - shipHoverOverlaySet, - selectedMmsi, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Globe ship click selection - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (projection !== 'globe' || !settings.showShips) return; - - const symbolId = 'ships-globe'; - const symbolLiteId = 'ships-globe-lite'; - const haloId = 'ships-globe-halo'; - const outlineId = 'ships-globe-outline'; - const clickedRadiusDeg2 = Math.pow(0.08, 2); - - const onClick = (e: maplibregl.MapMouseEvent) => { - try { - const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id)); - let feats: unknown[] = []; - if (layerIds.length > 0) { - try { - feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; - } catch { - feats = []; - } - } - const f = feats?.[0]; - const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< - string, - unknown - >; - const mmsi = Number(props.mmsi); - if (Number.isFinite(mmsi)) { - if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { - onToggleHighlightMmsi?.(mmsi); - return; - } - onSelectMmsi(mmsi); - return; - } - - const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; - const cosLat = Math.cos(clicked.lat * DEG2RAD); - let bestMmsi: number | null = null; - let bestD2 = Number.POSITIVE_INFINITY; - for (const t of targets) { - if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; - const dLon = (clicked.lon - t.lon) * cosLat; - const dLat = clicked.lat - t.lat; - const d2 = dLon * dLon + dLat * dLat; - if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { - bestD2 = d2; - bestMmsi = t.mmsi; - } - } - if (bestMmsi != null) { - if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { - onToggleHighlightMmsi?.(bestMmsi); - return; - } - onSelectMmsi(bestMmsi); - return; - } - } catch { - // ignore - } - onSelectMmsi(null); - }; - - map.on('click', onClick); - return () => { - try { - map.off('click', onClick); - } catch { - // ignore - } - }; - }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); + // Globe 호버 오버레이 + 클릭 선택 + useGlobeShipHover(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { + projection: opts.projection, + settings: opts.settings, + shipLayerData: opts.shipLayerData, + shipHoverOverlaySet: opts.shipHoverOverlaySet, + legacyHits: opts.legacyHits, + selectedMmsi: opts.selectedMmsi, + mapSyncEpoch: opts.mapSyncEpoch, + onSelectMmsi: opts.onSelectMmsi, + onToggleHighlightMmsi: opts.onToggleHighlightMmsi, + targets: opts.targets, + hasAuxiliarySelectModifier: opts.hasAuxiliarySelectModifier, + }); } -- 2.45.2 From b1551f800b71ba023dfd37dddf6650dbfda5adfc Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:41:29 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor(map3d):=20useDeckLayers=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=83=9D=EC=84=B1=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildMercatorDeckLayers: Mercator 모드 Deck.gl 레이어 팩토리 - buildGlobeDeckLayers: Globe 모드 Deck.gl 레이어 팩토리 - useDeckLayers: 오케스트레이션 + 툴팁/클릭 + setProps Co-Authored-By: Claude Opus 4.6 --- .../src/widgets/map3d/hooks/useDeckLayers.ts | 452 ++-------------- .../widgets/map3d/lib/deckLayerFactories.ts | 485 ++++++++++++++++++ 2 files changed, 539 insertions(+), 398 deletions(-) create mode 100644 apps/web/src/widgets/map3d/lib/deckLayerFactories.ts diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index 1eefe43..c580f69 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -1,6 +1,4 @@ import { useEffect, useMemo, type MutableRefObject } from 'react'; -import { HexagonLayer } from '@deck.gl/aggregation-layers'; -import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { type PickingInfo } from '@deck.gl/core'; import type { AisTarget } from '../../../entities/aisTarget/model/types'; @@ -9,39 +7,7 @@ import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDash import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; -import { - SHIP_ICON_MAPPING, - FLAT_SHIP_ICON_SIZE, - FLAT_SHIP_ICON_SIZE_SELECTED, - FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, - FLAT_LEGACY_HALO_RADIUS, - FLAT_LEGACY_HALO_RADIUS_SELECTED, - FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, - EMPTY_MMSI_SET, - DEPTH_DISABLED_PARAMS, - GLOBE_OVERLAY_PARAMS, - HALO_OUTLINE_COLOR, - HALO_OUTLINE_COLOR_SELECTED, - HALO_OUTLINE_COLOR_HIGHLIGHTED, - PAIR_RANGE_NORMAL_DECK, - PAIR_RANGE_WARN_DECK, - PAIR_LINE_NORMAL_DECK, - PAIR_LINE_WARN_DECK, - FC_LINE_NORMAL_DECK, - FC_LINE_SUSPICIOUS_DECK, - FLEET_RANGE_LINE_DECK, - FLEET_RANGE_FILL_DECK, - PAIR_RANGE_NORMAL_DECK_HL, - PAIR_RANGE_WARN_DECK_HL, - PAIR_LINE_NORMAL_DECK_HL, - PAIR_LINE_WARN_DECK_HL, - FC_LINE_NORMAL_DECK_HL, - FC_LINE_SUSPICIOUS_DECK_HL, - FLEET_RANGE_LINE_DECK_HL, - FLEET_RANGE_FILL_DECK_HL, -} from '../constants'; import { toSafeNumber } from '../lib/setUtils'; -import { getDisplayHeading, getShipColor } from '../lib/shipUtils'; import { getShipTooltipHtml, getPairLinkTooltipHtml, @@ -50,7 +16,7 @@ import { getFleetCircleTooltipHtml, } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; -import { getCachedShipIcon } from '../lib/shipIconCache'; +import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories'; // NOTE: // Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). @@ -151,343 +117,37 @@ export function useDeckLayers( const deckTarget = ensureMercatorOverlay(); if (!deckTarget) return; - const layers: unknown[] = []; - const overlayParams = DEPTH_DISABLED_PARAMS; - const clearDeckHover = () => { - touchDeckHoverState(false); - }; - const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false); - const shipOtherData: AisTarget[] = []; - const shipTargetData: AisTarget[] = []; - for (const t of shipLayerData) { - if (isTargetShip(t.mmsi)) shipTargetData.push(t); - else shipOtherData.push(t); - } - const shipOverlayOtherData: AisTarget[] = []; - const shipOverlayTargetData: AisTarget[] = []; - for (const t of shipOverlayLayerData) { - if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t); - else shipOverlayOtherData.push(t); - } - - if (settings.showDensity) { - layers.push( - new HexagonLayer({ - id: 'density', - data: shipLayerData, - pickable: true, - extruded: true, - radius: 2500, - elevationScale: 35, - coverage: 0.92, - opacity: 0.35, - getPosition: (d) => [d.lon, d.lat], - }), - ); - } - - if (overlays.pairRange && pairRanges.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'pair-range', - data: pairRanges, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: 'meters', - getRadius: (d) => d.radiusNm * 1852, - radiusMinPixels: 10, - lineWidthUnits: 'pixels', - getLineWidth: () => 1, - getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const p = info.object as PairRangeCircle; - setDeckHoverPairs([p.aMmsi, p.bMmsi]); - setDeckHoverMmsi([p.aMmsi, p.bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) { onSelectMmsi(null); return; } - const obj = info.object as PairRangeCircle; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - }), - ); - } - - if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { - layers.push( - new LineLayer({ - id: 'pair-lines', - data: pairLinks, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), - getWidth: (d) => (d.warn ? 2.2 : 1.4), - widthUnits: 'pixels', - onHover: (info) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const obj = info.object as PairLink; - setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); - setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as PairLink; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - }), - ); - } - - if (overlays.fcLines && fcDashed.length > 0) { - layers.push( - new LineLayer({ - id: 'fc-lines', - data: fcDashed, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), - getWidth: () => 1.3, - widthUnits: 'pixels', - onHover: (info) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const obj = info.object as DashSeg; - if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; } - setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); - setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as DashSeg; - if (obj.fromMmsi == null || obj.toMmsi == null) return; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.fromMmsi); - onToggleHighlightMmsi?.(obj.toMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); - }, - }), - ); - } - - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { - layers.push( - new ScatterplotLayer({ - id: 'fleet-circles', - data: fleetCircles, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: 'meters', - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: 'pixels', - getLineWidth: () => 1.1, - getLineColor: () => FLEET_RANGE_LINE_DECK, - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - setMapFleetHoverState(obj.ownerKey || null, list); - setDeckHoverMmsi(list); - clearDeckHoverPairs(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); - return; - } - const first = list[0]; - if (first != null) onDeckSelectOrHighlight({ mmsi: first }); - }, - }), - ); - layers.push( - new ScatterplotLayer({ - id: 'fleet-circles-fill', - data: fleetCircles, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: 'meters', - getRadius: (d) => d.radiusNm * 1852, - getFillColor: () => FLEET_RANGE_FILL_DECK, - getPosition: (d) => d.center, - }), - ); - } - - if (settings.showShips) { - const shipOnHover = (info: PickingInfo) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - }; - const shipOnClick = (info: PickingInfo) => { - if (!info.object) { onSelectMmsi(null); return; } - onDeckSelectOrHighlight( - { - mmsi: (info.object as AisTarget).mmsi, - srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, - }, - true, - ); - }; - - if (shipOtherData.length > 0) { - layers.push( - new IconLayer({ - id: 'ships-other', - data: shipOtherData, - pickable: true, - billboard: false, - parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), - sizeUnits: 'pixels', - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), - onHover: shipOnHover, - onClick: shipOnClick, - alphaCutoff: 0.05, - }), - ); - } - - if (shipOverlayOtherData.length > 0) { - layers.push( - new IconLayer({ - id: 'ships-overlay-other', - data: shipOverlayOtherData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), - sizeUnits: 'pixels', - getSize: (d) => { - if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet), - alphaCutoff: 0.05, - }), - ); - } - - if (legacyTargetsOrdered.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'legacy-halo', - data: legacyTargetsOrdered, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: 'pixels', - getRadius: () => FLAT_LEGACY_HALO_RADIUS, - lineWidthUnits: 'pixels', - getLineWidth: () => 2, - getLineColor: () => HALO_OUTLINE_COLOR, - getPosition: (d) => [d.lon, d.lat] as [number, number], - }), - ); - } - - if (shipTargetData.length > 0) { - layers.push( - new IconLayer({ - id: 'ships-target', - data: shipTargetData, - pickable: true, - billboard: false, - parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), - sizeUnits: 'pixels', - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), - onHover: shipOnHover, - onClick: shipOnClick, - alphaCutoff: 0.05, - }), - ); - } - } - - if (pairRangesInteractive.length > 0) { - layers.push(new ScatterplotLayer({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center })); - } - if (pairLinksInteractive.length > 0) { - layers.push(new LineLayer({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' })); - } - if (fcLinesInteractive.length > 0) { - layers.push(new LineLayer({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' })); - } - if (fleetCirclesInteractive.length > 0) { - layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL })); - layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); - } - - if (settings.showShips && legacyOverlayTargets.length > 0) { - layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); - } - - if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { - const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); - layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); - } + const layers = buildMercatorDeckLayers({ + shipLayerData, + shipOverlayLayerData, + legacyTargetsOrdered, + legacyOverlayTargets, + legacyHits, + pairLinks, + fcDashed, + fleetCircles, + pairRanges, + pairLinksInteractive, + pairRangesInteractive, + fcLinesInteractive, + fleetCirclesInteractive, + overlays, + showDensity: settings.showDensity, + showShips: settings.showShips, + selectedMmsi, + shipHighlightSet, + touchDeckHoverState, + setDeckHoverPairs, + setDeckHoverMmsi, + clearDeckHoverPairs, + clearMapFleetHoverState, + setMapFleetHoverState, + toFleetMmsiList, + hasAuxiliarySelectModifier, + onSelectMmsi, + onToggleHighlightMmsi, + onDeckSelectOrHighlight, + }); const normalizedBaseLayers = sanitizeDeckLayerList(layers); const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers); @@ -610,32 +270,28 @@ export function useDeckLayers( return; } - - const overlayParams = GLOBE_OVERLAY_PARAMS; - const globeLayers: unknown[] = []; - - if (overlays.pairRange && pairRanges.length > 0) { - globeLayers.push(new ScatterplotLayer({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } })); - } - - if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { - const links = pairLinks || []; - globeLayers.push(new LineLayer({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } })); - } - - if (overlays.fcLines && fcDashed.length > 0) { - globeLayers.push(new LineLayer({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } })); - } - - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { - const circles = fleetCircles || []; - globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } })); - globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center })); - } - - if (settings.showShips && legacyTargetsOrdered.length > 0) { - globeLayers.push(new ScatterplotLayer({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); - } + const globeLayers = buildGlobeDeckLayers({ + pairRanges, + pairLinks, + fcDashed, + fleetCircles, + legacyTargetsOrdered, + legacyHits, + overlays, + showShips: settings.showShips, + selectedMmsi, + isHighlightedFleet, + isHighlightedPair, + isHighlightedMmsi, + touchDeckHoverState, + setDeckHoverPairs, + setDeckHoverMmsi, + clearDeckHoverPairs, + clearDeckHoverMmsi, + clearMapFleetHoverState, + setMapFleetHoverState, + toFleetMmsiList, + }); const normalizedLayers = sanitizeDeckLayerList(globeLayers); const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined }; diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts new file mode 100644 index 0000000..8963e2b --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -0,0 +1,485 @@ +import { HexagonLayer } from '@deck.gl/aggregation-layers'; +import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers'; +import type { PickingInfo } from '@deck.gl/core'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { DashSeg, PairRangeCircle } from '../types'; +import { + SHIP_ICON_MAPPING, + FLAT_SHIP_ICON_SIZE, + FLAT_SHIP_ICON_SIZE_SELECTED, + FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, + FLAT_LEGACY_HALO_RADIUS, + FLAT_LEGACY_HALO_RADIUS_SELECTED, + FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, + EMPTY_MMSI_SET, + DEPTH_DISABLED_PARAMS, + GLOBE_OVERLAY_PARAMS, + HALO_OUTLINE_COLOR, + HALO_OUTLINE_COLOR_SELECTED, + HALO_OUTLINE_COLOR_HIGHLIGHTED, + PAIR_RANGE_NORMAL_DECK, + PAIR_RANGE_WARN_DECK, + PAIR_LINE_NORMAL_DECK, + PAIR_LINE_WARN_DECK, + FC_LINE_NORMAL_DECK, + FC_LINE_SUSPICIOUS_DECK, + FLEET_RANGE_LINE_DECK, + FLEET_RANGE_FILL_DECK, + PAIR_RANGE_NORMAL_DECK_HL, + PAIR_RANGE_WARN_DECK_HL, + PAIR_LINE_NORMAL_DECK_HL, + PAIR_LINE_WARN_DECK_HL, + FC_LINE_NORMAL_DECK_HL, + FC_LINE_SUSPICIOUS_DECK_HL, + FLEET_RANGE_LINE_DECK_HL, + FLEET_RANGE_FILL_DECK_HL, +} from '../constants'; +import { getDisplayHeading, getShipColor } from './shipUtils'; +import { getCachedShipIcon } from './shipIconCache'; + +/* ── 공통 콜백 인터페이스 ─────────────────────────────── */ + +interface DeckHoverCallbacks { + touchDeckHoverState: (isHover: boolean) => void; + setDeckHoverPairs: (next: number[]) => void; + setDeckHoverMmsi: (next: number[]) => void; + clearDeckHoverPairs: () => void; + clearMapFleetHoverState: () => void; + setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void; + toFleetMmsiList: (value: unknown) => number[]; +} + +interface DeckSelectCallbacks { + hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; + onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; + onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void; +} + +/* ── Mercator Deck 레이어 ─────────────────────────────── */ + +export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks { + shipLayerData: AisTarget[]; + shipOverlayLayerData: AisTarget[]; + legacyTargetsOrdered: AisTarget[]; + legacyOverlayTargets: AisTarget[]; + legacyHits: Map | null | undefined; + pairLinks: PairLink[] | undefined; + fcDashed: DashSeg[]; + fleetCircles: FleetCircle[] | undefined; + pairRanges: PairRangeCircle[]; + pairLinksInteractive: PairLink[]; + pairRangesInteractive: PairRangeCircle[]; + fcLinesInteractive: DashSeg[]; + fleetCirclesInteractive: FleetCircle[]; + overlays: MapToggleState; + showDensity: boolean; + showShips: boolean; + selectedMmsi: number | null; + shipHighlightSet: Set; +} + +export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] { + const layers: unknown[] = []; + const overlayParams = DEPTH_DISABLED_PARAMS; + const clearDeckHover = () => { ctx.touchDeckHoverState(false); }; + const isTargetShip = (mmsi: number) => (ctx.legacyHits ? ctx.legacyHits.has(mmsi) : false); + + const shipOtherData: AisTarget[] = []; + const shipTargetData: AisTarget[] = []; + for (const t of ctx.shipLayerData) { + if (isTargetShip(t.mmsi)) shipTargetData.push(t); + else shipOtherData.push(t); + } + const shipOverlayOtherData: AisTarget[] = []; + for (const t of ctx.shipOverlayLayerData) { + if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t); + } + + /* ─ density ─ */ + if (ctx.showDensity) { + layers.push( + new HexagonLayer({ + id: 'density', + data: ctx.shipLayerData, + pickable: true, + extruded: true, + radius: 2500, + elevationScale: 35, + coverage: 0.92, + opacity: 0.35, + getPosition: (d) => [d.lon, d.lat], + }), + ); + } + + /* ─ pair range ─ */ + if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'pair-range', + data: ctx.pairRanges, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: 'pixels', + getLineWidth: () => 1, + getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const p = info.object as PairRangeCircle; + ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]); + ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]); + ctx.clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { ctx.onSelectMmsi(null); return; } + const obj = info.object as PairRangeCircle; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) { + ctx.onToggleHighlightMmsi?.(obj.aMmsi); + ctx.onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + /* ─ pair lines ─ */ + if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) { + layers.push( + new LineLayer({ + id: 'pair-lines', + data: ctx.pairLinks, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), + getWidth: (d) => (d.warn ? 2.2 : 1.4), + widthUnits: 'pixels', + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const obj = info.object as PairLink; + ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); + ctx.clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as PairLink; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) { + ctx.onToggleHighlightMmsi?.(obj.aMmsi); + ctx.onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + /* ─ fc lines ─ */ + if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) { + layers.push( + new LineLayer({ + id: 'fc-lines', + data: ctx.fcDashed, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), + getWidth: () => 1.3, + widthUnits: 'pixels', + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; } + ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); + ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); + ctx.clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) return; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) { + ctx.onToggleHighlightMmsi?.(obj.fromMmsi); + ctx.onToggleHighlightMmsi?.(obj.toMmsi); + return; + } + ctx.onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); + }, + }), + ); + } + + /* ─ fleet circles ─ */ + if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: 'fleet-circles', + data: ctx.fleetCircles, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: 'pixels', + getLineWidth: () => 1.1, + getLineColor: () => FLEET_RANGE_LINE_DECK, + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const obj = info.object as FleetCircle; + const list = ctx.toFleetMmsiList(obj.vesselMmsis); + ctx.setMapFleetHoverState(obj.ownerKey || null, list); + ctx.setDeckHoverMmsi(list); + ctx.clearDeckHoverPairs(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as FleetCircle; + const list = ctx.toFleetMmsiList(obj.vesselMmsis); + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) { + for (const mmsi of list) ctx.onToggleHighlightMmsi?.(mmsi); + return; + } + const first = list[0]; + if (first != null) ctx.onDeckSelectOrHighlight({ mmsi: first }); + }, + }), + ); + layers.push( + new ScatterplotLayer({ + id: 'fleet-circles-fill', + data: ctx.fleetCircles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + getFillColor: () => FLEET_RANGE_FILL_DECK, + getPosition: (d) => d.center, + }), + ); + } + + /* ─ ships ─ */ + if (ctx.showShips) { + const shipOnHover = (info: PickingInfo) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const obj = info.object as AisTarget; + ctx.setDeckHoverMmsi([obj.mmsi]); + ctx.clearDeckHoverPairs(); + ctx.clearMapFleetHoverState(); + }; + const shipOnClick = (info: PickingInfo) => { + if (!info.object) { ctx.onSelectMmsi(null); return; } + ctx.onDeckSelectOrHighlight( + { + mmsi: (info.object as AisTarget).mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, + true, + ); + }; + + if (shipOtherData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-other', + data: shipOtherData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: getCachedShipIcon(), + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + + if (shipOverlayOtherData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-overlay-other', + data: shipOverlayOtherData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: getCachedShipIcon(), + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: (d) => { + if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet), + alphaCutoff: 0.05, + }), + ); + } + + if (ctx.legacyTargetsOrdered.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'legacy-halo', + data: ctx.legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'pixels', + getRadius: () => FLAT_LEGACY_HALO_RADIUS, + lineWidthUnits: 'pixels', + getLineWidth: () => 2, + getLineColor: () => HALO_OUTLINE_COLOR, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (shipTargetData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-target', + data: shipTargetData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: getCachedShipIcon(), + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + } + + /* ─ interactive overlays ─ */ + if (ctx.pairRangesInteractive.length > 0) { + layers.push(new ScatterplotLayer({ 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({ 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({ 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({ 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({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); + } + + /* ─ legacy overlay (highlight/selected) ─ */ + if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) { + layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + } + + if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) { + const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)); + layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } })); + } + + return layers; +} + +/* ── Globe Deck 오버레이 레이어 ────────────────────────── */ + +export interface GlobeDeckLayerContext { + pairRanges: PairRangeCircle[]; + pairLinks: PairLink[] | undefined; + fcDashed: DashSeg[]; + fleetCircles: FleetCircle[] | undefined; + legacyTargetsOrdered: AisTarget[]; + legacyHits: Map | null | undefined; + overlays: MapToggleState; + showShips: boolean; + selectedMmsi: number | null; + isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean; + isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean; + isHighlightedMmsi: (mmsi: number) => boolean; + touchDeckHoverState: (isHover: boolean) => void; + setDeckHoverPairs: (next: number[]) => void; + setDeckHoverMmsi: (next: number[]) => void; + clearDeckHoverPairs: () => void; + clearDeckHoverMmsi: () => void; + clearMapFleetHoverState: () => void; + setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void; + toFleetMmsiList: (value: unknown) => number[]; +} + +export function buildGlobeDeckLayers(ctx: GlobeDeckLayerContext): unknown[] { + const overlayParams = GLOBE_OVERLAY_PARAMS; + const globeLayers: unknown[] = []; + + if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) { + globeLayers.push(new ScatterplotLayer({ id: 'pair-range-globe', data: ctx.pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const p = info.object as PairRangeCircle; ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]); ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]); ctx.clearMapFleetHoverState(); } })); + } + + if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) { + const links = ctx.pairLinks || []; + globeLayers.push(new LineLayer({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as PairLink; ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); ctx.clearMapFleetHoverState(); } })); + } + + if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) { + globeLayers.push(new LineLayer({ id: 'fc-lines-globe', data: ctx.fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); ctx.clearMapFleetHoverState(); } })); + } + + if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) { + const circles = ctx.fleetCircles || []; + globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); ctx.clearMapFleetHoverState(); return; } ctx.touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = ctx.toFleetMmsiList(obj.vesselMmsis); ctx.setMapFleetHoverState(obj.ownerKey || null, list); ctx.setDeckHoverMmsi(list); ctx.clearDeckHoverPairs(); } })); + globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center })); + } + + if (ctx.showShips && ctx.legacyTargetsOrdered.length > 0) { + globeLayers.push(new ScatterplotLayer({ id: 'legacy-halo-globe', data: ctx.legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + } + + return globeLayers; +} -- 2.45.2 From 4d67b26ffa630c518d9446945bee750e6db4abc4 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:44:19 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor(map3d):=20useGlobeOverlays=20600?= =?UTF-8?q?=EC=A4=84=20=E2=86=92=20=EC=84=9C=EB=B8=8C=ED=9B=85=202+1?= =?UTF-8?q?=EA=B0=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGlobePairOverlay: pair lines + pair range + paint - useGlobeFcFleetOverlay: fc lines + fleet circles + paint - useGlobeOverlays: 오케스트레이터 (기존 호출부 호환) Co-Authored-By: Claude Opus 4.6 --- .../map3d/hooks/useGlobeFcFleetOverlay.ts | 356 +++++++++++ .../widgets/map3d/hooks/useGlobeOverlays.ts | 601 +----------------- .../map3d/hooks/useGlobePairOverlay.ts | 284 +++++++++ 3 files changed, 663 insertions(+), 578 deletions(-) create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts new file mode 100644 index 0000000..65c6a7e --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts @@ -0,0 +1,356 @@ +import { useCallback, useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { DashSeg, MapProjectionId } from '../types'; +import { + FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, + FLEET_FILL_ML_HL, + FLEET_LINE_ML, FLEET_LINE_ML_HL, +} from '../constants'; +import { makeUniqueSorted } from '../lib/setUtils'; +import { + makeFcSegmentFeatureId, + makeFleetCircleFeatureId, +} from '../lib/featureIds'; +import { + makeMmsiAnyEndpointExpr, + makeFleetOwnerMatchExpr, + makeFleetMemberMatchExpr, +} from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { circleRingLngLat } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; +import { dashifyLine } from '../lib/dashifyLine'; + +/** Globe FC lines + fleet circles 오버레이 */ +export function useGlobeFcFleetOverlay( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + overlays: MapToggleState; + fcLinks: FcLink[] | undefined; + fleetCircles: FleetCircle[] | undefined; + projection: MapProjectionId; + mapSyncEpoch: number; + hoveredFleetMmsiList: number[]; + hoveredFleetOwnerKeyList: string[]; + hoveredPairMmsiList: number[]; + }, +) { + const { + overlays, fcLinks, fleetCircles, projection, mapSyncEpoch, + hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, + } = opts; + + // FC lines + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'fc-lines-ml-src'; + const layerId = 'fc-lines-ml'; + + const remove = () => { + guardedSetVisibility(map, layerId, 'none'); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + const fcHoverActive = fleetAwarePairMmsiList.length > 0; + if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) { + remove(); + return; + } + + const segs: DashSeg[] = []; + for (const l of fcLinks || []) { + segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); + } + if (segs.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: segs.map((s, idx) => ({ + type: 'Feature', + id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), + geometry: { type: 'LineString', coordinates: [s.from, s.to] }, + properties: { + type: 'fc', + suspicious: s.suspicious, + distanceNm: s.distanceNm, + fcMmsi: s.fromMmsi ?? -1, + otherMmsi: s.toMmsi ?? -1, + }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('FC lines source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], + ['boolean', ['get', 'suspicious'], false], + FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML, + ] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never, + 'line-opacity': 0.9, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('FC lines layer add failed:', e); + } + } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [ + projection, + overlays.fcLines, + fcLinks, + hoveredPairMmsiList, + hoveredFleetMmsiList, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Fleet circles + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'fleet-circles-ml-src'; + const fillSrcId = 'fleet-circles-ml-fill-src'; + const layerId = 'fleet-circles-ml'; + const fillLayerId = 'fleet-circles-ml-fill'; + + const remove = () => { + guardedSetVisibility(map, layerId, 'none'); + guardedSetVisibility(map, fillLayerId, 'none'); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; + if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) { + remove(); + return; + } + + const circles = fleetCircles || []; + const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) => + hoveredFleetOwnerKeyList.includes(ownerKey) || + (hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi))); + + const fcLine: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: circles.map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: makeFleetCircleFeatureId(c.ownerKey), + geometry: { type: 'LineString', coordinates: ring }, + properties: { + type: 'fleet', + ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, + count: c.count, + vesselMmsis: c.vesselMmsis, + highlighted: 0, + }, + }; + }), + }; + + const fcFill: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: circles + .filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis)) + .map((c) => ({ + type: 'Feature', + id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`), + geometry: { + type: 'Polygon', + coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)], + }, + properties: { + ownerKey: c.ownerKey, + }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fcLine); + else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Fleet circles source setup failed:', e); + return; + } + + try { + const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; + if (existingFill) existingFill.setData(fcFill); + else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Fleet circles fill source setup failed:', e); + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never, + 'line-opacity': 0.85, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Fleet circles layer add failed:', e); + } + } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + if (!map.getLayer(fillLayerId)) { + try { + map.addLayer( + { + id: fillLayerId, + type: 'fill', + source: fillSrcId, + layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' }, + paint: { + 'fill-color': FLEET_FILL_ML_HL, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Fleet circles fill layer add failed:', e); + } + } else { + guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none'); + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [ + projection, + overlays.fleetCircles, + fleetCircles, + hoveredFleetOwnerKeyList, + hoveredFleetMmsiList, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // FC + Fleet paint state updates + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const updateFcFleetPaintStates = useCallback(() => { + if (projection !== 'globe' || projectionBusyRef.current) return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + + const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 + ? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList) + : false; + + const fleetOwnerMatchExpr = + hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; + const fleetMemberExpr = + hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; + const fleetHighlightExpr = + hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 + ? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never) + : false; + + try { + if (map.getLayer('fc-lines-ml')) { + map.setPaintProperty( + 'fc-lines-ml', 'line-color', + ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'fc-lines-ml', 'line-width', + ['case', fcEndpointHighlightExpr, 2.0, 1.3] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer('fleet-circles-ml')) { + map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); + map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); + } + } catch { + // ignore + } + }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const stop = onMapStyleReady(map, updateFcFleetPaintStates); + updateFcFleetPaintStates(); + return () => { + stop(); + }; + }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index b2261d6..6d3918b 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -1,35 +1,10 @@ -import { useCallback, useEffect, type MutableRefObject } from 'react'; +import type { MutableRefObject } from 'react'; import type maplibregl from 'maplibre-gl'; -import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; -import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types'; -import { - PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL, - PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, - FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, - FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, - FLEET_FILL_ML_HL, - FLEET_LINE_ML, FLEET_LINE_ML_HL, -} from '../constants'; -import { makeUniqueSorted } from '../lib/setUtils'; -import { - makePairLinkFeatureId, - makeFcSegmentFeatureId, - makeFleetCircleFeatureId, -} from '../lib/featureIds'; -import { - makeMmsiPairHighlightExpr, - makeMmsiAnyEndpointExpr, - makeFleetOwnerMatchExpr, - makeFleetMemberMatchExpr, -} from '../lib/mlExpressions'; -import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; -import { circleRingLngLat } from '../lib/geometry'; -import { guardedSetVisibility } from '../lib/layerHelpers'; -import { dashifyLine } from '../lib/dashifyLine'; +import type { MapProjectionId } from '../types'; +import { useGlobePairOverlay } from './useGlobePairOverlay'; +import { useGlobeFcFleetOverlay } from './useGlobeFcFleetOverlay'; export function useGlobeOverlays( mapRef: MutableRefObject, @@ -47,554 +22,24 @@ export function useGlobeOverlays( hoveredPairMmsiList: number[]; }, ) { - const { - overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch, - hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, - } = opts; + // Pair lines + pair range + useGlobePairOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { + overlays: opts.overlays, + pairLinks: opts.pairLinks, + projection: opts.projection, + mapSyncEpoch: opts.mapSyncEpoch, + hoveredPairMmsiList: opts.hoveredPairMmsiList, + }); - // Pair lines - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'pair-lines-ml-src'; - const layerId = 'pair-lines-ml'; - - const remove = () => { - guardedSetVisibility(map, layerId, 'none'); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - const pairHoverActive = hoveredPairMmsiList.length >= 2; - if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: (pairLinks || []).map((p) => ({ - type: 'Feature', - id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), - geometry: { type: 'LineString', coordinates: [p.from, p.to] }, - properties: { - type: 'pair', - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - warn: p.warn, - }, - })), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Pair lines source setup failed:', e); - return; - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'line', - source: srcId, - layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': [ - 'case', - ['==', ['get', 'highlighted'], 1], - ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], - ['boolean', ['get', 'warn'], false], - PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML, - ] as never, - 'line-width': [ - 'case', - ['==', ['get', 'highlighted'], 1], 2.8, - ['boolean', ['get', 'warn'], false], 2.2, - 1.4, - ] as never, - 'line-opacity': 0.9, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Pair lines layer add failed:', e); - } - } else { - guardedSetVisibility(map, layerId, 'visible'); - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); - - // FC lines - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'fc-lines-ml-src'; - const layerId = 'fc-lines-ml'; - - const remove = () => { - guardedSetVisibility(map, layerId, 'none'); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); - const fcHoverActive = fleetAwarePairMmsiList.length > 0; - if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) { - remove(); - return; - } - - const segs: DashSeg[] = []; - for (const l of fcLinks || []) { - segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); - } - if (segs.length === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: segs.map((s, idx) => ({ - type: 'Feature', - id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), - geometry: { type: 'LineString', coordinates: [s.from, s.to] }, - properties: { - type: 'fc', - suspicious: s.suspicious, - distanceNm: s.distanceNm, - fcMmsi: s.fromMmsi ?? -1, - otherMmsi: s.toMmsi ?? -1, - }, - })), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('FC lines source setup failed:', e); - return; - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'line', - source: srcId, - layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': [ - 'case', - ['==', ['get', 'highlighted'], 1], - ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], - ['boolean', ['get', 'suspicious'], false], - FC_LINE_SUSPICIOUS_ML, - FC_LINE_NORMAL_ML, - ] as never, - 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never, - 'line-opacity': 0.9, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('FC lines layer add failed:', e); - } - } else { - guardedSetVisibility(map, layerId, 'visible'); - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fcLines, - fcLinks, - hoveredPairMmsiList, - hoveredFleetMmsiList, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Fleet circles - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'fleet-circles-ml-src'; - const fillSrcId = 'fleet-circles-ml-fill-src'; - const layerId = 'fleet-circles-ml'; - const fillLayerId = 'fleet-circles-ml-fill'; - - // fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인 - // 라인만으로 fleet circle 시각화 충분 - - const remove = () => { - guardedSetVisibility(map, layerId, 'none'); - guardedSetVisibility(map, fillLayerId, 'none'); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; - if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) { - remove(); - return; - } - - const circles = fleetCircles || []; - const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) => - hoveredFleetOwnerKeyList.includes(ownerKey) || - (hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi))); - - const fcLine: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: circles.map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: 'Feature', - id: makeFleetCircleFeatureId(c.ownerKey), - geometry: { type: 'LineString', coordinates: ring }, - properties: { - type: 'fleet', - ownerKey: c.ownerKey, - ownerLabel: c.ownerLabel, - count: c.count, - vesselMmsis: c.vesselMmsis, - highlighted: 0, - }, - }; - }), - }; - - const fcFill: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: circles - .filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis)) - .map((c) => ({ - type: 'Feature', - id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`), - geometry: { - type: 'Polygon', - coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)], - }, - properties: { - ownerKey: c.ownerKey, - }, - })), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fcLine); - else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Fleet circles source setup failed:', e); - return; - } - - try { - const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; - if (existingFill) existingFill.setData(fcFill); - else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Fleet circles fill source setup failed:', e); - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'line', - source: srcId, - layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, - 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never, - 'line-opacity': 0.85, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Fleet circles layer add failed:', e); - } - } else { - guardedSetVisibility(map, layerId, 'visible'); - } - - if (!map.getLayer(fillLayerId)) { - try { - map.addLayer( - { - id: fillLayerId, - type: 'fill', - source: fillSrcId, - layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' }, - paint: { - 'fill-color': FLEET_FILL_ML_HL, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Fleet circles fill layer add failed:', e); - } - } else { - guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none'); - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fleetCircles, - fleetCircles, - hoveredFleetOwnerKeyList, - hoveredFleetMmsiList, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Pair range - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'pair-range-ml-src'; - const layerId = 'pair-range-ml'; - - const remove = () => { - guardedSetVisibility(map, layerId, 'none'); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - const pairHoverActive = hoveredPairMmsiList.length >= 2; - if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) { - remove(); - return; - } - - const ranges: PairRangeCircle[] = []; - for (const p of pairLinks || []) { - const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; - ranges.push({ - center, - radiusNm: Math.max(0.05, p.distanceNm / 2), - warn: p.warn, - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - }); - } - if (ranges.length === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: ranges.map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: 'Feature', - id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), - geometry: { type: 'LineString', coordinates: ring }, - properties: { - type: 'pair-range', - warn: c.warn, - aMmsi: c.aMmsi, - bMmsi: c.bMmsi, - distanceNm: c.distanceNm, - highlighted: 0, - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Pair range source setup failed:', e); - return; - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'line', - source: srcId, - layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': [ - 'case', - ['==', ['get', 'highlighted'], 1], - ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], - ['boolean', ['get', 'warn'], false], - PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML, - ] as never, - 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never, - 'line-opacity': 0.85, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Pair range layer add failed:', e); - } - } else { - guardedSetVisibility(map, layerId, 'visible'); - } - - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); - - // Paint state updates for hover highlights - // eslint-disable-next-line react-hooks/preserve-manual-memoization - const updateGlobeOverlayPaintStates = useCallback(() => { - if (projection !== 'globe' || projectionBusyRef.current) return; - - const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; - - const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); - - const pairHighlightExpr = hoveredPairMmsiList.length >= 2 - ? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList) - : false; - - const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 - ? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList) - : false; - - const fleetOwnerMatchExpr = - hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; - const fleetMemberExpr = - hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; - const fleetHighlightExpr = - hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 - ? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never) - : false; - - try { - if (map.getLayer('pair-lines-ml')) { - map.setPaintProperty( - 'pair-lines-ml', 'line-color', - ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never, - ); - map.setPaintProperty( - 'pair-lines-ml', 'line-width', - ['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never, - ); - } - } catch { - // ignore - } - - try { - if (map.getLayer('fc-lines-ml')) { - map.setPaintProperty( - 'fc-lines-ml', 'line-color', - ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never, - ); - map.setPaintProperty( - 'fc-lines-ml', 'line-width', - ['case', fcEndpointHighlightExpr, 2.0, 1.3] as never, - ); - } - } catch { - // ignore - } - - try { - if (map.getLayer('pair-range-ml')) { - map.setPaintProperty( - 'pair-range-ml', 'line-color', - ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never, - ); - map.setPaintProperty( - 'pair-range-ml', 'line-width', - ['case', pairHighlightExpr, 1.6, 1.0] as never, - ); - } - } catch { - // ignore - } - - try { - // fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인) - if (map.getLayer('fleet-circles-ml')) { - map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); - map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); - } - } catch { - // ignore - } - }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates); - updateGlobeOverlayPaintStates(); - return () => { - stop(); - }; - }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); + // FC lines + fleet circles + useGlobeFcFleetOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { + overlays: opts.overlays, + fcLinks: opts.fcLinks, + fleetCircles: opts.fleetCircles, + projection: opts.projection, + mapSyncEpoch: opts.mapSyncEpoch, + hoveredFleetMmsiList: opts.hoveredFleetMmsiList, + hoveredFleetOwnerKeyList: opts.hoveredFleetOwnerKeyList, + hoveredPairMmsiList: opts.hoveredPairMmsiList, + }); } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts new file mode 100644 index 0000000..41176a2 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts @@ -0,0 +1,284 @@ +import { useCallback, useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { PairLink } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { MapProjectionId, PairRangeCircle } from '../types'; +import { + PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL, + PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, +} from '../constants'; +import { makePairLinkFeatureId } from '../lib/featureIds'; +import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { circleRingLngLat } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; + +/** Globe pair lines + pair range 오버레이 */ +export function useGlobePairOverlay( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + overlays: MapToggleState; + pairLinks: PairLink[] | undefined; + projection: MapProjectionId; + mapSyncEpoch: number; + hoveredPairMmsiList: number[]; + }, +) { + const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts; + + // Pair lines + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'pair-lines-ml-src'; + const layerId = 'pair-lines-ml'; + + const remove = () => { + guardedSetVisibility(map, layerId, 'none'); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + const pairHoverActive = hoveredPairMmsiList.length >= 2; + if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: (pairLinks || []).map((p) => ({ + type: 'Feature', + id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), + geometry: { type: 'LineString', coordinates: [p.from, p.to] }, + properties: { + type: 'pair', + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + warn: p.warn, + }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Pair lines source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], + ['boolean', ['get', 'warn'], false], + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML, + ] as never, + 'line-width': [ + 'case', + ['==', ['get', 'highlighted'], 1], 2.8, + ['boolean', ['get', 'warn'], false], 2.2, + 1.4, + ] as never, + 'line-opacity': 0.9, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Pair lines layer add failed:', e); + } + } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Pair range + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'pair-range-ml-src'; + const layerId = 'pair-range-ml'; + + const remove = () => { + guardedSetVisibility(map, layerId, 'none'); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + const pairHoverActive = hoveredPairMmsiList.length >= 2; + if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) { + remove(); + return; + } + + const ranges: PairRangeCircle[] = []; + for (const p of pairLinks || []) { + const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; + ranges.push({ + center, + radiusNm: Math.max(0.05, p.distanceNm / 2), + warn: p.warn, + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + }); + } + if (ranges.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: ranges.map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), + geometry: { type: 'LineString', coordinates: ring }, + properties: { + type: 'pair-range', + warn: c.warn, + aMmsi: c.aMmsi, + bMmsi: c.bMmsi, + distanceNm: c.distanceNm, + highlighted: 0, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Pair range source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], + ['boolean', ['get', 'warn'], false], + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML, + ] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never, + 'line-opacity': 0.85, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Pair range layer add failed:', e); + } + } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Pair paint state updates + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const updatePairPaintStates = useCallback(() => { + if (projection !== 'globe' || projectionBusyRef.current) return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const pairHighlightExpr = hoveredPairMmsiList.length >= 2 + ? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList) + : false; + + try { + if (map.getLayer('pair-lines-ml')) { + map.setPaintProperty( + 'pair-lines-ml', 'line-color', + ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-lines-ml', 'line-width', + ['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer('pair-range-ml')) { + map.setPaintProperty( + 'pair-range-ml', 'line-color', + ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-range-ml', 'line-width', + ['case', pairHighlightExpr, 1.6, 1.0] as never, + ); + } + } catch { + // ignore + } + }, [projection, hoveredPairMmsiList]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const stop = onMapStyleReady(map, updatePairPaintStates); + updatePairPaintStates(); + return () => { + stop(); + }; + }, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]); +} -- 2.45.2 From ec03a88fbdd8b4e9f888d817c2d315a1b38ee026 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:55:58 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor(dashboard):=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20+=20=EC=83=81=ED=83=9C=20=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardPage.tsx (808줄) → 3파일 분리: - useDashboardState.ts (147줄): UI 상태 관리 훅 - DashboardSidebar.tsx (430줄): 좌측 사이드바 컴포넌트 - DashboardPage.tsx (295줄): 레이아웃 + 지도 영역 Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 595 +++--------------- .../src/pages/dashboard/DashboardSidebar.tsx | 430 +++++++++++++ .../src/pages/dashboard/useDashboardState.ts | 147 +++++ 3 files changed, 658 insertions(+), 514 deletions(-) create mode 100644 apps/web/src/pages/dashboard/DashboardSidebar.tsx create mode 100644 apps/web/src/pages/dashboard/useDashboardState.ts diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 734ae11..8ab706c 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,37 +1,22 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo } from "react"; import { useAuth } from "../../shared/auth"; -import { usePersistedState } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; -import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles"; -import type { MapToggleState } from "../../features/mapToggles/MapToggles"; -import { MapToggles } from "../../features/mapToggles/MapToggles"; -import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid"; -import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types"; -import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types"; +import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; +import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types"; import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels"; import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib"; -import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types"; +import type { VesselTypeCode } from "../../entities/vessel/model/types"; +import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta"; import { useZones } from "../../entities/zone/api/useZones"; import { useSubcables } from "../../entities/subcable/api/useSubcables"; -import type { VesselTypeCode } from "../../entities/vessel/model/types"; import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel"; -import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList"; -import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel"; import { MapLegend } from "../../widgets/legend/MapLegend"; -import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D"; -import type { MapViewState } from "../../widgets/map3d/types"; -import { RelationsPanel } from "../../widgets/relations/RelationsPanel"; -import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel"; +import { Map3D } from "../../widgets/map3d/Map3D"; import { Topbar } from "../../widgets/topbar/Topbar"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; -import { VesselList } from "../../widgets/vesselList/VesselList"; -import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { DepthLegend } from "../../widgets/legend/DepthLegend"; -import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types"; -import type { MapStyleSettings } from "../../features/mapSettings/types"; -import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime"; import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService"; import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore"; import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel"; @@ -39,6 +24,7 @@ import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolli import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay"; import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel"; import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; +import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { buildLegacyHitMap, computeCountsByType, @@ -49,18 +35,16 @@ import { deriveLegacyVessels, filterByShipCodes, } from "../../features/legacyDashboard/model/derive"; -import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta"; +import { useDashboardState } from "./useDashboardState"; +import type { Bbox } from "./useDashboardState"; +import { DashboardSidebar } from "./DashboardSidebar"; -const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, ""); const AIS_CENTER = { lon: 126.95, lat: 35.95, radiusMeters: 2_000_000, }; -type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax] -type FleetRelationSortMode = "count" | "range"; - function inBbox(lon: number, lat: number, bbox: Bbox) { const [lonMin, latMin, lonMax, latMax] = bbox; if (lat < latMin || lat > latMax) return false; @@ -68,34 +52,56 @@ function inBbox(lon: number, lat: number, bbox: Bbox) { return lon >= lonMin || lon <= lonMax; } -function fmtBbox(b: Bbox | null) { - if (!b) return "-"; - return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`; -} - -function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | null { +function useLegacyIndex(data: LegacyVesselDataset | null) { return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]); } export function DashboardPage() { const { user, logout } = useAuth(); + const uid = user?.id ?? null; + + // ── Data fetching ── const { data: zones, error: zonesError } = useZones(); const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: subcableData } = useSubcables(); const legacyIndex = useLegacyIndex(legacyData); + // ── UI state ── + const state = useDashboardState(uid); + const { + mapInstance, handleMapReady, + viewBbox, setViewBbox, + useViewportFilter, + useApiBbox, apiBbox, + selectedMmsi, setSelectedMmsi, + highlightedMmsiSet, + hoveredMmsiSet, setHoveredMmsiSet, + hoveredFleetMmsiSet, setHoveredFleetMmsiSet, + hoveredPairMmsiSet, setHoveredPairMmsiSet, + hoveredFleetOwnerKey, setHoveredFleetOwnerKey, + typeEnabled, + showTargets, showOthers, + baseMap, projection, + mapStyleSettings, setMapStyleSettings, + overlays, settings, + mapView, setMapView, + fleetFocus, setFleetFocus, + hoveredCableId, setHoveredCableId, + selectedCableId, setSelectedCableId, + trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu, + handleProjectionLoadingChange, + setIsGlobeShipsReady, + showMapLoader, + clock, adminMode, onLogoClick, + setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi, + alarmKindEnabled, + } = state; + + // ── Weather ── const weather = useWeatherPolling(zones); - const [mapInstance, setMapInstance] = useState(null); const weatherOverlay = useWeatherOverlay(mapInstance); - const handleMapReady = useCallback((map: import("maplibre-gl").Map) => { - setMapInstance(map); - }, []); - - const [viewBbox, setViewBbox] = useState(null); - const [useViewportFilter, setUseViewportFilter] = useState(false); - const [useApiBbox, setUseApiBbox] = useState(false); - const [apiBbox, setApiBbox] = useState(undefined); + // ── AIS polling ── const { targets, snapshot } = useAisTargetPolling({ chnprmshipMinutes: 120, incrementalMinutes: 2, @@ -107,48 +113,7 @@ export function DashboardPage() { radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters, }); - const [selectedMmsi, setSelectedMmsi] = useState(null); - const [highlightedMmsiSet, setHighlightedMmsiSet] = useState([]); - const [hoveredMmsiSet, setHoveredMmsiSet] = useState([]); - const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); - const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); - const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); - const uid = user?.id ?? null; - const [typeEnabled, setTypeEnabled] = usePersistedState>( - uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true }, - ); - const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true); - const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false); - - // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [baseMap, _setBaseMap] = useState("enhanced"); - // 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환 - const [projection, setProjection] = useState('mercator'); - const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); - - const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { - pairLines: true, pairRange: true, fcLines: true, zones: true, - fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, - }); - const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState(uid, 'sortMode', "count"); - - const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState>( - uid, 'alarmKindEnabled', - () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record, - ); - - const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); - - const [hoveredCableId, setHoveredCableId] = useState(null); - const [selectedCableId, setSelectedCableId] = useState(null); - - // 항적 (vessel track) - const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null); - const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => { - setTrackContextMenu(info); - }, []); - const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []); + // ── Track request ── const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { const trackStore = useTrackQueryStore.getState(); const queryKey = `${mmsi}:${minutes}:${Date.now()}`; @@ -172,40 +137,7 @@ export function DashboardPage() { } }, [targets]); - const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { - showShips: true, showDensity: false, showSeamark: false, - }); - const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); - - const [isProjectionLoading, setIsProjectionLoading] = useState(false); - // 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화 - const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false); - const handleProjectionLoadingChange = useCallback((loading: boolean) => { - setIsProjectionLoading(loading); - }, []); - const showMapLoader = isProjectionLoading; - // globe 레이어 미준비 또는 전환 중일 때 토글 비활성화 - const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading; - - const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); - useEffect(() => { - const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000); - return () => window.clearInterval(id); - }, []); - - // Secret admin toggle: 7 clicks within 900ms on the logo. - const [adminMode, setAdminMode] = useState(false); - const clicksRef = useRef([]); - const onLogoClick = () => { - const now = Date.now(); - clicksRef.current = clicksRef.current.filter((t) => now - t < 900); - clicksRef.current.push(now); - if (clicksRef.current.length >= 7) { - clicksRef.current = []; - setAdminMode((v) => !v); - } - }; - + // ── Derived data ── const targetsInScope = useMemo(() => { if (!useViewportFilter || !viewBbox) return targets; return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox)); @@ -244,17 +176,14 @@ export function DashboardPage() { const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]); const alarmKindCounts = useMemo(() => { - const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record; + const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record; for (const a of alarms) { base[a.kind] = (base[a.kind] ?? 0) + 1; } return base; }, [alarms]); - const enabledAlarmKinds = useMemo(() => { - return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]); - }, [alarmKindEnabled]); - + const enabledAlarmKinds = useMemo(() => LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]), [alarmKindEnabled]); const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length; const filteredAlarms = useMemo(() => { @@ -291,13 +220,12 @@ export function DashboardPage() { [highlightedMmsiSet, availableTargetMmsiSet], ); - const setUniqueSorted = (items: number[]) => - Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b); + const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length; + const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length; - const setSortedIfChanged = (next: number[]) => { - const sorted = setUniqueSorted(next); - return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted); - }; + const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]); + const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode; + const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKinds.length}/${LEGACY_ALARM_KINDS.length}`; const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => { if (!mmsis.length) return; @@ -312,30 +240,10 @@ export function DashboardPage() { const sumLon = members.reduce((acc, v) => acc + v.lon, 0); const sumLat = members.reduce((acc, v) => acc + v.lat, 0); const center: [number, number] = [sumLon / members.length, sumLat / members.length]; - setFleetFocus({ - id: `${ownerKey}-${Date.now()}`, - center, - zoom: 9, - }); + setFleetFocus({ id: `${ownerKey}-${Date.now()}`, center, zoom: 9 }); }; - const toggleHighlightedMmsi = (mmsi: number) => { - setHighlightedMmsiSet((prev) => { - const next = new Set(prev); - if (next.has(mmsi)) next.delete(mmsi); - else next.add(mmsi); - return Array.from(next).sort((a, b) => a - b); - }); - }; - - const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length; - const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length; - - const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]); - const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode; - const enabledAlarmKindCount = enabledAlarmKinds.length; - const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`; - + // ── Render ── return (
-
-
-
업종 필터
-
-
{ - setShowTargets((v) => { - const next = !v; - if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m)); - return next; - }); - }} - title="레거시(CN permit) 대상 선박 표시" - > - 대상 선박 -
-
setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시"> - 기타 AIS -
-
- { - // When hiding the currently selected legacy vessel's type, clear selection. - if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null); - setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] })); - }} - onToggleAll={() => { - const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]); - const nextVal = !allOn; // any-off -> true, all-on -> false - if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null); - setTypeEnabled({ - PT: nextVal, - "PT-S": nextVal, - GN: nextVal, - OT: nextVal, - PS: nextVal, - FC: nextVal, - }); - }} - /> -
- -
-
- 지도 표시 설정 -
-
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 -
-
- setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> - {/* 베이스맵 선택 — 현재 enhanced 단일 맵 사용, 레거시는 비활성 -
-
setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)"> - 기본 -
-
setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵"> - 레거시 -
-
*/} -
- -
-
속도 프로파일
- -
- -
-
-
- 선단 연관관계{" "} - - {selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"} - -
-
- - -
-
-
- setHoveredMmsiSet(setUniqueSorted(mmsis))} - onClearHover={() => setHoveredMmsiSet([])} - onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} - onClearPairHover={() => setHoveredPairMmsiSet([])} - onHoverFleet={(ownerKey, fleetMmsis) => { - setHoveredFleetOwnerKey(ownerKey); - setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); - }} - onClearFleetHover={() => { - setHoveredFleetOwnerKey(null); - setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); - }} - fleetSortMode={fleetRelationSortMode} - hoveredFleetOwnerKey={hoveredFleetOwnerKey} - hoveredFleetMmsiSet={hoveredFleetMmsiSet} - onContextMenuFleet={handleFleetContextMenu} - /> -
-
- -
-
- 선박 목록{" "} - - ({legacyVesselsFiltered.length}척) - -
- setHoveredMmsiSet([mmsi])} - onClearHover={() => setHoveredMmsiSet([])} - /> -
- -
-
-
- 실시간 경고{" "} - - ({filteredAlarms.length}/{alarms.length}) - -
- - {LEGACY_ALARM_KINDS.length <= 3 ? ( -
- {LEGACY_ALARM_KINDS.map((k) => ( - - ))} -
- ) : ( -
- - {alarmFilterSummary} - -
- -
- {LEGACY_ALARM_KINDS.map((k) => ( - - ))} -
-
- )} -
- -
- -
-
- - {adminMode ? ( - <> -
-
ADMIN · AIS Target Polling
-
-
엔드포인트
-
{AIS_API_BASE}/api/ais-target/search
-
상태
-
- - {snapshot.status.toUpperCase()} - - {snapshot.error ? {snapshot.error} : null} -
-
최근 fetch
-
- {fmtIsoFull(snapshot.lastFetchAt)}{" "} - - ({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) - -
-
메시지
-
{snapshot.lastMessage ?? "-"}
-
-
- -
-
ADMIN · Legacy (CN Permit)
- {legacyError ? ( -
legacy load error: {legacyError}
- ) : ( -
-
데이터셋
-
/data/legacy/chinese-permitted.v1.json
-
매칭(현재 scope)
-
- {legacyVesselsAll.length}{" "} - / {targetsInScope.length} -
-
생성시각
-
{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}
-
- )} -
- -
-
ADMIN · Viewport / BBox
-
-
현재 View BBox
-
{fmtBbox(viewBbox)}
-
- - - - - -
-
- 표시 선박: {targetsInScope.length} / 스토어:{" "} - {snapshot.total} -
-
-
- -
-
ADMIN · Map (Extras)
- setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} /> -
단일 WebGL 컨텍스트: MapboxOverlay(interleaved)
-
- -
-
ADMIN · AIS Targets (All)
- -
- -
-
ADMIN · 수역 데이터
- {zonesError ? ( -
zones load error: {zonesError}
- ) : ( -
- {zones ? `loaded (${zones.features.length} features)` : "loading..."} -
- )} -
- - ) : null} -
+
{showMapLoader ? ( diff --git a/apps/web/src/pages/dashboard/DashboardSidebar.tsx b/apps/web/src/pages/dashboard/DashboardSidebar.tsx new file mode 100644 index 0000000..82ef6b3 --- /dev/null +++ b/apps/web/src/pages/dashboard/DashboardSidebar.tsx @@ -0,0 +1,430 @@ +import type { AisTarget } from '../../entities/aisTarget/model/types'; +import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib'; +import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; +import type { VesselTypeCode } from '../../entities/vessel/model/types'; +import { VESSEL_TYPE_ORDER } from '../../entities/vessel/model/meta'; +import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; +import type { AisPollingSnapshot } from '../../features/aisPolling/useAisTargetPolling'; +import { Map3DSettingsToggles } from '../../features/map3dSettings/Map3DSettingsToggles'; +import type { DerivedLegacyVessel, LegacyAlarm, LegacyAlarmKind } from '../../features/legacyDashboard/model/types'; +import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types'; +import { MapToggles } from '../../features/mapToggles/MapToggles'; +import { TypeFilterGrid } from '../../features/typeFilter/TypeFilterGrid'; +import { AisTargetList } from '../../widgets/aisTargetList/AisTargetList'; +import { AlarmsPanel } from '../../widgets/alarms/AlarmsPanel'; +import { RelationsPanel } from '../../widgets/relations/RelationsPanel'; +import { SpeedProfilePanel } from '../../widgets/speed/SpeedProfilePanel'; +import { VesselList } from '../../widgets/vesselList/VesselList'; +import { fmtIsoFull } from '../../shared/lib/datetime'; +import type { useDashboardState } from './useDashboardState'; +import type { Bbox } from './useDashboardState'; + +const AIS_API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); + +function fmtBbox(b: Bbox | null) { + if (!b) return '-'; + return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`; +} + +interface DashboardSidebarProps { + state: ReturnType; + // Derived data + legacyVesselsAll: DerivedLegacyVessel[]; + legacyVesselsFiltered: DerivedLegacyVessel[]; + legacyCounts: Record; + selectedLegacyVessel: DerivedLegacyVessel | null; + activeHighlightedMmsiSet: number[]; + legacyHits: Map; + filteredAlarms: LegacyAlarm[]; + alarms: LegacyAlarm[]; + alarmKindCounts: Record; + allAlarmKindsEnabled: boolean; + alarmFilterSummary: string; + speedPanelType: VesselTypeCode; + onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void; + // Data fetching (admin panels) + snapshot: AisPollingSnapshot; + legacyError: string | null; + legacyData: LegacyVesselDataset | null; + targetsInScope: AisTarget[]; + zonesError: string | null; + zones: ZonesGeoJson | null; + legacyIndex: LegacyVesselIndex | null; +} + +export function DashboardSidebar({ + state, + legacyVesselsAll, + legacyVesselsFiltered, + legacyCounts, + selectedLegacyVessel, + activeHighlightedMmsiSet, + legacyHits, + filteredAlarms, + alarms, + alarmKindCounts, + allAlarmKindsEnabled, + alarmFilterSummary, + speedPanelType, + onFleetContextMenu, + snapshot, + legacyError, + legacyData, + targetsInScope, + zonesError, + zones, + legacyIndex, +}: DashboardSidebarProps) { + const { + showTargets, setShowTargets, showOthers, setShowOthers, + typeEnabled, setTypeEnabled, + overlays, setOverlays, + projection, setProjection, isProjectionToggleDisabled, + selectedMmsi, setSelectedMmsi, + fleetRelationSortMode, setFleetRelationSortMode, + hoveredFleetOwnerKey, hoveredFleetMmsiSet, + setHoveredMmsiSet, setHoveredPairMmsiSet, + setHoveredFleetOwnerKey, setHoveredFleetMmsiSet, + alarmKindEnabled, setAlarmKindEnabled, + adminMode, + viewBbox, useViewportFilter, setUseViewportFilter, + useApiBbox, setUseApiBbox, setApiBbox, + settings, setSettings, + setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi, + } = state; + + return ( +
+
+
업종 필터
+
+
{ + setShowTargets((v) => { + const next = !v; + if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m)); + return next; + }); + }} + title="레거시(CN permit) 대상 선박 표시" + > + 대상 선박 +
+
setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시"> + 기타 AIS +
+
+ { + 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, + }); + }} + /> +
+ +
+
+ 지도 표시 설정 +
+
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 +
+
+ setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> +
+ +
+
속도 프로파일
+ +
+ +
+
+
+ 선단 연관관계{' '} + + {selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'} + +
+
+ + +
+
+
+ setHoveredMmsiSet(setUniqueSorted(mmsis))} + onClearHover={() => setHoveredMmsiSet([])} + onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} + onClearPairHover={() => setHoveredPairMmsiSet([])} + onHoverFleet={(ownerKey, fleetMmsis) => { + setHoveredFleetOwnerKey(ownerKey); + setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); + }} + onClearFleetHover={() => { + setHoveredFleetOwnerKey(null); + setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); + }} + fleetSortMode={fleetRelationSortMode} + hoveredFleetOwnerKey={hoveredFleetOwnerKey} + hoveredFleetMmsiSet={hoveredFleetMmsiSet} + onContextMenuFleet={onFleetContextMenu} + /> +
+
+ +
+
+ 선박 목록{' '} + + ({legacyVesselsFiltered.length}척) + +
+ setHoveredMmsiSet([mmsi])} + onClearHover={() => setHoveredMmsiSet([])} + /> +
+ +
+
+
+ 실시간 경고{' '} + + ({filteredAlarms.length}/{alarms.length}) + +
+ + {LEGACY_ALARM_KINDS.length <= 3 ? ( +
+ {LEGACY_ALARM_KINDS.map((k) => ( + + ))} +
+ ) : ( +
+ + {alarmFilterSummary} + +
+ +
+ {LEGACY_ALARM_KINDS.map((k) => ( + + ))} +
+
+ )} +
+ +
+ +
+
+ + {adminMode ? ( + <> +
+
ADMIN · AIS Target Polling
+
+
엔드포인트
+
{AIS_API_BASE}/api/ais-target/search
+
상태
+
+ + {snapshot.status.toUpperCase()} + + {snapshot.error ? {snapshot.error} : null} +
+
최근 fetch
+
+ {fmtIsoFull(snapshot.lastFetchAt)}{' '} + + ({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) + +
+
메시지
+
{snapshot.lastMessage ?? '-'}
+
+
+ +
+
ADMIN · Legacy (CN Permit)
+ {legacyError ? ( +
legacy load error: {legacyError}
+ ) : ( +
+
데이터셋
+
/data/legacy/chinese-permitted.v1.json
+
매칭(현재 scope)
+
+ {legacyVesselsAll.length}{' '} + / {targetsInScope.length} +
+
생성시각
+
{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}
+
+ )} +
+ +
+
ADMIN · Viewport / BBox
+
+
현재 View BBox
+
{fmtBbox(viewBbox)}
+
+ + + + + +
+
+ 표시 선박: {targetsInScope.length} / 스토어:{' '} + {snapshot.total} +
+
+
+ +
+
ADMIN · Map (Extras)
+ setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} /> +
단일 WebGL 컨텍스트: MapboxOverlay(interleaved)
+
+ +
+
ADMIN · AIS Targets (All)
+ +
+ +
+
ADMIN · 수역 데이터
+ {zonesError ? ( +
zones load error: {zonesError}
+ ) : ( +
+ {zones ? `loaded (${zones.features.length} features)` : 'loading...'} +
+ )} +
+ + ) : null} +
+ ); +} diff --git a/apps/web/src/pages/dashboard/useDashboardState.ts b/apps/web/src/pages/dashboard/useDashboardState.ts new file mode 100644 index 0000000..f3e4034 --- /dev/null +++ b/apps/web/src/pages/dashboard/useDashboardState.ts @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { usePersistedState } from '../../shared/hooks'; +import type { VesselTypeCode } from '../../entities/vessel/model/types'; +import type { MapToggleState } from '../../features/mapToggles/MapToggles'; +import type { LegacyAlarmKind } from '../../features/legacyDashboard/model/types'; +import { LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types'; +import type { BaseMapId, Map3DSettings, MapProjectionId } from '../../widgets/map3d/Map3D'; +import type { MapViewState } from '../../widgets/map3d/types'; +import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types'; +import type { MapStyleSettings } from '../../features/mapSettings/types'; +import { fmtDateTimeFull } from '../../shared/lib/datetime'; + +export type Bbox = [number, number, number, number]; +export type FleetRelationSortMode = 'count' | 'range'; + +export function useDashboardState(uid: number | null) { + // ── Map instance ── + const [mapInstance, setMapInstance] = useState(null); + const handleMapReady = useCallback((map: import('maplibre-gl').Map) => setMapInstance(map), []); + + // ── Viewport / API BBox ── + const [viewBbox, setViewBbox] = useState(null); + const [useViewportFilter, setUseViewportFilter] = useState(false); + const [useApiBbox, setUseApiBbox] = useState(false); + const [apiBbox, setApiBbox] = useState(undefined); + + // ── Selection & hover ── + const [selectedMmsi, setSelectedMmsi] = useState(null); + const [highlightedMmsiSet, setHighlightedMmsiSet] = useState([]); + const [hoveredMmsiSet, setHoveredMmsiSet] = useState([]); + const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); + const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); + const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); + + // ── Filters (persisted) ── + const [typeEnabled, setTypeEnabled] = usePersistedState>( + uid, 'typeEnabled', { PT: true, 'PT-S': true, GN: true, OT: true, PS: true, FC: true }, + ); + const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true); + const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false); + + // ── Map settings (persisted) ── + // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [baseMap, _setBaseMap] = useState('enhanced'); + const [projection, setProjection] = useState('mercator'); + const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); + const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { + pairLines: true, pairRange: true, fcLines: true, zones: true, + fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, + }); + const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { + showShips: true, showDensity: false, showSeamark: false, + }); + const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); + + // ── Sort & alarm filters (persisted) ── + const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState(uid, 'sortMode', 'count'); + const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState>( + uid, 'alarmKindEnabled', + () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record, + ); + + // ── Fleet focus ── + const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); + + // ── Cable ── + const [hoveredCableId, setHoveredCableId] = useState(null); + const [selectedCableId, setSelectedCableId] = useState(null); + + // ── Track context menu ── + const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null); + const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => setTrackContextMenu(info), []); + const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []); + + // ── Projection loading ── + const [isProjectionLoading, setIsProjectionLoading] = useState(false); + const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false); + const handleProjectionLoadingChange = useCallback((loading: boolean) => setIsProjectionLoading(loading), []); + const showMapLoader = isProjectionLoading; + const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading; + + // ── Clock ── + const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); + useEffect(() => { + const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000); + return () => window.clearInterval(id); + }, []); + + // ── Admin mode (7 clicks within 900ms) ── + const [adminMode, setAdminMode] = useState(false); + const clicksRef = useRef([]); + const onLogoClick = () => { + const now = Date.now(); + clicksRef.current = clicksRef.current.filter((t) => now - t < 900); + clicksRef.current.push(now); + if (clicksRef.current.length >= 7) { + clicksRef.current = []; + setAdminMode((v) => !v); + } + }; + + // ── Helpers ── + const setUniqueSorted = (items: number[]) => + Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b); + + const setSortedIfChanged = (next: number[]) => { + const sorted = setUniqueSorted(next); + return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted); + }; + + const toggleHighlightedMmsi = (mmsi: number) => { + setHighlightedMmsiSet((prev) => { + const s = new Set(prev); + if (s.has(mmsi)) s.delete(mmsi); + else s.add(mmsi); + return Array.from(s).sort((a, b) => a - b); + }); + }; + + return { + mapInstance, handleMapReady, + viewBbox, setViewBbox, useViewportFilter, setUseViewportFilter, + useApiBbox, setUseApiBbox, apiBbox, setApiBbox, + selectedMmsi, setSelectedMmsi, + highlightedMmsiSet, + hoveredMmsiSet, setHoveredMmsiSet, + hoveredFleetMmsiSet, setHoveredFleetMmsiSet, + hoveredPairMmsiSet, setHoveredPairMmsiSet, + hoveredFleetOwnerKey, setHoveredFleetOwnerKey, + typeEnabled, setTypeEnabled, showTargets, setShowTargets, showOthers, setShowOthers, + baseMap, projection, setProjection, + mapStyleSettings, setMapStyleSettings, + overlays, setOverlays, settings, setSettings, + mapView, setMapView, + fleetRelationSortMode, setFleetRelationSortMode, + alarmKindEnabled, setAlarmKindEnabled, + fleetFocus, setFleetFocus, + hoveredCableId, setHoveredCableId, selectedCableId, setSelectedCableId, + trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu, + handleProjectionLoadingChange, + isGlobeShipsReady, setIsGlobeShipsReady, + showMapLoader, isProjectionToggleDisabled, + clock, adminMode, onLogoClick, + setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi, + }; +} -- 2.45.2 From 3fa0b67e9780f70c02155692be04a2ae98e6a795 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Feb 2026 00:01:04 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor(styles):=20CSS=201,814=EC=A4=84=20?= =?UTF-8?q?=E2=86=92=2015=EA=B0=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit styles.css 모놀리스를 @import 기반 모듈 구조로 분리: - base.css: CSS 변수, 리셋, 폰트 - layout.css: 그리드 레이아웃, 반응형 - components/: topbar, panels, toggles, speed, vessel-list, ais-list, alarms, relations, map-panels, map-settings, auth, weather, weather-overlay Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/styles.css | 1833 +---------------- apps/web/src/app/styles/base.css | 36 + .../src/app/styles/components/ais-list.css | 159 ++ apps/web/src/app/styles/components/alarms.css | 95 + apps/web/src/app/styles/components/auth.css | 126 ++ .../src/app/styles/components/map-panels.css | 211 ++ .../app/styles/components/map-settings.css | 175 ++ apps/web/src/app/styles/components/panels.css | 77 + .../src/app/styles/components/relations.css | 95 + apps/web/src/app/styles/components/speed.css | 33 + .../web/src/app/styles/components/toggles.css | 75 + apps/web/src/app/styles/components/topbar.css | 84 + .../src/app/styles/components/vessel-list.css | 61 + .../app/styles/components/weather-overlay.css | 356 ++++ .../web/src/app/styles/components/weather.css | 185 ++ apps/web/src/app/styles/layout.css | 30 + 16 files changed, 1817 insertions(+), 1814 deletions(-) create mode 100644 apps/web/src/app/styles/base.css create mode 100644 apps/web/src/app/styles/components/ais-list.css create mode 100644 apps/web/src/app/styles/components/alarms.css create mode 100644 apps/web/src/app/styles/components/auth.css create mode 100644 apps/web/src/app/styles/components/map-panels.css create mode 100644 apps/web/src/app/styles/components/map-settings.css create mode 100644 apps/web/src/app/styles/components/panels.css create mode 100644 apps/web/src/app/styles/components/relations.css create mode 100644 apps/web/src/app/styles/components/speed.css create mode 100644 apps/web/src/app/styles/components/toggles.css create mode 100644 apps/web/src/app/styles/components/topbar.css create mode 100644 apps/web/src/app/styles/components/vessel-list.css create mode 100644 apps/web/src/app/styles/components/weather-overlay.css create mode 100644 apps/web/src/app/styles/components/weather.css create mode 100644 apps/web/src/app/styles/layout.css diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 9e8c645..a57393e 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -1,1814 +1,19 @@ -@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap"); - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --bg: #020617; - --panel: #0f172a; - --card: #1e293b; - --border: #1e3a5f; - --text: #e2e8f0; - --muted: #64748b; - --accent: #3b82f6; - - --crit: #ef4444; - --high: #f59e0b; -} - -html, -body { - height: 100%; -} - -body { - font-family: "Noto Sans KR", sans-serif; - background: var(--bg); - color: var(--text); - overflow: hidden; -} - -#root { - height: 100%; -} - -.app { - display: grid; - grid-template-columns: 310px 1fr; - grid-template-rows: 44px 1fr; - height: 100vh; -} - -.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; -} - -.sidebar { - background: var(--panel); - border-right: 1px solid var(--border); - overflow-y: auto; - display: flex; - flex-direction: column; -} - -.map-area { - position: relative; - background: #010610; -} - -.sb { - padding: 10px 12px; - border-bottom: 1px solid var(--border); -} - -.sb-t { - font-size: 9px; - font-weight: 700; - color: var(--muted); - letter-spacing: 1.5px; - text-transform: uppercase; - margin-bottom: 6px; -} - -.sb-t-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.relation-sort { - display: flex; - align-items: center; - gap: 6px; - font-size: 8px; - color: var(--muted); - white-space: nowrap; -} - -.relation-sort__option { - display: inline-flex; - align-items: center; - gap: 3px; - cursor: pointer; - user-select: none; -} - -/* 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); -} - -/* Speed bar */ -.sbar { - position: relative; - height: 24px; - background: var(--bg); - border-radius: 5px; - overflow: hidden; - margin: 4px 0; -} - -.sseg { - position: absolute; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - font-size: 7px; - color: #fff; - font-weight: 600; -} - -.sseg.p { - height: 24px; - top: 0; - border: 1.5px solid rgba(255, 255, 255, 0.25); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); -} - -.sseg:not(.p) { - height: 16px; - top: 4px; - opacity: 0.6; -} - -/* Vessel list */ -.vlist { - flex: 1; - min-height: 0; - overflow-y: auto; -} - -.vi { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 6px; - border-radius: 3px; - cursor: pointer; - font-size: 10px; - transition: background 0.1s; - user-select: none; -} - -.vi:hover { - background: var(--card); -} - -.vi.sel { - background: rgba(14, 234, 255, 0.16); - border-color: rgba(14, 234, 255, 0.55); -} - -.vi.hl { - background: rgba(245, 158, 11, 0.16); - border: 1px solid rgba(245, 158, 11, 0.4); -} - -.vi.sel.hl { - background: linear-gradient(90deg, rgba(14, 234, 255, 0.16), rgba(245, 158, 11, 0.16)); - border-color: rgba(14, 234, 255, 0.7); -} - -.vi .dot { - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} - -.vi .nm { - flex: 1; - font-weight: 500; -} - -.vi .sp { - font-weight: 700; - font-size: 9px; -} - -.vi .st { - font-size: 7px; - padding: 1px 3px; - border-radius: 2px; - font-weight: 600; -} - -/* AIS target list */ -.ais-q { - flex: 1; - font-size: 10px; - padding: 6px 8px; - border-radius: 6px; - border: 1px solid var(--border); - background: rgba(30, 41, 59, 0.75); - color: var(--text); - outline: none; -} - -.ais-q::placeholder { - color: rgba(100, 116, 139, 0.9); -} - -.ais-mode { - display: flex; - border: 1px solid var(--border); - border-radius: 6px; - overflow: hidden; -} - -.ais-mode-btn { - font-size: 9px; - padding: 0 8px; - height: 28px; - border: none; - background: var(--card); - color: var(--muted); - cursor: pointer; -} - -.ais-mode-btn.on { - background: rgba(59, 130, 246, 0.18); - color: var(--text); -} - -.ais-list { - flex: 1; - min-height: 0; - overflow-y: auto; -} - -.ais-row { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 6px; - border-radius: 6px; - cursor: pointer; - user-select: none; - transition: background 0.12s, border-color 0.12s; - border: 1px solid transparent; -} - -.ais-row:hover { - background: rgba(30, 41, 59, 0.6); - border-color: rgba(30, 58, 95, 0.8); -} - -.ais-row.sel { - background: rgba(59, 130, 246, 0.14); - border-color: rgba(59, 130, 246, 0.55); -} - -.ais-dot { - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} - -.ais-nm { - flex: 1; - min-width: 0; -} - -.ais-nm1 { - font-size: 10px; - font-weight: 700; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ais-nm2 { - font-size: 9px; - color: var(--muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ais-right { - text-align: right; - flex-shrink: 0; -} - -.ais-badges { - display: flex; - gap: 4px; - justify-content: flex-end; - margin-bottom: 2px; - flex-wrap: wrap; -} - -.ais-badge { - font-size: 8px; - padding: 1px 5px; - border-radius: 5px; - border: 1px solid rgba(255, 255, 255, 0.08); - font-weight: 800; - letter-spacing: 0.2px; - color: #fff; - background: rgba(100, 116, 139, 0.22); -} - -.ais-badge.pn { - color: var(--muted); - background: rgba(30, 41, 59, 0.55); - border-color: rgba(30, 58, 95, 0.9); - font-weight: 700; -} - -.ais-badge.PT { - background: rgba(30, 64, 175, 0.28); - border-color: rgba(30, 64, 175, 0.7); -} -.ais-badge.PT-S { - background: rgba(234, 88, 12, 0.22); - border-color: rgba(234, 88, 12, 0.7); -} -.ais-badge.GN { - background: rgba(16, 185, 129, 0.22); - border-color: rgba(16, 185, 129, 0.7); -} -.ais-badge.OT { - background: rgba(139, 92, 246, 0.22); - border-color: rgba(139, 92, 246, 0.7); -} -.ais-badge.PS { - background: rgba(239, 68, 68, 0.22); - border-color: rgba(239, 68, 68, 0.7); -} -.ais-badge.FC { - background: rgba(245, 158, 11, 0.22); - border-color: rgba(245, 158, 11, 0.7); -} - -.ais-sp { - font-size: 10px; - font-weight: 800; -} - -.ais-ts { - font-size: 9px; - color: rgba(100, 116, 139, 0.9); -} - -/* Alarm */ -.ai { - display: flex; - gap: 6px; - padding: 4px 6px; - border-radius: 3px; - margin-bottom: 2px; - font-size: 9px; - border-left: 3px solid; -} - -.ai.cr { - border-color: var(--crit); - background: rgba(239, 68, 68, 0.07); -} - -.ai.hi { - border-color: var(--high); - background: rgba(245, 158, 11, 0.05); -} - -.ai .at { - color: var(--muted); - font-size: 8px; - white-space: nowrap; -} - -/* Alarm filter (dropdown) */ -.alarm-filter { - position: relative; -} - -.alarm-filter__summary { - list-style: none; - cursor: pointer; - padding: 2px 8px; - border-radius: 6px; - border: 1px solid var(--border); - background: rgba(30, 41, 59, 0.55); - color: var(--text); - font-size: 8px; - font-weight: 700; - letter-spacing: 0.4px; - user-select: none; - white-space: nowrap; -} - -.alarm-filter__summary::-webkit-details-marker { - display: none; -} - -.alarm-filter__menu { - position: absolute; - right: 0; - top: 22px; - z-index: 2000; - min-width: 170px; - padding: 6px; - border-radius: 10px; - border: 1px solid var(--border); - background: rgba(15, 23, 42, 0.98); - box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55); -} - -.alarm-filter__row { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 6px; - border-radius: 6px; - cursor: pointer; - font-size: 10px; - color: var(--text); - user-select: none; -} - -.alarm-filter__row:hover { - background: rgba(59, 130, 246, 0.08); -} - -.alarm-filter__row input { - cursor: pointer; -} - -.alarm-filter__cnt { - margin-left: auto; - font-size: 9px; - color: var(--muted); -} - -.alarm-filter__sep { - height: 1px; - background: rgba(30, 58, 95, 0.85); - margin: 4px 0; -} - -/* Relation panel */ -.rel-panel { - background: var(--card); - border-radius: 6px; - padding: 8px; - margin-top: 4px; -} - -.rel-header { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 6px; -} - -.rel-badge { - font-size: 9px; - padding: 1px 5px; - border-radius: 3px; - font-weight: 700; -} - -.rel-line { - display: flex; - align-items: center; - gap: 4px; - font-size: 10px; - padding: 2px 0; -} - -.rel-line .dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; -} - -.rel-link { - width: 20px; - display: flex; - align-items: center; - justify-content: center; - color: var(--muted); - font-size: 10px; -} - -.rel-dist { - font-size: 8px; - padding: 1px 4px; - border-radius: 2px; - font-weight: 600; -} - -/* Fleet network */ -.fleet-card { - background: rgba(30, 41, 59, 0.8); - border: 1px solid var(--border); - border-radius: 6px; - padding: 8px; - margin-bottom: 4px; -} - -.fleet-card.hl, -.fleet-card:hover { - border-color: rgba(245, 158, 11, 0.75); - background: rgba(251, 191, 36, 0.09); - box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.25) inset; -} - -.fleet-owner { - font-size: 10px; - font-weight: 700; - color: var(--accent); - margin-bottom: 4px; -} - -.fleet-owner.hl { - color: rgba(245, 158, 11, 1); -} - -.fleet-vessel { - display: flex; - align-items: center; - gap: 4px; - font-size: 9px; - padding: 1px 0; -} - -.fleet-vessel.hl { - color: rgba(245, 158, 11, 1); -} - -.fleet-dot.hl { - box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.45); -} - -/* 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); -} - -/* Map panels */ -.map-legend { - position: absolute; - /* Keep attribution visible (bottom-right) for licensing/compliance. */ - bottom: 44px; - right: 12px; - z-index: 800; - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px; - font-size: 9px; - min-width: 180px; -} - -.map-legend .lt { - font-size: 8px; - font-weight: 700; - color: var(--muted); - margin-bottom: 4px; - letter-spacing: 1px; -} - -.map-legend .li { - display: flex; - align-items: center; - gap: 5px; - margin-bottom: 2px; -} - -.map-legend .ls { - width: 12px; - height: 12px; - border-radius: 3px; - flex-shrink: 0; -} - -.map-info { - position: absolute; - top: 12px; - right: 12px; - z-index: 800; - background: rgba(15, 23, 42, 0.95); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - width: 270px; -} - -.map-info .ir { - display: flex; - justify-content: space-between; - font-size: 10px; - padding: 2px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.03); -} - -.map-info .il { - color: var(--muted); -} - -.map-info .iv { - font-weight: 600; -} - -.map-loader-overlay { - position: absolute; - inset: 0; - z-index: 950; - display: flex; - align-items: center; - justify-content: center; - background: rgba(2, 6, 23, 0.42); - pointer-events: auto; -} - -.map-loader-overlay__panel { - width: min(72vw, 320px); - background: rgba(15, 23, 42, 0.94); - border: 1px solid var(--border); - border-radius: 12px; - padding: 14px 16px; - display: grid; - gap: 10px; - justify-items: center; -} - -.map-loader-overlay__spinner { - width: 28px; - height: 28px; - border: 3px solid rgba(148, 163, 184, 0.28); - border-top-color: var(--accent); - border-radius: 50%; - animation: map-loader-spin 0.7s linear infinite; -} - -.map-loader-overlay__text { - font-size: 12px; - color: var(--text); - letter-spacing: 0.2px; -} - -.map-loader-overlay__bar { - width: 100%; - height: 6px; - border-radius: 999px; - background: rgba(148, 163, 184, 0.2); - overflow: hidden; - position: relative; -} - -.map-loader-overlay__fill { - width: 28%; - height: 100%; - border-radius: inherit; - background: var(--accent); - animation: map-loader-fill 1.2s ease-in-out infinite; -} - -.maplibre-tooltip-popup .maplibregl-popup-content { - color: #f8fafc !important; - background: rgba(2, 6, 23, 0.98) !important; - border: 1px solid rgba(148, 163, 184, 0.4) !important; - box-shadow: 0 8px 26px rgba(2, 6, 23, 0.55) !important; - border-radius: 8px !important; - font-size: 11px !important; - line-height: 1.35 !important; - padding: 7px 9px !important; - color: #f8fafc !important; - min-width: 180px; -} - -.maplibre-tooltip-popup .maplibregl-popup-tip { - border-top-color: rgba(2, 6, 23, 0.97) !important; -} - -.maplibre-tooltip-popup__content { - color: #f8fafc; - font-family: Pretendard, Inter, ui-sans-serif, -apple-system, Segoe UI, sans-serif; - font-size: 11px; - line-height: 1.35; -} - -.maplibre-tooltip-popup__content div, -.maplibre-tooltip-popup__content span, -.maplibre-tooltip-popup__content p { - color: inherit; -} - -.maplibre-tooltip-popup__content div { - word-break: break-word; -} - -.maplibre-tooltip-popup .maplibregl-popup-content div, -.maplibre-tooltip-popup .maplibregl-popup-content span, -.maplibre-tooltip-popup .maplibregl-popup-content p { - color: inherit !important; -} - -.maplibre-tooltip-popup .maplibregl-popup-close-button { - color: #94a3b8 !important; -} - -@keyframes map-loader-spin { - to { - transform: rotate(360deg); - } -} - -@keyframes map-loader-fill { - 0% { - transform: translateX(-40%); - } - - 50% { - transform: translateX(220%); - } - - 100% { - transform: translateX(-40%); - } -} - -.close-btn { - position: absolute; - top: 6px; - right: 8px; - background: none; - border: none; - color: var(--muted); - cursor: pointer; - font-size: 13px; -} - -.month-row { - display: flex; - gap: 1px; -} - -.month-cell { - flex: 1; - height: 12px; - border-radius: 2px; - display: flex; - align-items: center; - justify-content: center; - font-size: 6px; - font-weight: 600; -} - -::-webkit-scrollbar { - width: 3px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 2px; -} - -.maplibregl-ctrl-group { - border: 1px solid var(--border) !important; - background: rgba(15, 23, 42, 0.92) !important; - backdrop-filter: blur(8px); -} - -.maplibregl-ctrl-group button { - background: transparent !important; -} - -.maplibregl-ctrl-group button + button { - border-top: 1px solid var(--border) !important; -} - -.maplibregl-ctrl-group button span { - filter: invert(1); - opacity: 0.9; -} - -.maplibregl-ctrl-attrib { - font-size: 10px !important; - background: rgba(15, 23, 42, 0.75) !important; - color: var(--text) !important; - border: 1px solid var(--border) !important; - border-radius: 8px; -} - -/* ── Map Settings Panel ────────────────────────────────────────────── */ - -.map-settings-gear { - position: absolute; - top: 100px; - left: 10px; - z-index: 850; - width: 29px; - height: 29px; - border-radius: 4px; - border: 1px solid var(--border); - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(8px); - color: var(--muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - transition: color 0.15s, border-color 0.15s; - user-select: none; - padding: 0; -} - -.map-settings-gear:hover { - color: var(--text); - border-color: var(--accent); -} - -.map-settings-gear.open { - color: var(--accent); - border-color: var(--accent); -} - -.map-settings-panel { - position: absolute; - top: 10px; - left: 48px; - z-index: 850; - background: rgba(15, 23, 42, 0.95); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - width: 240px; - max-height: calc(100vh - 80px); - overflow-y: auto; -} - -.map-settings-panel .ms-title { - font-size: 11px; - font-weight: 700; - color: var(--text); - letter-spacing: 1px; - margin-bottom: 10px; -} - -.map-settings-panel .ms-section { - margin-bottom: 10px; -} - -.map-settings-panel .ms-label { - font-size: 10px; - font-weight: 600; - color: var(--text); - letter-spacing: 0.5px; - margin-bottom: 5px; -} - -.map-settings-panel .ms-row { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 4px; -} - -.map-settings-panel .ms-color-input { - width: 24px; - height: 24px; - border: 1px solid var(--border); - border-radius: 4px; - padding: 0; - cursor: pointer; - background: transparent; - flex-shrink: 0; -} - -.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper { - padding: 1px; -} - -.map-settings-panel .ms-color-input::-webkit-color-swatch { - border: none; - border-radius: 2px; -} - -.map-settings-panel .ms-hex { - font-size: 9px; - color: #94a3b8; - font-family: monospace; -} - -.map-settings-panel .ms-depth-label { - font-size: 10px; - color: var(--text); - min-width: 48px; - text-align: right; -} - -.map-settings-panel select { - font-size: 10px; - padding: 4px 8px; - border-radius: 4px; - border: 1px solid var(--border); - background: var(--card); - color: var(--text); - cursor: pointer; - outline: none; - width: 100%; -} - -.map-settings-panel select:focus { - border-color: var(--accent); -} - -.map-settings-panel .ms-reset { - width: 100%; - font-size: 9px; - padding: 5px 8px; - border-radius: 4px; - border: 1px solid var(--border); - background: var(--card); - color: var(--muted); - cursor: pointer; - transition: all 0.15s; - margin-top: 4px; -} - -.map-settings-panel .ms-reset:hover { - color: var(--text); - border-color: var(--accent); -} - -/* ── Depth Legend ──────────────────────────────────────────────────── */ - -.depth-legend { - position: absolute; - bottom: 44px; - left: 10px; - z-index: 800; - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px; - display: flex; - gap: 6px; - align-items: stretch; -} - -.depth-legend__bar { - width: 14px; - border-radius: 3px; - min-height: 120px; -} - -.depth-legend__ticks { - display: flex; - flex-direction: column; - justify-content: space-between; - font-size: 8px; - color: var(--muted); - font-family: monospace; - padding: 1px 0; -} - -/* ── Auth pages ──────────────────────────────────────────────────── */ - -.auth-page { - height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, #020617 0%, #0f172a 50%, #020617 100%); -} - -.auth-card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 16px; - padding: 40px 36px; - width: 360px; - text-align: center; -} - -.auth-logo { - font-size: 36px; - font-weight: 900; - color: var(--accent); - letter-spacing: 4px; - margin-bottom: 4px; -} - -.auth-title { - font-size: 16px; - font-weight: 700; - color: var(--text); - margin-bottom: 8px; -} - -.auth-subtitle { - font-size: 12px; - color: var(--muted); - margin-bottom: 24px; -} - -.auth-error { - font-size: 11px; - color: var(--crit); - background: rgba(239, 68, 68, 0.08); - border: 1px solid rgba(239, 68, 68, 0.2); - border-radius: 8px; - padding: 8px 12px; - margin-bottom: 16px; -} - -.auth-google-btn { - display: flex; - justify-content: center; - margin-bottom: 12px; -} - -.auth-dev-btn { - width: 100%; - padding: 10px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--card); - color: var(--muted); - font-size: 12px; - cursor: pointer; - transition: all 0.15s; - margin-bottom: 12px; -} - -.auth-dev-btn:hover { - color: var(--text); - border-color: var(--accent); -} - -.auth-footer { - font-size: 10px; - color: var(--muted); - margin-top: 16px; -} - -.auth-status-icon { - font-size: 48px; - margin-bottom: 12px; -} - -.auth-message { - font-size: 13px; - color: var(--muted); - line-height: 1.6; - margin-bottom: 16px; -} - -.auth-message b { - color: var(--text); -} - -.auth-link-btn { - background: none; - border: none; - color: var(--accent); - font-size: 12px; - cursor: pointer; - text-decoration: underline; - padding: 4px 8px; -} - -.auth-link-btn:hover { - color: var(--text); -} - -.auth-loading { - height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg); -} - -.auth-loading__spinner { - width: 32px; - height: 32px; - border: 3px solid rgba(148, 163, 184, 0.28); - border-top-color: var(--accent); - border-radius: 50%; - animation: map-loader-spin 0.7s linear infinite; -} - -/* ── Topbar user ─────────────────────────────────────────────────── */ - -.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); -} - -/* ── Weather Overlay Panel ─────────────────────────────────── */ - -.weather-gear { - position: absolute; - top: 140px; - left: 10px; - z-index: 850; - width: 29px; - height: 29px; - border-radius: 4px; - border: 1px solid var(--border); - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(8px); - color: var(--muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - transition: color 0.15s, border-color 0.15s; - user-select: none; - padding: 0; -} - -.weather-gear:hover { - color: var(--text); - border-color: var(--accent); -} - -.weather-gear.open { - color: var(--accent); - border-color: var(--accent); -} - -.weather-panel { - position: absolute; - top: 130px; - left: 48px; - z-index: 850; - background: rgba(15, 23, 42, 0.95); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px; - width: 260px; - max-height: calc(100vh - 200px); - overflow-y: auto; -} - -.wp-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} - -.wp-title { - font-size: 11px; - font-weight: 700; - color: var(--text); - letter-spacing: 1px; -} - -.wp-loading { - font-size: 9px; - color: var(--accent); -} - -.wp-error { - font-size: 9px; - color: #f87171; - margin-bottom: 6px; - padding: 4px 6px; - background: rgba(248, 113, 113, 0.1); - border-radius: 4px; -} - -.wp-empty { - font-size: 10px; - color: var(--muted); - text-align: center; - padding: 12px 0; -} - -.wz-card { - border-left: 3px solid var(--border); - padding: 6px 8px; - margin-bottom: 6px; - border-radius: 0 4px 4px 0; - background: rgba(255, 255, 255, 0.03); - transition: background 0.15s; -} - -.wz-card:last-of-type { - margin-bottom: 0; -} - -.wz-card.wz-warn { - background: rgba(248, 113, 113, 0.08); -} - -.wz-name { - font-size: 10px; - font-weight: 600; - color: var(--text); - margin-bottom: 4px; - letter-spacing: 0.3px; -} - -.wz-row { - display: flex; - gap: 10px; - flex-wrap: wrap; - margin-bottom: 2px; -} - -.wz-item { - display: inline-flex; - align-items: center; - gap: 3px; - font-size: 10px; - color: var(--muted); - white-space: nowrap; -} - -.wz-icon { - font-size: 10px; - color: var(--accent); -} - -.wz-label { - font-size: 9px; - color: var(--muted); -} - -.wz-value { - font-size: 10px; - font-weight: 600; - color: var(--text); -} - -.wz-weather { - font-weight: 500; - color: var(--muted); -} - -.wp-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 8px; - padding-top: 6px; - border-top: 1px solid var(--border); -} - -.wp-time { - font-size: 9px; - color: var(--muted); -} - -.wp-refresh { - font-size: 14px; - color: var(--muted); - background: none; - border: 1px solid var(--border); - border-radius: 3px; - width: 22px; - height: 22px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: color 0.15s, border-color 0.15s; - padding: 0; -} - -.wp-refresh:hover { - color: var(--text); - border-color: var(--accent); -} - -.wp-refresh:disabled { - opacity: 0.5; - cursor: default; -} - -/* ── Weather Overlay Panel (MapTiler) ────────────────────────────── */ - -.wo-gear { - position: absolute; - top: 180px; - left: 10px; - z-index: 850; - width: 29px; - height: 29px; - border-radius: 4px; - border: 1px solid var(--border); - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(8px); - color: var(--muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - transition: color 0.15s, border-color 0.15s; - user-select: none; - padding: 0; -} - -.wo-gear:hover { - color: var(--text); - border-color: var(--accent); -} - -.wo-gear.open { - color: var(--accent); - border-color: var(--accent); -} - -.wo-gear.active { - border-color: #22c55e; -} - -.wo-gear.active.open { - border-color: var(--accent); -} - -.wo-gear-badge { - position: absolute; - top: -4px; - right: -4px; - background: #22c55e; - color: #fff; - font-size: 8px; - font-weight: 700; - width: 14px; - height: 14px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -.wo-stack { - position: absolute; - top: 170px; - left: 48px; - z-index: 850; - display: flex; - flex-direction: column; - gap: 6px; - width: 280px; - pointer-events: none; -} -.wo-stack > * { - pointer-events: auto; -} - -.wo-panel { - background: rgba(15, 23, 42, 0.95); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px; - width: 100%; - max-height: calc(100vh - 240px); - overflow-y: auto; -} - -.wo-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; -} - -.wo-title { - font-size: 11px; - font-weight: 700; - color: var(--text); - letter-spacing: 1px; -} - -.wo-loading { - font-size: 9px; - color: var(--accent); - animation: wo-pulse 1.2s ease-in-out infinite; -} - -@keyframes wo-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -.wo-layers { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 4px; - margin-bottom: 10px; -} - -.wo-layer-btn { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - padding: 6px 4px; - border-radius: 6px; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.03); - color: var(--muted); - cursor: pointer; - transition: all 0.15s; - font-size: 10px; -} - -.wo-layer-btn:hover { - background: rgba(255, 255, 255, 0.06); - color: var(--text); - border-color: rgba(59, 130, 246, 0.4); -} - -.wo-layer-btn.on { - background: rgba(59, 130, 246, 0.15); - color: var(--text); - border-color: var(--accent); -} - -.wo-layer-icon { - font-size: 16px; - line-height: 1; -} - -.wo-layer-name { - font-size: 9px; - font-weight: 600; - letter-spacing: 0.3px; -} - -.wo-section { - margin-bottom: 8px; -} - -.wo-label { - font-size: 9px; - font-weight: 600; - color: var(--text); - margin-bottom: 4px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.wo-val { - font-weight: 400; - color: var(--muted); - font-size: 9px; -} -.wo-offset { - color: #4fc3f7; - font-weight: 600; -} - -.wo-slider { - width: 100%; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: var(--border); - border-radius: 2px; - outline: none; - cursor: pointer; -} - -.wo-slider::-webkit-slider-thumb { - -webkit-appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - border: 2px solid var(--panel); - cursor: pointer; -} - -.wo-slider::-moz-range-thumb { - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - border: 2px solid var(--panel); - cursor: pointer; -} - -.wo-slider:disabled { - opacity: 0.4; - cursor: default; -} - -.wo-timeline { - padding-top: 8px; - border-top: 1px solid var(--border); -} - -.wo-step-slider-wrap { - position: relative; - padding-bottom: 10px; -} - -.wo-time-slider { - margin-bottom: 2px; -} - -.wo-step-ticks { - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 8px; - pointer-events: none; -} - -.wo-step-tick { - position: absolute; - bottom: 0; - width: 1px; - height: 4px; - background: var(--muted); - opacity: 0.4; - transform: translateX(-0.5px); -} - -.wo-step-tick.day { - height: 8px; - opacity: 0.8; - background: var(--accent); -} - -.wo-time-range { - display: flex; - justify-content: space-between; - font-size: 8px; - color: var(--muted); - margin-bottom: 6px; -} - -.wo-playback { - display: flex; - align-items: center; - gap: 6px; -} - -.wo-play-btn { - width: 26px; - height: 26px; - border-radius: 50%; - border: 1px solid var(--border); - background: var(--card); - color: var(--text); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - transition: all 0.15s; - padding: 0; - flex-shrink: 0; -} - -.wo-play-btn:hover { - border-color: var(--accent); - background: rgba(59, 130, 246, 0.12); -} - -.wo-play-btn:disabled { - opacity: 0.4; - cursor: default; -} - -.wo-speed-btns { - display: flex; - gap: 2px; -} - -.wo-speed-btn { - font-size: 8px; - padding: 3px 6px; - border-radius: 3px; - border: 1px solid var(--border); - background: transparent; - color: var(--muted); - cursor: pointer; - transition: all 0.15s; -} - -.wo-speed-btn:hover { - color: var(--text); - border-color: rgba(59, 130, 246, 0.4); -} - -.wo-speed-btn.on { - background: rgba(59, 130, 246, 0.15); - color: var(--text); - border-color: var(--accent); -} - -.wo-hint { - font-size: 8px; - color: var(--muted); - text-align: right; - margin-top: 4px; - opacity: 0.6; -} - -/* ── Weather Legend ── */ -.wo-legend { - background: rgba(15, 23, 42, 0.85); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 6px; - padding: 6px 10px 4px; - width: 100%; -} -.wo-legend-header { - font-size: 9px; - color: var(--muted); - margin-bottom: 4px; - text-align: center; -} -.wo-legend-bar { - height: 10px; - border-radius: 3px; - width: 100%; -} -.wo-legend-ticks { - display: flex; - justify-content: space-between; - font-size: 8px; - color: var(--muted); - margin-top: 2px; -} - -@media (max-width: 920px) { - .app { - grid-template-columns: 1fr; - grid-template-rows: 44px 1fr; - } - - .sidebar { - display: none; - } -} +/* ── Wing Fleet Dashboard – Style Entry Point ─────────────────── */ + +@import "./styles/base.css"; +@import "./styles/layout.css"; + +/* 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"; +@import "./styles/components/alarms.css"; +@import "./styles/components/relations.css"; +@import "./styles/components/map-panels.css"; +@import "./styles/components/map-settings.css"; +@import "./styles/components/auth.css"; +@import "./styles/components/weather.css"; +@import "./styles/components/weather-overlay.css"; diff --git a/apps/web/src/app/styles/base.css b/apps/web/src/app/styles/base.css new file mode 100644 index 0000000..1902d59 --- /dev/null +++ b/apps/web/src/app/styles/base.css @@ -0,0 +1,36 @@ +@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap"); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #020617; + --panel: #0f172a; + --card: #1e293b; + --border: #1e3a5f; + --text: #e2e8f0; + --muted: #64748b; + --accent: #3b82f6; + + --crit: #ef4444; + --high: #f59e0b; +} + +html, +body { + height: 100%; +} + +body { + font-family: "Noto Sans KR", sans-serif; + background: var(--bg); + color: var(--text); + overflow: hidden; +} + +#root { + height: 100%; +} diff --git a/apps/web/src/app/styles/components/ais-list.css b/apps/web/src/app/styles/components/ais-list.css new file mode 100644 index 0000000..3897468 --- /dev/null +++ b/apps/web/src/app/styles/components/ais-list.css @@ -0,0 +1,159 @@ +/* AIS target list */ +.ais-q { + flex: 1; + font-size: 10px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(30, 41, 59, 0.75); + color: var(--text); + outline: none; +} + +.ais-q::placeholder { + color: rgba(100, 116, 139, 0.9); +} + +.ais-mode { + display: flex; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.ais-mode-btn { + font-size: 9px; + padding: 0 8px; + height: 28px; + border: none; + background: var(--card); + color: var(--muted); + cursor: pointer; +} + +.ais-mode-btn.on { + background: rgba(59, 130, 246, 0.18); + color: var(--text); +} + +.ais-list { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.ais-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 6px; + border-radius: 6px; + cursor: pointer; + user-select: none; + transition: background 0.12s, border-color 0.12s; + border: 1px solid transparent; +} + +.ais-row:hover { + background: rgba(30, 41, 59, 0.6); + border-color: rgba(30, 58, 95, 0.8); +} + +.ais-row.sel { + background: rgba(59, 130, 246, 0.14); + border-color: rgba(59, 130, 246, 0.55); +} + +.ais-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.ais-nm { + flex: 1; + min-width: 0; +} + +.ais-nm1 { + font-size: 10px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ais-nm2 { + font-size: 9px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ais-right { + text-align: right; + flex-shrink: 0; +} + +.ais-badges { + display: flex; + gap: 4px; + justify-content: flex-end; + margin-bottom: 2px; + flex-wrap: wrap; +} + +.ais-badge { + font-size: 8px; + padding: 1px 5px; + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, 0.08); + font-weight: 800; + letter-spacing: 0.2px; + color: #fff; + background: rgba(100, 116, 139, 0.22); +} + +.ais-badge.pn { + color: var(--muted); + background: rgba(30, 41, 59, 0.55); + border-color: rgba(30, 58, 95, 0.9); + font-weight: 700; +} + +.ais-badge.PT { + background: rgba(30, 64, 175, 0.28); + border-color: rgba(30, 64, 175, 0.7); +} +.ais-badge.PT-S { + background: rgba(234, 88, 12, 0.22); + border-color: rgba(234, 88, 12, 0.7); +} +.ais-badge.GN { + background: rgba(16, 185, 129, 0.22); + border-color: rgba(16, 185, 129, 0.7); +} +.ais-badge.OT { + background: rgba(139, 92, 246, 0.22); + border-color: rgba(139, 92, 246, 0.7); +} +.ais-badge.PS { + background: rgba(239, 68, 68, 0.22); + border-color: rgba(239, 68, 68, 0.7); +} +.ais-badge.FC { + background: rgba(245, 158, 11, 0.22); + border-color: rgba(245, 158, 11, 0.7); +} + +.ais-sp { + font-size: 10px; + font-weight: 800; +} + +.ais-ts { + font-size: 9px; + color: rgba(100, 116, 139, 0.9); +} diff --git a/apps/web/src/app/styles/components/alarms.css b/apps/web/src/app/styles/components/alarms.css new file mode 100644 index 0000000..0b98003 --- /dev/null +++ b/apps/web/src/app/styles/components/alarms.css @@ -0,0 +1,95 @@ +/* Alarm */ +.ai { + display: flex; + gap: 6px; + padding: 4px 6px; + border-radius: 3px; + margin-bottom: 2px; + font-size: 9px; + border-left: 3px solid; +} + +.ai.cr { + border-color: var(--crit); + background: rgba(239, 68, 68, 0.07); +} + +.ai.hi { + border-color: var(--high); + background: rgba(245, 158, 11, 0.05); +} + +.ai .at { + color: var(--muted); + font-size: 8px; + white-space: nowrap; +} + +/* Alarm filter (dropdown) */ +.alarm-filter { + position: relative; +} + +.alarm-filter__summary { + list-style: none; + cursor: pointer; + padding: 2px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(30, 41, 59, 0.55); + color: var(--text); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.4px; + user-select: none; + white-space: nowrap; +} + +.alarm-filter__summary::-webkit-details-marker { + display: none; +} + +.alarm-filter__menu { + position: absolute; + right: 0; + top: 22px; + z-index: 2000; + min-width: 170px; + padding: 6px; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55); +} + +.alarm-filter__row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 6px; + cursor: pointer; + font-size: 10px; + color: var(--text); + user-select: none; +} + +.alarm-filter__row:hover { + background: rgba(59, 130, 246, 0.08); +} + +.alarm-filter__row input { + cursor: pointer; +} + +.alarm-filter__cnt { + margin-left: auto; + font-size: 9px; + color: var(--muted); +} + +.alarm-filter__sep { + height: 1px; + background: rgba(30, 58, 95, 0.85); + margin: 4px 0; +} diff --git a/apps/web/src/app/styles/components/auth.css b/apps/web/src/app/styles/components/auth.css new file mode 100644 index 0000000..930b931 --- /dev/null +++ b/apps/web/src/app/styles/components/auth.css @@ -0,0 +1,126 @@ +/* ── Auth pages ──────────────────────────────────────────────────── */ + +.auth-page { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #020617 0%, #0f172a 50%, #020617 100%); +} + +.auth-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + padding: 40px 36px; + width: 360px; + text-align: center; +} + +.auth-logo { + font-size: 36px; + font-weight: 900; + color: var(--accent); + letter-spacing: 4px; + margin-bottom: 4px; +} + +.auth-title { + font-size: 16px; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} + +.auth-subtitle { + font-size: 12px; + color: var(--muted); + margin-bottom: 24px; +} + +.auth-error { + font-size: 11px; + color: var(--crit); + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; + padding: 8px 12px; + margin-bottom: 16px; +} + +.auth-google-btn { + display: flex; + justify-content: center; + margin-bottom: 12px; +} + +.auth-dev-btn { + width: 100%; + padding: 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--card); + color: var(--muted); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + margin-bottom: 12px; +} + +.auth-dev-btn:hover { + color: var(--text); + border-color: var(--accent); +} + +.auth-footer { + font-size: 10px; + color: var(--muted); + margin-top: 16px; +} + +.auth-status-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.auth-message { + font-size: 13px; + color: var(--muted); + line-height: 1.6; + margin-bottom: 16px; +} + +.auth-message b { + color: var(--text); +} + +.auth-link-btn { + background: none; + border: none; + color: var(--accent); + font-size: 12px; + cursor: pointer; + text-decoration: underline; + padding: 4px 8px; +} + +.auth-link-btn:hover { + color: var(--text); +} + +.auth-loading { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); +} + +.auth-loading__spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(148, 163, 184, 0.28); + border-top-color: var(--accent); + border-radius: 50%; + animation: map-loader-spin 0.7s linear infinite; +} diff --git a/apps/web/src/app/styles/components/map-panels.css b/apps/web/src/app/styles/components/map-panels.css new file mode 100644 index 0000000..a2824cd --- /dev/null +++ b/apps/web/src/app/styles/components/map-panels.css @@ -0,0 +1,211 @@ +/* Map panels */ +.map-legend { + position: absolute; + /* Keep attribution visible (bottom-right) for licensing/compliance. */ + bottom: 44px; + right: 12px; + z-index: 800; + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + font-size: 9px; + min-width: 180px; +} + +.map-legend .lt { + font-size: 8px; + font-weight: 700; + color: var(--muted); + margin-bottom: 4px; + letter-spacing: 1px; +} + +.map-legend .li { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 2px; +} + +.map-legend .ls { + width: 12px; + height: 12px; + border-radius: 3px; + flex-shrink: 0; +} + +.map-info { + position: absolute; + top: 12px; + right: 12px; + z-index: 800; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + width: 270px; +} + +.map-info .ir { + display: flex; + justify-content: space-between; + font-size: 10px; + padding: 2px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.map-info .il { + color: var(--muted); +} + +.map-info .iv { + font-weight: 600; +} + +.map-loader-overlay { + position: absolute; + inset: 0; + z-index: 950; + display: flex; + align-items: center; + justify-content: center; + background: rgba(2, 6, 23, 0.42); + pointer-events: auto; +} + +.map-loader-overlay__panel { + width: min(72vw, 320px); + background: rgba(15, 23, 42, 0.94); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + display: grid; + gap: 10px; + justify-items: center; +} + +.map-loader-overlay__spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(148, 163, 184, 0.28); + border-top-color: var(--accent); + border-radius: 50%; + animation: map-loader-spin 0.7s linear infinite; +} + +.map-loader-overlay__text { + font-size: 12px; + color: var(--text); + letter-spacing: 0.2px; +} + +.map-loader-overlay__bar { + width: 100%; + height: 6px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.2); + overflow: hidden; + position: relative; +} + +.map-loader-overlay__fill { + width: 28%; + height: 100%; + border-radius: inherit; + background: var(--accent); + animation: map-loader-fill 1.2s ease-in-out infinite; +} + +.maplibre-tooltip-popup .maplibregl-popup-content { + color: #f8fafc !important; + background: rgba(2, 6, 23, 0.98) !important; + border: 1px solid rgba(148, 163, 184, 0.4) !important; + box-shadow: 0 8px 26px rgba(2, 6, 23, 0.55) !important; + border-radius: 8px !important; + font-size: 11px !important; + line-height: 1.35 !important; + padding: 7px 9px !important; + color: #f8fafc !important; + min-width: 180px; +} + +.maplibre-tooltip-popup .maplibregl-popup-tip { + border-top-color: rgba(2, 6, 23, 0.97) !important; +} + +.maplibre-tooltip-popup__content { + color: #f8fafc; + font-family: Pretendard, Inter, ui-sans-serif, -apple-system, Segoe UI, sans-serif; + font-size: 11px; + line-height: 1.35; +} + +.maplibre-tooltip-popup__content div, +.maplibre-tooltip-popup__content span, +.maplibre-tooltip-popup__content p { + color: inherit; +} + +.maplibre-tooltip-popup__content div { + word-break: break-word; +} + +.maplibre-tooltip-popup .maplibregl-popup-content div, +.maplibre-tooltip-popup .maplibregl-popup-content span, +.maplibre-tooltip-popup .maplibregl-popup-content p { + color: inherit !important; +} + +.maplibre-tooltip-popup .maplibregl-popup-close-button { + color: #94a3b8 !important; +} + +@keyframes map-loader-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes map-loader-fill { + 0% { + transform: translateX(-40%); + } + + 50% { + transform: translateX(220%); + } + + 100% { + transform: translateX(-40%); + } +} + +.maplibregl-ctrl-group { + border: 1px solid var(--border) !important; + background: rgba(15, 23, 42, 0.92) !important; + backdrop-filter: blur(8px); +} + +.maplibregl-ctrl-group button { + background: transparent !important; +} + +.maplibregl-ctrl-group button + button { + border-top: 1px solid var(--border) !important; +} + +.maplibregl-ctrl-group button span { + filter: invert(1); + opacity: 0.9; +} + +.maplibregl-ctrl-attrib { + font-size: 10px !important; + background: rgba(15, 23, 42, 0.75) !important; + color: var(--text) !important; + border: 1px solid var(--border) !important; + border-radius: 8px; +} diff --git a/apps/web/src/app/styles/components/map-settings.css b/apps/web/src/app/styles/components/map-settings.css new file mode 100644 index 0000000..23a8147 --- /dev/null +++ b/apps/web/src/app/styles/components/map-settings.css @@ -0,0 +1,175 @@ +/* ── Map Settings Panel ──────────────────────────────────────────── */ + +.map-settings-gear { + position: absolute; + top: 100px; + left: 10px; + z-index: 850; + width: 29px; + height: 29px; + border-radius: 4px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: color 0.15s, border-color 0.15s; + user-select: none; + padding: 0; +} + +.map-settings-gear:hover { + color: var(--text); + border-color: var(--accent); +} + +.map-settings-gear.open { + color: var(--accent); + border-color: var(--accent); +} + +.map-settings-panel { + position: absolute; + top: 10px; + left: 48px; + z-index: 850; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + width: 240px; + max-height: calc(100vh - 80px); + overflow-y: auto; +} + +.map-settings-panel .ms-title { + font-size: 11px; + font-weight: 700; + color: var(--text); + letter-spacing: 1px; + margin-bottom: 10px; +} + +.map-settings-panel .ms-section { + margin-bottom: 10px; +} + +.map-settings-panel .ms-label { + font-size: 10px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.5px; + margin-bottom: 5px; +} + +.map-settings-panel .ms-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.map-settings-panel .ms-color-input { + width: 24px; + height: 24px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0; + cursor: pointer; + background: transparent; + flex-shrink: 0; +} + +.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper { + padding: 1px; +} + +.map-settings-panel .ms-color-input::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.map-settings-panel .ms-hex { + font-size: 9px; + color: #94a3b8; + font-family: monospace; +} + +.map-settings-panel .ms-depth-label { + font-size: 10px; + color: var(--text); + min-width: 48px; + text-align: right; +} + +.map-settings-panel select { + font-size: 10px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--card); + color: var(--text); + cursor: pointer; + outline: none; + width: 100%; +} + +.map-settings-panel select:focus { + border-color: var(--accent); +} + +.map-settings-panel .ms-reset { + width: 100%; + font-size: 9px; + padding: 5px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--card); + color: var(--muted); + cursor: pointer; + transition: all 0.15s; + margin-top: 4px; +} + +.map-settings-panel .ms-reset:hover { + color: var(--text); + border-color: var(--accent); +} + +/* ── Depth Legend ──────────────────────────────────────────────────── */ + +.depth-legend { + position: absolute; + bottom: 44px; + left: 10px; + z-index: 800; + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px; + display: flex; + gap: 6px; + align-items: stretch; +} + +.depth-legend__bar { + width: 14px; + border-radius: 3px; + min-height: 120px; +} + +.depth-legend__ticks { + display: flex; + flex-direction: column; + justify-content: space-between; + font-size: 8px; + color: var(--muted); + font-family: monospace; + padding: 1px 0; +} diff --git a/apps/web/src/app/styles/components/panels.css b/apps/web/src/app/styles/components/panels.css new file mode 100644 index 0000000..f9ad84e --- /dev/null +++ b/apps/web/src/app/styles/components/panels.css @@ -0,0 +1,77 @@ +.sb { + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} + +.sb-t { + font-size: 9px; + font-weight: 700; + color: var(--muted); + letter-spacing: 1.5px; + text-transform: uppercase; + margin-bottom: 6px; +} + +.sb-t-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.relation-sort { + display: flex; + align-items: center; + gap: 6px; + font-size: 8px; + color: var(--muted); + white-space: nowrap; +} + +.relation-sort__option { + display: inline-flex; + align-items: center; + gap: 3px; + cursor: pointer; + user-select: none; +} + +.close-btn { + position: absolute; + top: 6px; + right: 8px; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 13px; +} + +.month-row { + display: flex; + gap: 1px; +} + +.month-cell { + flex: 1; + height: 12px; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 6px; + font-weight: 600; +} + +::-webkit-scrollbar { + width: 3px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} diff --git a/apps/web/src/app/styles/components/relations.css b/apps/web/src/app/styles/components/relations.css new file mode 100644 index 0000000..3cc2596 --- /dev/null +++ b/apps/web/src/app/styles/components/relations.css @@ -0,0 +1,95 @@ +/* Relation panel */ +.rel-panel { + background: var(--card); + border-radius: 6px; + padding: 8px; + margin-top: 4px; +} + +.rel-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.rel-badge { + font-size: 9px; + padding: 1px 5px; + border-radius: 3px; + font-weight: 700; +} + +.rel-line { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + padding: 2px 0; +} + +.rel-line .dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.rel-link { + width: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 10px; +} + +.rel-dist { + font-size: 8px; + padding: 1px 4px; + border-radius: 2px; + font-weight: 600; +} + +/* Fleet network */ +.fleet-card { + background: rgba(30, 41, 59, 0.8); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + margin-bottom: 4px; +} + +.fleet-card.hl, +.fleet-card:hover { + border-color: rgba(245, 158, 11, 0.75); + background: rgba(251, 191, 36, 0.09); + box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.25) inset; +} + +.fleet-owner { + font-size: 10px; + font-weight: 700; + color: var(--accent); + margin-bottom: 4px; +} + +.fleet-owner.hl { + color: rgba(245, 158, 11, 1); +} + +.fleet-vessel { + display: flex; + align-items: center; + gap: 4px; + font-size: 9px; + padding: 1px 0; +} + +.fleet-vessel.hl { + color: rgba(245, 158, 11, 1); +} + +.fleet-dot.hl { + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.45); +} diff --git a/apps/web/src/app/styles/components/speed.css b/apps/web/src/app/styles/components/speed.css new file mode 100644 index 0000000..284d1b1 --- /dev/null +++ b/apps/web/src/app/styles/components/speed.css @@ -0,0 +1,33 @@ +/* Speed bar */ +.sbar { + position: relative; + height: 24px; + background: var(--bg); + border-radius: 5px; + overflow: hidden; + margin: 4px 0; +} + +.sseg { + position: absolute; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 7px; + color: #fff; + font-weight: 600; +} + +.sseg.p { + height: 24px; + top: 0; + border: 1.5px solid rgba(255, 255, 255, 0.25); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); +} + +.sseg:not(.p) { + height: 16px; + top: 4px; + opacity: 0.6; +} diff --git a/apps/web/src/app/styles/components/toggles.css b/apps/web/src/app/styles/components/toggles.css new file mode 100644 index 0000000..328818c --- /dev/null +++ b/apps/web/src/app/styles/components/toggles.css @@ -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); +} diff --git a/apps/web/src/app/styles/components/topbar.css b/apps/web/src/app/styles/components/topbar.css new file mode 100644 index 0000000..dcb52c8 --- /dev/null +++ b/apps/web/src/app/styles/components/topbar.css @@ -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); +} diff --git a/apps/web/src/app/styles/components/vessel-list.css b/apps/web/src/app/styles/components/vessel-list.css new file mode 100644 index 0000000..11e4308 --- /dev/null +++ b/apps/web/src/app/styles/components/vessel-list.css @@ -0,0 +1,61 @@ +/* Vessel list */ +.vlist { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.vi { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 3px; + cursor: pointer; + font-size: 10px; + transition: background 0.1s; + user-select: none; +} + +.vi:hover { + background: var(--card); +} + +.vi.sel { + background: rgba(14, 234, 255, 0.16); + border-color: rgba(14, 234, 255, 0.55); +} + +.vi.hl { + background: rgba(245, 158, 11, 0.16); + border: 1px solid rgba(245, 158, 11, 0.4); +} + +.vi.sel.hl { + background: linear-gradient(90deg, rgba(14, 234, 255, 0.16), rgba(245, 158, 11, 0.16)); + border-color: rgba(14, 234, 255, 0.7); +} + +.vi .dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.vi .nm { + flex: 1; + font-weight: 500; +} + +.vi .sp { + font-weight: 700; + font-size: 9px; +} + +.vi .st { + font-size: 7px; + padding: 1px 3px; + border-radius: 2px; + font-weight: 600; +} diff --git a/apps/web/src/app/styles/components/weather-overlay.css b/apps/web/src/app/styles/components/weather-overlay.css new file mode 100644 index 0000000..3a3e5b7 --- /dev/null +++ b/apps/web/src/app/styles/components/weather-overlay.css @@ -0,0 +1,356 @@ +/* ── Weather Overlay Panel (MapTiler) ────────────────────────────── */ + +.wo-gear { + position: absolute; + top: 180px; + left: 10px; + z-index: 850; + width: 29px; + height: 29px; + border-radius: 4px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: color 0.15s, border-color 0.15s; + user-select: none; + padding: 0; +} + +.wo-gear:hover { + color: var(--text); + border-color: var(--accent); +} + +.wo-gear.open { + color: var(--accent); + border-color: var(--accent); +} + +.wo-gear.active { + border-color: #22c55e; +} + +.wo-gear.active.open { + border-color: var(--accent); +} + +.wo-gear-badge { + position: absolute; + top: -4px; + right: -4px; + background: #22c55e; + color: #fff; + font-size: 8px; + font-weight: 700; + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.wo-stack { + position: absolute; + top: 170px; + left: 48px; + z-index: 850; + display: flex; + flex-direction: column; + gap: 6px; + width: 280px; + pointer-events: none; +} +.wo-stack > * { + pointer-events: auto; +} + +.wo-panel { + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + width: 100%; + max-height: calc(100vh - 240px); + overflow-y: auto; +} + +.wo-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.wo-title { + font-size: 11px; + font-weight: 700; + color: var(--text); + letter-spacing: 1px; +} + +.wo-loading { + font-size: 9px; + color: var(--accent); + animation: wo-pulse 1.2s ease-in-out infinite; +} + +@keyframes wo-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.wo-layers { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + margin-bottom: 10px; +} + +.wo-layer-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 4px; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + cursor: pointer; + transition: all 0.15s; + font-size: 10px; +} + +.wo-layer-btn:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border-color: rgba(59, 130, 246, 0.4); +} + +.wo-layer-btn.on { + background: rgba(59, 130, 246, 0.15); + color: var(--text); + border-color: var(--accent); +} + +.wo-layer-icon { + font-size: 16px; + line-height: 1; +} + +.wo-layer-name { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.wo-section { + margin-bottom: 8px; +} + +.wo-label { + font-size: 9px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.wo-val { + font-weight: 400; + color: var(--muted); + font-size: 9px; +} +.wo-offset { + color: #4fc3f7; + font-weight: 600; +} + +.wo-slider { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--border); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +.wo-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--panel); + cursor: pointer; +} + +.wo-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--panel); + cursor: pointer; +} + +.wo-slider:disabled { + opacity: 0.4; + cursor: default; +} + +.wo-timeline { + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.wo-step-slider-wrap { + position: relative; + padding-bottom: 10px; +} + +.wo-time-slider { + margin-bottom: 2px; +} + +.wo-step-ticks { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 8px; + pointer-events: none; +} + +.wo-step-tick { + position: absolute; + bottom: 0; + width: 1px; + height: 4px; + background: var(--muted); + opacity: 0.4; + transform: translateX(-0.5px); +} + +.wo-step-tick.day { + height: 8px; + opacity: 0.8; + background: var(--accent); +} + +.wo-time-range { + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--muted); + margin-bottom: 6px; +} + +.wo-playback { + display: flex; + align-items: center; + gap: 6px; +} + +.wo-play-btn { + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--card); + color: var(--text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + transition: all 0.15s; + padding: 0; + flex-shrink: 0; +} + +.wo-play-btn:hover { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.12); +} + +.wo-play-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.wo-speed-btns { + display: flex; + gap: 2px; +} + +.wo-speed-btn { + font-size: 8px; + padding: 3px 6px; + border-radius: 3px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: all 0.15s; +} + +.wo-speed-btn:hover { + color: var(--text); + border-color: rgba(59, 130, 246, 0.4); +} + +.wo-speed-btn.on { + background: rgba(59, 130, 246, 0.15); + color: var(--text); + border-color: var(--accent); +} + +.wo-hint { + font-size: 8px; + color: var(--muted); + text-align: right; + margin-top: 4px; + opacity: 0.6; +} + +/* ── Weather Legend ── */ +.wo-legend { + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px 4px; + width: 100%; +} +.wo-legend-header { + font-size: 9px; + color: var(--muted); + margin-bottom: 4px; + text-align: center; +} +.wo-legend-bar { + height: 10px; + border-radius: 3px; + width: 100%; +} +.wo-legend-ticks { + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--muted); + margin-top: 2px; +} diff --git a/apps/web/src/app/styles/components/weather.css b/apps/web/src/app/styles/components/weather.css new file mode 100644 index 0000000..f957d7a --- /dev/null +++ b/apps/web/src/app/styles/components/weather.css @@ -0,0 +1,185 @@ +/* ── Weather Panel (Open-Meteo) ───────────────────────────────────── */ + +.weather-gear { + position: absolute; + top: 140px; + left: 10px; + z-index: 850; + width: 29px; + height: 29px; + border-radius: 4px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: color 0.15s, border-color 0.15s; + user-select: none; + padding: 0; +} + +.weather-gear:hover { + color: var(--text); + border-color: var(--accent); +} + +.weather-gear.open { + color: var(--accent); + border-color: var(--accent); +} + +.weather-panel { + position: absolute; + top: 130px; + left: 48px; + z-index: 850; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + width: 260px; + max-height: calc(100vh - 200px); + overflow-y: auto; +} + +.wp-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.wp-title { + font-size: 11px; + font-weight: 700; + color: var(--text); + letter-spacing: 1px; +} + +.wp-loading { + font-size: 9px; + color: var(--accent); +} + +.wp-error { + font-size: 9px; + color: #f87171; + margin-bottom: 6px; + padding: 4px 6px; + background: rgba(248, 113, 113, 0.1); + border-radius: 4px; +} + +.wp-empty { + font-size: 10px; + color: var(--muted); + text-align: center; + padding: 12px 0; +} + +.wz-card { + border-left: 3px solid var(--border); + padding: 6px 8px; + margin-bottom: 6px; + border-radius: 0 4px 4px 0; + background: rgba(255, 255, 255, 0.03); + transition: background 0.15s; +} + +.wz-card:last-of-type { + margin-bottom: 0; +} + +.wz-card.wz-warn { + background: rgba(248, 113, 113, 0.08); +} + +.wz-name { + font-size: 10px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; + letter-spacing: 0.3px; +} + +.wz-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 2px; +} + +.wz-item { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + color: var(--muted); + white-space: nowrap; +} + +.wz-icon { + font-size: 10px; + color: var(--accent); +} + +.wz-label { + font-size: 9px; + color: var(--muted); +} + +.wz-value { + font-size: 10px; + font-weight: 600; + color: var(--text); +} + +.wz-weather { + font-weight: 500; + color: var(--muted); +} + +.wp-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + padding-top: 6px; + border-top: 1px solid var(--border); +} + +.wp-time { + font-size: 9px; + color: var(--muted); +} + +.wp-refresh { + font-size: 14px; + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + width: 22px; + height: 22px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, border-color 0.15s; + padding: 0; +} + +.wp-refresh:hover { + color: var(--text); + border-color: var(--accent); +} + +.wp-refresh:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/apps/web/src/app/styles/layout.css b/apps/web/src/app/styles/layout.css new file mode 100644 index 0000000..18b1ec4 --- /dev/null +++ b/apps/web/src/app/styles/layout.css @@ -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; + } +} -- 2.45.2 From ec9d894ac87c82999f7a95e8aae6e151c36938d3 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Feb 2026 00:04:40 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20FSD=20=EC=9C=84=EB=B0=98=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C=20=E2=80=94=20=EA=B3=B5=EC=9C=A0=20=EC=83=81?= =?UTF-8?q?=EC=88=98/=ED=95=A8=EC=88=98=EB=A5=BC=20shared/=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../trackReplay/layers/replayLayers.ts | 2 +- .../trackReplay/layers/trackLayers.ts | 2 +- .../weatherOverlay/useWeatherOverlay.ts | 2 +- apps/web/src/shared/lib/map/mapConstants.ts | 20 +++++++++++++++++++ apps/web/src/shared/lib/map/mapTilerKey.ts | 9 +++++++++ apps/web/src/widgets/map3d/constants.ts | 19 ++++-------------- apps/web/src/widgets/map3d/lib/mapCore.ts | 8 ++------ 7 files changed, 38 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/shared/lib/map/mapConstants.ts create mode 100644 apps/web/src/shared/lib/map/mapTilerKey.ts diff --git a/apps/web/src/features/trackReplay/layers/replayLayers.ts b/apps/web/src/features/trackReplay/layers/replayLayers.ts index 013c4ba..c297e3e 100644 --- a/apps/web/src/features/trackReplay/layers/replayLayers.ts +++ b/apps/web/src/features/trackReplay/layers/replayLayers.ts @@ -3,7 +3,7 @@ import type { Layer } from '@deck.gl/core'; import type { ProcessedTrack } from '../model/track.types'; import { getShipKindColor } from '../lib/adapters'; import { TRACK_REPLAY_LAYER_IDS } from './trackLayers'; -import { DEPTH_DISABLED_PARAMS } from '../../../widgets/map3d/constants'; +import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants'; interface ReplayTrip { vesselId: string; diff --git a/apps/web/src/features/trackReplay/layers/trackLayers.ts b/apps/web/src/features/trackReplay/layers/trackLayers.ts index 206e3d0..640d4ea 100644 --- a/apps/web/src/features/trackReplay/layers/trackLayers.ts +++ b/apps/web/src/features/trackReplay/layers/trackLayers.ts @@ -1,6 +1,6 @@ import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import type { Layer, PickingInfo } from '@deck.gl/core'; -import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../widgets/map3d/constants'; +import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../shared/lib/map/mapConstants'; import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache'; import { getShipKindColor } from '../lib/adapters'; import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types'; diff --git a/apps/web/src/features/weatherOverlay/useWeatherOverlay.ts b/apps/web/src/features/weatherOverlay/useWeatherOverlay.ts index 2262708..ea95aea 100644 --- a/apps/web/src/features/weatherOverlay/useWeatherOverlay.ts +++ b/apps/web/src/features/weatherOverlay/useWeatherOverlay.ts @@ -9,7 +9,7 @@ import { RadarLayer, ColorRamp, } from '@maptiler/weather'; -import { getMapTilerKey } from '../../widgets/map3d/lib/mapCore'; +import { getMapTilerKey } from '../../shared/lib/map/mapTilerKey'; /** 6종 기상 레이어 ID */ export type WeatherLayerId = diff --git a/apps/web/src/shared/lib/map/mapConstants.ts b/apps/web/src/shared/lib/map/mapConstants.ts new file mode 100644 index 0000000..f17991a --- /dev/null +++ b/apps/web/src/shared/lib/map/mapConstants.ts @@ -0,0 +1,20 @@ +// ── Shared map constants ── +// Moved from widgets/map3d/constants.ts to resolve FSD layer violation +// (features/ must not import from widgets/). + +export const SHIP_ICON_MAPPING = { + ship: { + x: 0, + y: 0, + width: 128, + height: 128, + anchorX: 64, + anchorY: 64, + mask: true, + }, +} as const; + +export const DEPTH_DISABLED_PARAMS = { + depthCompare: 'always', + depthWriteEnabled: false, +} as const; diff --git a/apps/web/src/shared/lib/map/mapTilerKey.ts b/apps/web/src/shared/lib/map/mapTilerKey.ts new file mode 100644 index 0000000..16e6af9 --- /dev/null +++ b/apps/web/src/shared/lib/map/mapTilerKey.ts @@ -0,0 +1,9 @@ +// Moved from widgets/map3d/lib/mapCore.ts to resolve FSD layer violation +// (features/ must not import from widgets/). + +export function getMapTilerKey(): string | null { + const k = import.meta.env.VITE_MAPTILER_KEY; + if (typeof k !== 'string') return null; + const v = k.trim(); + return v ? v : null; +} diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts index 3da1d96..c29a7bc 100644 --- a/apps/web/src/widgets/map3d/constants.ts +++ b/apps/web/src/widgets/map3d/constants.ts @@ -15,18 +15,9 @@ const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; // ── Ship icon mapping (Deck.gl IconLayer) ── +// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage) -export const SHIP_ICON_MAPPING = { - ship: { - x: 0, - y: 0, - width: 128, - height: 128, - anchorX: 64, - anchorY: 64, - mask: true, - }, -} as const; +export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants'; // ── Ship constants ── @@ -70,10 +61,8 @@ export const DECK_VIEW_ID = 'mapbox'; // ── Depth params ── -export const DEPTH_DISABLED_PARAMS = { - depthCompare: 'always', - depthWriteEnabled: false, -} as const; +// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage) +export { DEPTH_DISABLED_PARAMS } from '../../shared/lib/map/mapConstants'; export const GLOBE_OVERLAY_PARAMS = { depthCompare: 'less-equal', diff --git a/apps/web/src/widgets/map3d/lib/mapCore.ts b/apps/web/src/widgets/map3d/lib/mapCore.ts index 1a35be3..e95616e 100644 --- a/apps/web/src/widgets/map3d/lib/mapCore.ts +++ b/apps/web/src/widgets/map3d/lib/mapCore.ts @@ -83,12 +83,8 @@ export function extractProjectionType(map: maplibregl.Map): MapProjectionId | un return undefined; } -export function getMapTilerKey(): string | null { - const k = import.meta.env.VITE_MAPTILER_KEY; - if (typeof k !== 'string') return null; - const v = k.trim(); - return v ? v : null; -} +// Canonical source: shared/lib/map/mapTilerKey.ts (re-exported for local usage) +export { getMapTilerKey } from '../../../shared/lib/map/mapTilerKey'; export function getLayerId(value: unknown): string | null { if (!value || typeof value !== 'object') return null; -- 2.45.2