fix: 모선 검토 대기 목록을 폴리곤 폴링 데이터에서 파생하여 동기화 문제 해소

- Backend: LATEST_GROUPS_SQL에 candidateCount CTE 추가 (GroupPolygonDto 확장)
- Frontend: parentInferenceQueue를 별도 API 대신 groupPolygons useMemo 파생으로 전환
- 렌더 루프 수정: refreshParentInferenceQueue deps에서 groupPolygons → polygonRefresh 분리
- 초기 로드 시 자동 그룹 선택 제거, 검토 패널만 표시
- 후보 소스 배지 축약 (CORRELATION→CORR, PREVIOUS_SELECTION→PREV)
- useGroupPolygons에 refresh 콜백 외부 노출

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-04 10:21:28 +09:00
부모 83db0f8149
커밋 0b74831b87
8개의 변경된 파일125개의 추가작업 그리고 87개의 파일을 삭제

4
.gitignore vendored
파일 보기

@ -29,6 +29,10 @@ coverage/
.prettiercache .prettiercache
*.tsbuildinfo *.tsbuildinfo
# === Codex CLI ===
AGENTS.md
.codex/
# === Claude Code === # === Claude Code ===
# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함 # 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함
!.claude/ !.claude/

파일 보기

@ -175,6 +175,11 @@ deploy/ # systemd + nginx 배포 설정
3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인. 3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인.
실패 시 사용자에게 알리고 중단. 실패 시 사용자에게 알리고 중단.
**MR/머지는 사용자 명시적 요청 없이 절대 진행 금지:**
- `git push` 완료 후에는 "푸시 완료" 보고만 하고 **중단**
- MR 생성은 사용자가 `/mr`, `/release` 호출 또는 "MR 해줘" 등 명시적 요청 시에만
- **스킬 없이 Gitea API를 직접 호출하여 MR/머지를 진행하지 말 것** — 스킬 절차(사용자 확인 단계)를 우회하는 것과 동일
### 스킬 목록 ### 스킬 목록
- `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시) - `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시)
- `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함) - `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함)

파일 보기

@ -26,5 +26,6 @@ public class GroupPolygonDto {
private List<Map<String, Object>> members; private List<Map<String, Object>> members;
private String color; private String color;
private String resolution; private String resolution;
private Integer candidateCount;
private ParentInferenceSummaryDto parentInference; private ParentInferenceSummaryDto parentInference;
} }

파일 보기

