From 650c027013c80f67f033ad92edf9bc2db971236f Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 14:08:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=95=9C=EA=B5=AD=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EC=9C=84=EC=84=B1=EC=A7=80=EB=8F=84/ENC=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20+=20ENC=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ENC 전자해도: gcnautical 벡터 타일 연동 (gc-wing-dev 이식) - 상단 위성/ENC 토글 버튼 + ⚙ 드롭다운 설정 패널 - 12개 심볼 토글 + 8개 색상 수정 + 초기화 - mapMode/encSettings localStorage 영속화 - style.load 대기 패턴으로 스타일 전환 시 설정 자동 적용 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/korea/KoreaDashboard.tsx | 31 ++++- frontend/src/components/korea/KoreaMap.tsx | 33 ++++- .../features/encMap/EncMapSettingsPanel.tsx | 129 ++++++++++++++++++ frontend/src/features/encMap/encSettings.ts | 44 ++++++ frontend/src/features/encMap/encStyle.ts | 22 +++ frontend/src/features/encMap/types.ts | 82 +++++++++++ .../src/features/encMap/useEncMapSettings.ts | 64 +++++++++ 7 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/encMap/EncMapSettingsPanel.tsx create mode 100644 frontend/src/features/encMap/encSettings.ts create mode 100644 frontend/src/features/encMap/encStyle.ts create mode 100644 frontend/src/features/encMap/types.ts create mode 100644 frontend/src/features/encMap/useEncMapSettings.ts diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index ee37b93..fc3c6dd 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -2,6 +2,9 @@ import { useState, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage'; +import { DEFAULT_ENC_MAP_SETTINGS } from '../../features/encMap/types'; +import type { EncMapSettings } from '../../features/encMap/types'; +import { EncMapSettingsPanel } from '../../features/encMap/EncMapSettingsPanel'; import { KoreaMap } from './KoreaMap'; import { FieldAnalysisModal } from './FieldAnalysisModal'; import { ReportModal } from './ReportModal'; @@ -88,6 +91,9 @@ export const KoreaDashboard = ({ const [externalFlyTo, setExternalFlyTo] = useState<{ lat: number; lng: number; zoom: number } | null>(null); const { t } = useTranslation(); + const [mapMode, setMapMode] = useLocalStorage<'satellite' | 'enc'>('koreaMapMode', 'satellite'); + const [encSettings, setEncSettings] = useLocalStorage('encMapSettings', DEFAULT_ENC_MAP_SETTINGS); + const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = useSharedFilters(); @@ -274,7 +280,25 @@ export const KoreaDashboard = ({ return ( <> {headerSlot && createPortal( -
+ <> +
+ + + {mapMode === 'enc' && ( + + )} +
+
-
, +
+ , headerSlot, )} {countsSlot && createPortal( @@ -365,6 +390,8 @@ export const KoreaDashboard = ({ externalFlyTo={externalFlyTo} onExternalFlyToDone={() => setExternalFlyTo(null)} opsRoute={opsRoute} + mapMode={mapMode} + encSettings={encSettings} />
void; opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null; + mapMode: 'satellite' | 'enc'; + encSettings: EncMapSettings; } // MarineTraffic-style: satellite + dark ocean + nautical overlay @@ -213,10 +219,26 @@ const DebugTools = import.meta.env.DEV ? lazy(() => import('./debug')) : null; -export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) { +export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute, mapMode, encSettings }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); + const maplibreRef = useRef(null); const overlayRef = useRef(null); + + // ENC 스타일 사전 로드 + const [encStyle, setEncStyle] = useState(null); + useEffect(() => { + const ctrl = new AbortController(); + fetchEncStyle(ctrl.signal).then(setEncStyle).catch(() => {}); + return () => ctrl.abort(); + }, []); + const activeMapStyle = mapMode === 'enc' && encStyle ? encStyle : MAP_STYLE; + + // ENC 설정 적용을 트리거하는 epoch — 맵 로드/스타일 전환 시 증가 + const [encSyncEpoch, setEncSyncEpoch] = useState(0); + + // ENC 설정 런타임 적용 + useEncMapSettings(maplibreRef, mapMode, encSettings, encSyncEpoch); const replayLayerRef = useRef([]); const fleetClusterLayerRef = useRef([]); const requestRenderRef = useRef<(() => void) | null>(null); @@ -276,7 +298,10 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF }, []); // MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제) - const handleMapLoad = useCallback(() => {}, []); + const handleMapLoad = useCallback(() => { + maplibreRef.current = mapRef.current?.getMap() ?? null; + setEncSyncEpoch(v => v + 1); + }, []); // ── shipDeckStore 동기화 ── useEffect(() => { @@ -656,7 +681,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ref={mapRef} initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }} style={{ width: '100%', height: '100%' }} - mapStyle={MAP_STYLE} + mapStyle={activeMapStyle} onZoom={handleZoom} onLoad={handleMapLoad} > @@ -1057,6 +1082,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ); })()} + + {/* ENC 설정 패널은 KoreaDashboard 헤더에서 렌더 */} ); } diff --git a/frontend/src/features/encMap/EncMapSettingsPanel.tsx b/frontend/src/features/encMap/EncMapSettingsPanel.tsx new file mode 100644 index 0000000..26fbe2f --- /dev/null +++ b/frontend/src/features/encMap/EncMapSettingsPanel.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import type { EncMapSettings } from './types'; +import { DEFAULT_ENC_MAP_SETTINGS, ENC_DEPTH_COLOR_TARGETS } from './types'; +import { FONT_MONO } from '../../styles/fonts'; + +interface EncMapSettingsPanelProps { + value: EncMapSettings; + onChange: (next: EncMapSettings) => void; +} + +const SYMBOL_TOGGLES: { key: keyof EncMapSettings; label: string }[] = [ + { key: 'showBuoys', label: '부표' }, + { key: 'showBeacons', label: '비콘' }, + { key: 'showLights', label: '등대' }, + { key: 'showDangers', label: '위험물' }, + { key: 'showLandmarks', label: '랜드마크' }, + { key: 'showSoundings', label: '수심' }, + { key: 'showPilot', label: '도선소' }, + { key: 'showAnchorage', label: '정박지' }, + { key: 'showRestricted', label: '제한구역' }, + { key: 'showDredged', label: '준설구역' }, + { key: 'showTSS', label: '통항분리대' }, + { key: 'showContours', label: '등심선' }, +]; + +const AREA_COLOR_INPUTS: { key: keyof EncMapSettings; label: string }[] = [ + { key: 'backgroundColor', label: '바다 배경' }, + { key: 'landColor', label: '육지' }, + { key: 'coastlineColor', label: '해안선' }, +]; + + +export function EncMapSettingsPanel({ value, onChange }: EncMapSettingsPanelProps) { + const [open, setOpen] = useState(false); + + const update = (key: K, val: EncMapSettings[K]) => { + onChange({ ...value, [key]: val }); + }; + + const isDefault = JSON.stringify(value) === JSON.stringify(DEFAULT_ENC_MAP_SETTINGS); + const allChecked = SYMBOL_TOGGLES.every(({ key }) => value[key] as boolean); + + const toggleAll = (checked: boolean) => { + const next = { ...value }; + for (const { key } of SYMBOL_TOGGLES) { + (next as Record)[key] = checked; + } + onChange(next); + }; + + return ( + <> + + + {open && ( +
+
+ ENC 설정 + {!isDefault && ( + + )} +
+ + {/* 레이어 토글 */} +
+
+ 레이어 표시 + +
+
+ {SYMBOL_TOGGLES.map(({ key, label }) => ( + + ))} +
+
+ + {/* 영역 색상 */} +
+
영역 색상
+ {AREA_COLOR_INPUTS.map(({ key, label }) => ( +
+ {label} + update(key, e.target.value as never)} + style={{ width: 24, height: 16, border: 'none', cursor: 'pointer', background: 'transparent' }} /> +
+ ))} +
+ + {/* 수심 색상 */} +
+
수심 색상
+ {ENC_DEPTH_COLOR_TARGETS.map(({ key, label }) => ( +
+ {label} + update(key, e.target.value as never)} + style={{ width: 24, height: 16, border: 'none', cursor: 'pointer', background: 'transparent' }} /> +
+ ))} +
+
+ )} + + ); +} diff --git a/frontend/src/features/encMap/encSettings.ts b/frontend/src/features/encMap/encSettings.ts new file mode 100644 index 0000000..f76f5e7 --- /dev/null +++ b/frontend/src/features/encMap/encSettings.ts @@ -0,0 +1,44 @@ +import type maplibregl from 'maplibre-gl'; +import type { EncMapSettings } from './types'; +import { ENC_LAYER_CATEGORIES, ENC_COLOR_TARGETS, ENC_DEPTH_COLOR_TARGETS } from './types'; + +export function applyEncVisibility(map: maplibregl.Map, settings: EncMapSettings): void { + for (const [key, layerIds] of Object.entries(ENC_LAYER_CATEGORIES)) { + const visible = settings[key as keyof EncMapSettings] as boolean; + const vis = visible ? 'visible' : 'none'; + for (const layerId of layerIds) { + try { + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', vis); + } + } catch { /* layer may not exist */ } + } + } +} + +export function applyEncColors(map: maplibregl.Map, settings: EncMapSettings): void { + for (const [layerId, prop, key] of ENC_COLOR_TARGETS) { + try { + if (map.getLayer(layerId)) { + map.setPaintProperty(layerId, prop, settings[key] as string); + } + } catch { /* ignore */ } + } + + try { + if (map.getLayer('background')) { + map.setPaintProperty('background', 'background-color', settings.backgroundColor); + } + } catch { /* ignore */ } + + for (const { key, layerIds } of ENC_DEPTH_COLOR_TARGETS) { + const color = settings[key] as string; + for (const layerId of layerIds) { + try { + if (map.getLayer(layerId)) { + map.setPaintProperty(layerId, 'fill-color', color); + } + } catch { /* ignore */ } + } + } +} diff --git a/frontend/src/features/encMap/encStyle.ts b/frontend/src/features/encMap/encStyle.ts new file mode 100644 index 0000000..5f39fa1 --- /dev/null +++ b/frontend/src/features/encMap/encStyle.ts @@ -0,0 +1,22 @@ +import type { StyleSpecification } from 'maplibre-gl'; + +const NAUTICAL_STYLE_URL = 'https://tiles.gcnautical.com/styles/nautical.json'; + +const SERVER_FONTS = ['Noto Sans CJK KR Regular', 'Noto Sans Regular']; + +export async function fetchEncStyle(signal: AbortSignal): Promise { + const res = await fetch(NAUTICAL_STYLE_URL, { signal }); + if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`); + const style = (await res.json()) as StyleSpecification; + + for (const layer of style.layers) { + const layout = (layer as { layout?: Record }).layout; + if (!layout) continue; + const tf = layout['text-font']; + if (Array.isArray(tf) && tf.every((v) => typeof v === 'string')) { + layout['text-font'] = SERVER_FONTS; + } + } + + return style; +} diff --git a/frontend/src/features/encMap/types.ts b/frontend/src/features/encMap/types.ts new file mode 100644 index 0000000..e53ba4c --- /dev/null +++ b/frontend/src/features/encMap/types.ts @@ -0,0 +1,82 @@ +export interface EncMapSettings { + showBuoys: boolean; + showBeacons: boolean; + showLights: boolean; + showDangers: boolean; + showLandmarks: boolean; + showSoundings: boolean; + showPilot: boolean; + showAnchorage: boolean; + showRestricted: boolean; + showDredged: boolean; + showTSS: boolean; + showContours: boolean; + + landColor: string; + coastlineColor: string; + backgroundColor: string; + + depthDrying: string; + depthVeryShallow: string; + depthSafetyZone: string; + depthMedium: string; + depthDeep: string; +} + +export const DEFAULT_ENC_MAP_SETTINGS: EncMapSettings = { + showBuoys: true, + showBeacons: true, + showLights: true, + showDangers: true, + showLandmarks: true, + showSoundings: true, + showPilot: true, + showAnchorage: true, + showRestricted: true, + showDredged: true, + showTSS: true, + showContours: true, + + landColor: '#BFBE8D', + coastlineColor: '#4C5B62', + backgroundColor: '#93AEBB', + + depthDrying: '#58AF99', + depthVeryShallow: '#61B7FF', + depthSafetyZone: '#82CAFF', + depthMedium: '#A7D9FA', + depthDeep: '#C9EDFD', +}; + +export const ENC_LAYER_CATEGORIES: Record = { + showBuoys: ['boylat', 'boycar', 'boyisd', 'boysaw', 'boyspp'], + showBeacons: ['lndmrk'], + showLights: ['lights', 'lights-catlit'], + showDangers: ['uwtroc', 'obstrn', 'wrecks'], + showLandmarks: ['lndmrk'], + showSoundings: ['soundg', 'soundg-critical'], + showPilot: ['pilbop'], + showAnchorage: ['achare', 'achare-outline'], + showRestricted: ['resare-outline', 'resare-symbol', 'mipare'], + showDredged: [ + 'drgare-drying', 'drgare-very-shallow', 'drgare-safety-zone', + 'drgare-medium', 'drgare-deep', 'drgare-pattern', 'drgare-outline', 'drgare-symbol', + ], + showTSS: ['tsslpt', 'tsslpt-outline'], + showContours: ['depcnt', 'depare-safety-edge', 'depare-safety-edge-label'], +}; + +export const ENC_COLOR_TARGETS: [layerId: string, prop: string, settingsKey: keyof EncMapSettings][] = [ + ['lndare', 'fill-color', 'landColor'], + ['globe-lndare', 'fill-color', 'landColor'], + ['coalne', 'line-color', 'coastlineColor'], + ['globe-coalne', 'line-color', 'coastlineColor'], +]; + +export const ENC_DEPTH_COLOR_TARGETS: { key: keyof EncMapSettings; label: string; layerIds: string[] }[] = [ + { key: 'depthDrying', label: '건출 (< 0m)', layerIds: ['depare-drying', 'drgare-drying'] }, + { key: 'depthVeryShallow', label: '극천 (0~2m)', layerIds: ['depare-very-shallow', 'drgare-very-shallow'] }, + { key: 'depthSafetyZone', label: '안전수심 (2~30m)', layerIds: ['depare-safety-zone', 'drgare-safety-zone'] }, + { key: 'depthMedium', label: '중간 (30m~)', layerIds: ['depare-medium', 'drgare-medium'] }, + { key: 'depthDeep', label: '심해', layerIds: ['depare-deep', 'drgare-deep'] }, +]; diff --git a/frontend/src/features/encMap/useEncMapSettings.ts b/frontend/src/features/encMap/useEncMapSettings.ts new file mode 100644 index 0000000..f44b5a5 --- /dev/null +++ b/frontend/src/features/encMap/useEncMapSettings.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import { applyEncVisibility, applyEncColors } from './encSettings'; +import type { EncMapSettings } from './types'; + +/** + * 스타일 로드 완료를 안정적으로 감지하여 callback 실행. + * gc-wing-dev onMapStyleReady 패턴 이식. + */ +function onStyleReady(map: maplibregl.Map, callback: () => void): () => void { + if (map.isStyleLoaded()) { + callback(); + return () => {}; + } + let fired = false; + const runOnce = () => { + if (fired || !map.isStyleLoaded()) return; + fired = true; + callback(); + try { map.off('style.load', runOnce); map.off('styledata', runOnce); } catch { /* ignore */ } + }; + map.on('style.load', runOnce); + map.on('styledata', runOnce); + return () => { + try { map.off('style.load', runOnce); map.off('styledata', runOnce); } catch { /* ignore */ } + }; +} + +export function useEncMapSettings( + mapRef: MutableRefObject, + mapMode: 'satellite' | 'enc', + settings: EncMapSettings, + syncEpoch = 0, +) { + // settings를 ref로 유지 — style.load 콜백에서 최신값 참조 + const settingsRef = useRef(settings); + settingsRef.current = settings; + + // syncEpoch 변경 = 맵 로드 완료 → 전체 설정 재적용 + // mapMode 변경 = 위성↔ENC 전환 → style.load 대기 후 적용 + useEffect(() => { + if (mapMode !== 'enc') return; + const map = mapRef.current; + if (!map) return; + + const applyAll = () => { + const s = settingsRef.current; + applyEncVisibility(map, s); + applyEncColors(map, s); + }; + + const stop = onStyleReady(map, applyAll); + return stop; + }, [mapMode, syncEpoch, mapRef]); + + // settings 변경 시 즉시 적용 (스타일이 이미 로드된 상태에서) + useEffect(() => { + if (mapMode !== 'enc') return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + applyEncVisibility(map, settings); + applyEncColors(map, settings); + }, [settings, mapMode, mapRef]); +} -- 2.45.2 From a7eb7068394474c8768015dcac951ba2cbd33b6b Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 14:17:27 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20FLEET=20=ED=83=80=EC=9E=85=EC=97=90?= =?UTF-8?q?=20resolution=3D'1h'=20=EB=88=84=EB=9D=BD=20=E2=80=94=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=98=84=ED=99=A9=20API=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=84=A0=EB=8B=A8=20=EA=B7=B8=EB=A3=B9=20=EB=AF=B8?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FLEET 스냅샷에 resolution 필드를 설정하지 않아 DB default '6h'로 저장됨. LATEST_GROUPS_SQL이 resolution='1h' 필터를 사용하므로 FLEET 전부 누락. Co-Authored-By: Claude Opus 4.6 (1M context) --- prediction/algorithms/polygon_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 78339fb..5d0be30 100644 --- a/prediction/algorithms/polygon_builder.py +++ b/prediction/algorithms/polygon_builder.py @@ -399,6 +399,7 @@ def build_all_group_snapshots( 'group_type': 'FLEET', 'group_key': str(company_id), 'group_label': group_label, + 'resolution': '1h', 'snapshot_time': now, 'polygon_wkt': polygon_wkt, 'center_wkt': center_wkt, -- 2.45.2 From 98d173701e9537cf3a88d7ffdd0f70f7d372d29f Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 14:22:59 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=96=B4=EA=B5=AC=20=ED=98=84=ED=99=A9=EC=97=90=EC=84=9C=20fal?= =?UTF-8?q?lback=20=EA=B7=B8=EB=A3=B9=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1h 실제 활성 멤버 < 2일 때 resolution='1h-fb' (fallback)로 저장 - LATEST_GROUPS_SQL은 resolution='1h'만 필터 → fallback 자동 제외 - 리플레이 history API는 1h-fb 포함 (리플레이/일치율 추적 유지) - 프론트 리플레이: '1h' + '1h-fb' 모두 1h 프레임으로 처리 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/korea/FleetClusterLayer.tsx | 2 +- prediction/algorithms/polygon_builder.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index a5d43c8..57f5a25 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -180,7 +180,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS ]); // 2. resolution별 분리 → 1h(primary) + 6h(secondary) - const history1h = history.filter(h => h.resolution === '1h'); + const history1h = history.filter(h => h.resolution === '1h' || h.resolution === '1h-fb'); const history6h = history.filter(h => h.resolution === '6h'); // fallback: resolution 필드 없는 기존 데이터는 6h로 취급 const effective1h = history1h.length > 0 ? history1h : history; diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 5d0be30..2520806 100644 --- a/prediction/algorithms/polygon_builder.py +++ b/prediction/algorithms/polygon_builder.py @@ -423,12 +423,16 @@ def build_all_group_snapshots( continue # ── 1h 활성 멤버 필터 ── - display_members_1h = [ + active_members_1h = [ gm for gm in gear_members if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC ] - # fallback: 1h < 2이면 time_bucket 최신 2개 유지 (폴리곤 형태 보존) - if len(display_members_1h) < 2 and len(gear_members) >= 2: + active_count_1h = len(active_members_1h) + + # fallback: 1h < 2이면 time_bucket 최신 2개 유지 (리플레이/일치율 추적용) + # 라이브 현황에서는 active_count_1h로 필터 (fallback 그룹 제외) + display_members_1h = active_members_1h + if active_count_1h < 2 and len(gear_members) >= 2: sorted_by_age = sorted( gear_members, key=lambda gm: _get_time_bucket_age(gm.get('mmsi'), all_positions, now), @@ -443,7 +447,9 @@ def build_all_group_snapshots( display_members_6h = gear_members # ── resolution별 스냅샷 생성 ── - for resolution, members_for_snap in [('1h', display_members_1h), ('6h', display_members_6h)]: + # 1h-fb: fallback (실제 1h 활성 < 2) — 리플레이/일치율 추적용, 라이브 현황에서 제외 + res_1h = '1h' if active_count_1h >= 2 else '1h-fb' + for resolution, members_for_snap in [(res_1h, display_members_1h), ('6h', display_members_6h)]: if len(members_for_snap) < 2: continue # 6h: 최신 적재가 STALE_SEC(6h) 초과 시 스킵 -- 2.45.2 From fc6f696d1fdc6d30a4f45fc1c90945ed6eefff79 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 15:00:04 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=96=B4=EA=B5=AC=20=ED=98=84=ED=99=A9=20fallback=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20+=20FLEET=20resolution=20+=20DB=20VARCHAR(8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1h 실제 활성 멤버 < 2일 때 resolution='1h-fb' (fallback)로 저장 - LATEST_GROUPS_SQL은 resolution='1h'만 필터 → fallback 자동 제외 - FLEET 타입에 resolution='1h' 추가 (이전 누락) - DB resolution 컬럼: VARCHAR(4) → VARCHAR(8) 확장 - 프론트 리플레이: '1h' + '1h-fb' 모두 1h 프레임으로 처리 Co-Authored-By: Claude Opus 4.6 (1M context) --- database/migration/011_polygon_resolution.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migration/011_polygon_resolution.sql b/database/migration/011_polygon_resolution.sql index 4b02d0b..e7e7f53 100644 --- a/database/migration/011_polygon_resolution.sql +++ b/database/migration/011_polygon_resolution.sql @@ -2,7 +2,7 @@ -- 기존 데이터는 DEFAULT '6h'로 취급 ALTER TABLE kcg.group_polygon_snapshots - ADD COLUMN IF NOT EXISTS resolution VARCHAR(4) DEFAULT '6h'; + ADD COLUMN IF NOT EXISTS resolution VARCHAR(8) DEFAULT '6h'; -- 기존 인덱스 교체: resolution 포함 DROP INDEX IF EXISTS kcg.idx_gps_type_time; -- 2.45.2 From e9cbeaa0d8dd79bbd81ac53ebbf4979da37c8369 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 15:04:34 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-04-01.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 4a14389..11a4bc0 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,17 @@ ## [Unreleased] +## [2026-04-01.2] + +### 추가 +- 한국 현황 위성지도/ENC 토글 (gcnautical 벡터 타일 연동) +- ENC 스타일 설정 패널 (12개 심볼 토글 + 8개 색상 수정 + 초기화) + +### 수정 +- 라이브 어구 현황에서 fallback 그룹 제외 (1h-fb resolution 분리) +- FLEET 타입 resolution='1h' 누락 수정 +- DB resolution 컬럼 VARCHAR(4)→VARCHAR(8) 확장 + ## [2026-04-01] ### 추가 -- 2.45.2