@ -36,10 +36,35 @@ public class GroupPolygonService {
private volatile long lastCacheTime = 0; private volatile long lastCacheTime = 0;
private static final String LATEST_GROUPS_SQL_TEMPLATE = """ private static final String LATEST_GROUPS_SQL_TEMPLATE = """
WITH latest_groups AS (
SELECT g.group_type, g.group_key, g.group_label, g.sub_cluster_id, g.snapshot_time, SELECT g.group_type, g.group_key, g.group_label, g.sub_cluster_id, g.snapshot_time,
ST_AsGeoJSON(g.polygon) AS polygon_geojson, ST_AsGeoJSON(g.polygon) AS polygon_geojson,
ST_Y(g.center_point) AS center_lat, ST_X(g.center_point) AS center_lon, ST_Y(g.center_point) AS center_lat, ST_X(g.center_point) AS center_lon,
g.area_sq_nm, g.member_count, g.zone_id, g.zone_name, g.members, g.color, g.resolution, g.area_sq_nm, g.member_count, g.zone_id, g.zone_name, g.members, g.color, g.resolution
FROM %s g
WHERE g.snapshot_time = (SELECT MAX(snapshot_time) FROM %s WHERE resolution = '1h')
AND g.resolution = '1h'
),
latest_candidate_snapshot AS (
SELECT c.group_key, c.sub_cluster_id, MAX(c.observed_at) AS observed_at
FROM %s c
JOIN latest_groups lg
ON lg.group_key = c.group_key
AND lg.sub_cluster_id = c.sub_cluster_id
AND c.observed_at >= lg.snapshot_time
GROUP BY c.group_key, c.sub_cluster_id
),
candidate_counts AS (
SELECT c.group_key, c.sub_cluster_id, COUNT(*) AS candidate_count
FROM %s c
JOIN latest_candidate_snapshot l
ON l.group_key = c.group_key
AND l.sub_cluster_id = c.sub_cluster_id
AND l.observed_at = c.observed_at
GROUP BY c.group_key, c.sub_cluster_id
)
SELECT lg.*,
COALESCE(cc.candidate_count, 0) AS candidate_count,
r.normalized_parent_name, r.normalized_parent_name,
r.status AS parent_inference_status, r.status AS parent_inference_status,
r.selected_parent_mmsi, r.selected_parent_mmsi,
@ -50,14 +75,15 @@ public class GroupPolygonService {
r.score_margin, r.score_margin,
r.stable_cycles, r.stable_cycles,
r.evidence_summary r.evidence_summary
FROM %s g FROM latest_groups lg
LEFT JOIN %s r LEFT JOIN %s r
ON r.group_key = g.group_key ON r.group_key = lg.group_key
AND r.sub_cluster_id = g.sub_cluster_id AND r.sub_cluster_id = lg.sub_cluster_id
AND r.last_evaluated_at >= g.snapshot_time AND r.last_evaluated_at >= lg.snapshot_time
WHERE g.snapshot_time = (SELECT MAX(snapshot_time) FROM %s WHERE resolution = '1h') LEFT JOIN candidate_counts cc
AND g.resolution = '1h' ON cc.group_key = lg.group_key
ORDER BY g.group_type, g.member_count DESC AND cc.sub_cluster_id = lg.sub_cluster_id
ORDER BY lg.group_type, lg.member_count DESC
"""; """;
private static final String GROUP_DETAIL_SQL_TEMPLATE = """ private static final String GROUP_DETAIL_SQL_TEMPLATE = """
@ -330,10 +356,13 @@ public class GroupPolygonService {
private String latestGroupsSql() { private String latestGroupsSql() {
String groupPolygonSnapshots = table("group_polygon_snapshots"); String groupPolygonSnapshots = table("group_polygon_snapshots");
String candidateSnapshots = table("gear_group_parent_candidate_snapshots");
return LATEST_GROUPS_SQL_TEMPLATE.formatted( return LATEST_GROUPS_SQL_TEMPLATE.formatted(
groupPolygonSnapshots, groupPolygonSnapshots,
table("gear_group_parent_resolution"), groupPolygonSnapshots,
groupPolygonSnapshots candidateSnapshots,
candidateSnapshots,
table("gear_group_parent_resolution")
); );
} }
@ -1380,6 +1409,7 @@ public class GroupPolygonService {
.members(members) .members(members)
.color(rs.getString("color")) .color(rs.getString("color"))
.resolution(rs.getString("resolution")) .resolution(rs.getString("resolution"))
.candidateCount(nullableInt(rs, "candidate_count"))
.parentInference(mapParentInferenceSummary(rs)) .parentInference(mapParentInferenceSummary(rs))
.build(); .build();
} }

파일 보기

@ -12,7 +12,6 @@ import {
fetchGroupParentInference, fetchGroupParentInference,
fetchParentCandidateExclusions, fetchParentCandidateExclusions,
fetchParentLabelSessions, fetchParentLabelSessions,
fetchParentInferenceReview,
createGroupParentLabelSession, createGroupParentLabelSession,
createGroupCandidateExclusion, createGroupCandidateExclusion,
createGlobalCandidateExclusion, createGlobalCandidateExclusion,
@ -134,6 +133,21 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) {
return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters }; return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters };
} }
function polygonToReviewItem(g: GroupPolygonDto): GroupParentInferenceItem {
return {
groupType: g.groupType,
groupKey: g.groupKey,
groupLabel: g.groupLabel,
subClusterId: g.subClusterId,
snapshotTime: g.snapshotTime,
zoneName: g.zoneName,
memberCount: g.memberCount,
resolution: g.resolution,
candidateCount: g.candidateCount ?? null,
parentInference: g.parentInference ?? null,
};
}
function compareReviewQueueItems( function compareReviewQueueItems(
a: GroupParentInferenceItem, a: GroupParentInferenceItem,
b: GroupParentInferenceItem, b: GroupParentInferenceItem,
@ -331,7 +345,6 @@ export function FleetClusterLayer({
const [enabledModels, setEnabledModels] = useState<Set<string>>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'])); const [enabledModels, setEnabledModels] = useState<Set<string>>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern']));
const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null); const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null);
const [parentInferenceItems, setParentInferenceItems] = useState<GroupParentInferenceItem[]>([]); const [parentInferenceItems, setParentInferenceItems] = useState<GroupParentInferenceItem[]>([]);
const [parentInferenceQueue, setParentInferenceQueue] = useState<GroupParentInferenceItem[]>([]);
const [parentActiveGroupExclusions, setParentActiveGroupExclusions] = useState<ParentCandidateExclusion[]>([]); const [parentActiveGroupExclusions, setParentActiveGroupExclusions] = useState<ParentCandidateExclusion[]>([]);
const [parentActiveGlobalExclusions, setParentActiveGlobalExclusions] = useState<ParentCandidateExclusion[]>([]); const [parentActiveGlobalExclusions, setParentActiveGlobalExclusions] = useState<ParentCandidateExclusion[]>([]);
const [parentActiveLabelSessions, setParentActiveLabelSessions] = useState<ParentLabelSession[]>([]); const [parentActiveLabelSessions, setParentActiveLabelSessions] = useState<ParentLabelSession[]>([]);
@ -468,21 +481,10 @@ export function FleetClusterLayer({
reviewSpatialFilterDrawing, reviewSpatialFilterDrawing,
]); ]);
const reloadParentInference = useCallback(async ( const reloadParentInference = useCallback(async (groupKey: string) => {
groupKey: string,
options?: { reloadQueue?: boolean },
) => {
setParentInferenceLoading(true); setParentInferenceLoading(true);
try { try {
const requests: Promise<unknown>[] = []; const [detailRes, groupExclusionRes, globalExclusionRes, labelSessionRes] = await Promise.all([
if (options?.reloadQueue !== false) {
requests.push(
fetchParentInferenceReview('REVIEW_REQUIRED', 100).catch(
() => ({ count: 0, items: [] as GroupParentInferenceItem[] }),
),
);
}
requests.push(
fetchGroupParentInference(groupKey).catch(() => ({ groupKey, count: 0, items: [] as GroupParentInferenceItem[] })), fetchGroupParentInference(groupKey).catch(() => ({ groupKey, count: 0, items: [] as GroupParentInferenceItem[] })),
fetchParentCandidateExclusions({ groupKey, activeOnly: true, limit: 200 }).catch( fetchParentCandidateExclusions({ groupKey, activeOnly: true, limit: 200 }).catch(
() => ({ count: 0, items: [] as ParentCandidateExclusion[] }), () => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
@ -493,20 +495,7 @@ export function FleetClusterLayer({
fetchParentLabelSessions({ groupKey, activeOnly: true, limit: 100 }).catch( fetchParentLabelSessions({ groupKey, activeOnly: true, limit: 100 }).catch(
() => ({ count: 0, items: [] as ParentLabelSession[] }), () => ({ count: 0, items: [] as ParentLabelSession[] }),
), ),
); ]);
const results = await Promise.all(requests);
let resultIndex = 0;
if (options?.reloadQueue !== false) {
const queueRes = results[resultIndex] as { count: number; items: GroupParentInferenceItem[] };
setParentInferenceQueue(queueRes.items);
resultIndex += 1;
}
const detailRes = results[resultIndex++] as { groupKey: string; count: number; items: GroupParentInferenceItem[] };
const groupExclusionRes = results[resultIndex++] as { count: number; items: ParentCandidateExclusion[] };
const globalExclusionRes = results[resultIndex++] as { count: number; items: ParentCandidateExclusion[] };
const labelSessionRes = results[resultIndex] as { count: number; items: ParentLabelSession[] };
setParentInferenceItems(detailRes.items); setParentInferenceItems(detailRes.items);
setParentActiveGroupExclusions(groupExclusionRes.items); setParentActiveGroupExclusions(groupExclusionRes.items);
setParentActiveGlobalExclusions(globalExclusionRes.items); setParentActiveGlobalExclusions(globalExclusionRes.items);
@ -516,33 +505,13 @@ export function FleetClusterLayer({
} }
}, []); }, []);
const polygonRefresh = groupPolygons?.refresh;
const refreshParentInferenceQueue = useCallback(async () => { const refreshParentInferenceQueue = useCallback(async () => {
setParentInferenceLoading(true); await polygonRefresh?.();
try {
const queueRes = await fetchParentInferenceReview('REVIEW_REQUIRED', 100).catch(
() => ({ count: 0, items: [] as GroupParentInferenceItem[] }),
);
setParentInferenceQueue(queueRes.items);
if (selectedGearGroup) { if (selectedGearGroup) {
const [groupExclusionRes, globalExclusionRes, labelSessionRes] = await Promise.all([ await reloadParentInference(selectedGearGroup);
fetchParentCandidateExclusions({ groupKey: selectedGearGroup, activeOnly: true, limit: 200 }).catch(
() => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
),
fetchParentCandidateExclusions({ scopeType: 'GLOBAL', activeOnly: true, limit: 500 }).catch(
() => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
),
fetchParentLabelSessions({ groupKey: selectedGearGroup, activeOnly: true, limit: 100 }).catch(
() => ({ count: 0, items: [] as ParentLabelSession[] }),
),
]);
setParentActiveGroupExclusions(groupExclusionRes.items);
setParentActiveGlobalExclusions(globalExclusionRes.items);
setParentActiveLabelSessions(labelSessionRes.items);
} }
} finally { }, [polygonRefresh, selectedGearGroup, reloadParentInference]);
setParentInferenceLoading(false);
}
}, [selectedGearGroup]);
// ── 히스토리 로드 (3개 API 순차 await → 스토어 초기화 → 재생) ── // ── 히스토리 로드 (3개 API 순차 await → 스토어 초기화 → 재생) ──
const loadHistory = async (groupKey: string) => { const loadHistory = async (groupKey: string) => {
@ -705,6 +674,23 @@ export function FleetClusterLayer({
return { byCompositeKey, byGroupKey }; return { byCompositeKey, byGroupKey };
}, [groupPolygons?.allGroups]); }, [groupPolygons?.allGroups]);
const parentInferenceQueue = useMemo(() => {
const allGroups = groupPolygons?.allGroups ?? [];
return allGroups
.filter(g =>
g.groupType !== 'FLEET'
&& g.parentInference != null
&& g.parentInference.status === 'REVIEW_REQUIRED',
)
.map(polygonToReviewItem)
.sort((a, b) => {
const sa = a.parentInference?.topScore ?? 0;
const sb = b.parentInference?.topScore ?? 0;
if (sb !== sa) return sb - sa;
return (b.memberCount ?? 0) - (a.memberCount ?? 0);
});
}, [groupPolygons?.allGroups]);
const reviewSpatialFilterPoints = useMemo( const reviewSpatialFilterPoints = useMemo(
() => reviewSpatialFilterVertices.map(vertex => vertex.coordinate), () => reviewSpatialFilterVertices.map(vertex => vertex.coordinate),
[reviewSpatialFilterVertices], [reviewSpatialFilterVertices],
@ -883,6 +869,7 @@ export function FleetClusterLayer({
|| reviewSearchText.trim().length > 0 || reviewSearchText.trim().length > 0
|| !!reviewSpatialFilterPolygon; || !!reviewSpatialFilterPolygon;
const reviewAutoFocusMode = autoOpenReviewPanel && !historyActive; const reviewAutoFocusMode = autoOpenReviewPanel && !historyActive;
const showReviewPanel = !!selectedGearGroup || (reviewAutoFocusMode && parentInferenceQueue.length > 0);
const effectiveEnabledModels = useMemo( const effectiveEnabledModels = useMemo(
() => (reviewAutoFocusMode ? new Set<string>() : reviewDrivenOverlayMode ? new Set<string>(['identity']) : enabledModels), () => (reviewAutoFocusMode ? new Set<string>() : reviewDrivenOverlayMode ? new Set<string>(['identity']) : enabledModels),
[enabledModels, reviewAutoFocusMode, reviewDrivenOverlayMode], [enabledModels, reviewAutoFocusMode, reviewDrivenOverlayMode],
@ -959,10 +946,7 @@ export function FleetClusterLayer({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [selectedGearGroup, historyActive]); }, [selectedGearGroup, historyActive]);
useEffect(() => { // parentInferenceQueue는 groupPolygons에서 파생되므로 별도 초기 로드 불필요
if (!autoOpenReviewPanel) return;
void refreshParentInferenceQueue();
}, [autoOpenReviewPanel, refreshParentInferenceQueue]);
useEffect(() => { useEffect(() => {
if (!selectedGearGroup) { if (!selectedGearGroup) {
@ -974,7 +958,7 @@ export function FleetClusterLayer({
return; return;
} }
let cancelled = false; let cancelled = false;
void reloadParentInference(selectedGearGroup, { reloadQueue: false }).catch(() => { void reloadParentInference(selectedGearGroup).catch(() => {
if (!cancelled) { if (!cancelled) {
setParentInferenceItems([]); setParentInferenceItems([]);
setParentActiveGroupExclusions([]); setParentActiveGroupExclusions([]);
@ -1306,6 +1290,7 @@ export function FleetClusterLayer({
throw new Error('unsupported parent workflow action'); throw new Error('unsupported parent workflow action');
} }
await reloadParentInference(selectedGearGroup); await reloadParentInference(selectedGearGroup);
void polygonRefresh?.();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'parent workflow action failed'; const message = error instanceof Error ? error.message : 'parent workflow action failed';
console.warn('[parent-workflow] 실패:', message); console.warn('[parent-workflow] 실패:', message);
@ -1317,6 +1302,7 @@ export function FleetClusterLayer({
activeGlobalExclusionByMmsi, activeGlobalExclusionByMmsi,
activeGroupExclusionByKey, activeGroupExclusionByKey,
activeLabelSessionByQueueKey, activeLabelSessionByQueueKey,
polygonRefresh,
parentReviewActor, parentReviewActor,
parentWorkflowDurationDays, parentWorkflowDurationDays,
reloadParentInference, reloadParentInference,
@ -1333,18 +1319,12 @@ export function FleetClusterLayer({
handleGearGroupZoom(groupKey, subClusterId); handleGearGroupZoom(groupKey, subClusterId);
}, [handleGearGroupZoom, reloadParentInference, selectedGearGroup]); }, [handleGearGroupZoom, reloadParentInference, selectedGearGroup]);
// autoOpenReviewPanel 시 검토 패널만 표시, 그룹 자동 선택 없음
useEffect(() => { useEffect(() => {
if (!autoOpenReviewPanel || autoOpenedReviewRef.current || selectedGearGroup || displayedParentInferenceQueue.length === 0) { if (!autoOpenReviewPanel || autoOpenedReviewRef.current) return;
return; if (displayedParentInferenceQueue.length === 0) return;
}
const first = displayedParentInferenceQueue[0];
autoOpenedReviewRef.current = true; autoOpenedReviewRef.current = true;
setSelectedReviewQueueKey(reviewItemKey(first.groupKey, first.subClusterId)); }, [autoOpenReviewPanel, displayedParentInferenceQueue]);
setSelectedGearGroup(first.groupKey);
setExpandedGearGroup(first.groupKey);
const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === first.groupKey);
setActiveSection(isInZone ? 'inZone' : 'outZone');
}, [autoOpenReviewPanel, displayedParentInferenceQueue, groupPolygons?.gearInZoneGroups, selectedGearGroup]);
const resetParentReviewFilters = useCallback(() => { const resetParentReviewFilters = useCallback(() => {
setReviewFiltersTouched(false); setReviewFiltersTouched(false);
@ -1418,9 +1398,9 @@ export function FleetClusterLayer({
/> />
)} )}
{selectedGearGroup && ( {showReviewPanel && (
<ParentReviewPanel <ParentReviewPanel
selectedGearGroup={selectedGearGroup} selectedGearGroup={selectedGearGroup ?? ''}
items={parentInferenceItems} items={parentInferenceItems}
reviewQueue={displayedParentInferenceQueue} reviewQueue={displayedParentInferenceQueue}
reviewQueueFilteredCount={filteredParentInferenceQueue.length} reviewQueueFilteredCount={filteredParentInferenceQueue.length}
@ -1446,7 +1426,7 @@ export function FleetClusterLayer({
spatialFilterPointCount={reviewSpatialFilterPoints.length} spatialFilterPointCount={reviewSpatialFilterPoints.length}
onActorChange={setParentReviewActor} onActorChange={setParentReviewActor}
onWorkflowDurationDaysChange={(value) => setStoredParentWorkflowDurationDays(sanitizeWorkflowDurationDays(value))} onWorkflowDurationDaysChange={(value) => setStoredParentWorkflowDurationDays(sanitizeWorkflowDurationDays(value))}
onRefresh={() => { void reloadParentInference(selectedGearGroup); }} onRefresh={() => { void refreshParentInferenceQueue(); }}
onSelectGroup={handleParentReviewSelectGroup} onSelectGroup={handleParentReviewSelectGroup}
onJumpToGroup={handleJumpToReviewGroup} onJumpToGroup={handleJumpToReviewGroup}
onQueueHover={(queueKey) => { onQueueHover={(queueKey) => {

파일 보기

@ -127,9 +127,22 @@ function topScorePct(item: GroupParentInferenceItem) {
return Math.round((item.parentInference?.topScore ?? 0) * 100); return Math.round((item.parentInference?.topScore ?? 0) * 100);
} }
const SOURCE_SHORT: Record<string, string> = {
CORRELATION: 'CORR',
PREVIOUS_SELECTION: 'PREV',
DIRECT_PARENT_MATCH: 'DIRECT',
EPISODE_PRIOR: 'EPISODE',
MANUAL_LABEL: 'LABEL',
};
function shortenSource(s: string) {
return SOURCE_SHORT[s] ?? s;
}
function sourceList(candidate: ParentInferenceCandidate) { function sourceList(candidate: ParentInferenceCandidate) {
const raw = candidate.evidence?.sources; const raw = candidate.evidence?.sources;
return Array.isArray(raw) ? raw.join(', ') : candidate.candidateSource; if (Array.isArray(raw)) return raw.map(shortenSource).join(', ');
return shortenSource(candidate.candidateSource);
} }
function queueItemKey(item: GroupParentInferenceItem) { function queueItemKey(item: GroupParentInferenceItem) {

파일 보기

@ -38,8 +38,11 @@ export interface UseGroupPolygonsResult {
allGroups: GroupPolygonDto[]; allGroups: GroupPolygonDto[];
isLoading: boolean; isLoading: boolean;
lastUpdated: number; lastUpdated: number;
refresh: () => Promise<void>;
} }
const NOOP_REFRESH = async () => {};
const EMPTY: UseGroupPolygonsResult = { const EMPTY: UseGroupPolygonsResult = {
fleetGroups: [], fleetGroups: [],
gearInZoneGroups: [], gearInZoneGroups: [],
@ -47,6 +50,7 @@ const EMPTY: UseGroupPolygonsResult = {
allGroups: [], allGroups: [],
isLoading: false, isLoading: false,
lastUpdated: 0, lastUpdated: 0,
refresh: NOOP_REFRESH,
}; };
export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult { export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
@ -92,5 +96,5 @@ export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
if (!enabled) return EMPTY; if (!enabled) return EMPTY;
return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated }; return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated, refresh: load };
} }

파일 보기

@ -88,6 +88,7 @@ export interface GroupPolygonDto {
members: MemberInfo[]; members: MemberInfo[];
color: string; color: string;
resolution?: '1h' | '1h-fb' | '6h'; resolution?: '1h' | '1h-fb' | '6h';
candidateCount?: number | null;
parentInference?: ParentInferenceSummary | null; parentInference?: ParentInferenceSummary | null;
} }