Merge pull request 'release: 2026-04-04 (31건 커밋)' (#223) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m28s

This commit is contained in:
htlee 2026-04-04 10:53:04 +09:00
커밋 32f9aa897b
90개의 변경된 파일13313개의 추가작업 그리고 527개의 파일을 삭제

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 생성 (릴리즈 노트 갱신 포함)

파일 보기

@ -17,7 +17,7 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "login_history", schema = "kcg") @Table(name = "login_history")
@Getter @Getter
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor

파일 보기

@ -15,7 +15,7 @@ import lombok.Setter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "users", schema = "kcg") @Table(name = "users")
@Getter @Getter
@Setter @Setter
@Builder @Builder

파일 보기

@ -14,7 +14,7 @@ import java.util.List;
@Configuration @Configuration
public class WebConfig { public class WebConfig {
@Value("${app.cors.allowed-origins:http://localhost:5173}") @Value("${app.cors.allowed-origins:http://localhost:5174,http://localhost:5173}")
private List<String> allowedOrigins; private List<String> allowedOrigins;
@Bean @Bean

파일 보기

@ -7,7 +7,7 @@ import org.locationtech.jts.geom.Point;
import java.time.Instant; import java.time.Instant;
@Entity @Entity
@Table(name = "aircraft_positions", schema = "kcg") @Table(name = "aircraft_positions")
@Getter @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor

파일 보기

@ -9,7 +9,7 @@ import java.time.Instant;
import java.util.Map; import java.util.Map;
@Entity @Entity
@Table(name = "vessel_analysis_results", schema = "kcg") @Table(name = "vessel_analysis_results")
@Getter @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor

파일 보기

@ -9,7 +9,7 @@ import java.time.Instant;
import java.util.Map; import java.util.Map;
@Entity @Entity
@Table(name = "events", schema = "kcg") @Table(name = "events")
@Getter @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor

파일 보기

@ -1,6 +1,7 @@
package gc.mda.kcg.domain.fleet; package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -17,10 +18,14 @@ public class FleetCompanyController {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@Value("${DB_SCHEMA:kcg}")
private String dbSchema;
@GetMapping @GetMapping
public ResponseEntity<List<Map<String, Object>>> getFleetCompanies() { public ResponseEntity<List<Map<String, Object>>> getFleetCompanies() {
List<Map<String, Object>> results = jdbcTemplate.queryForList( List<Map<String, Object>> results = jdbcTemplate.queryForList(
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM kcg.fleet_companies ORDER BY id" "SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM "
+ dbSchema + ".fleet_companies ORDER BY id"
); );
return ResponseEntity.ok(results); return ResponseEntity.ok(results);
} }

파일 보기

@ -0,0 +1,12 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GlobalParentCandidateExclusionRequest {
private String candidateMmsi;
private String actor;
private String comment;
}

파일 보기

@ -0,0 +1,13 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GroupParentCandidateExclusionRequest {
private String candidateMmsi;
private Integer durationDays;
private String actor;
private String comment;
}

파일 보기

@ -0,0 +1,26 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupParentInferenceDto {
private String groupType;
private String groupKey;
private String groupLabel;
private int subClusterId;
private String snapshotTime;
private String zoneName;
private Integer memberCount;
private String resolution;
private Integer candidateCount;
private ParentInferenceSummaryDto parentInference;
private List<ParentInferenceCandidateDto> candidates;
private Map<String, Object> evidenceSummary;
}

파일 보기

@ -0,0 +1,13 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GroupParentInferenceReviewRequest {
private String action;
private String selectedParentMmsi;
private String actor;
private String comment;
}

파일 보기

@ -0,0 +1,13 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GroupParentLabelSessionRequest {
private String selectedParentMmsi;
private Integer durationDays;
private String actor;
private String comment;
}

파일 보기

@ -63,4 +63,61 @@ public class GroupPolygonController {
"items", correlations "items", correlations
)); ));
} }
@GetMapping("/parent-inference/review")
public ResponseEntity<Map<String, Object>> getParentInferenceReview(
@RequestParam(defaultValue = "REVIEW_REQUIRED") String status,
@RequestParam(defaultValue = "100") int limit) {
List<GroupParentInferenceDto> items = groupPolygonService.getParentInferenceReview(status, limit);
return ResponseEntity.ok(Map.of(
"count", items.size(),
"items", items
));
}
@GetMapping("/{groupKey}/parent-inference")
public ResponseEntity<Map<String, Object>> getGroupParentInference(@PathVariable String groupKey) {
List<GroupParentInferenceDto> items = groupPolygonService.getGroupParentInference(groupKey);
return ResponseEntity.ok(Map.of(
"groupKey", groupKey,
"count", items.size(),
"items", items
));
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/review")
public ResponseEntity<?> reviewGroupParentInference(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentInferenceReviewRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.reviewParentInference(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/label-sessions")
public ResponseEntity<?> createGroupParentLabelSession(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentLabelSessionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGroupParentLabelSession(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions")
public ResponseEntity<?> createGroupCandidateExclusion(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentCandidateExclusionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGroupCandidateExclusion(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
} }

파일 보기

@ -26,4 +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;
} }

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,28 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentCandidateExclusionDto {
private Long id;
private String scopeType;
private String groupKey;
private Integer subClusterId;
private String candidateMmsi;
private String reasonType;
private Integer durationDays;
private String activeFrom;
private String activeUntil;
private String releasedAt;
private String releasedBy;
private String actor;
private String comment;
private Boolean active;
private Map<String, Object> metadata;
}

파일 보기

@ -0,0 +1,30 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentInferenceCandidateDto {
private String candidateMmsi;
private String candidateName;
private Integer candidateVesselId;
private Integer rank;
private String candidateSource;
private Double finalScore;
private Double baseCorrScore;
private Double nameMatchScore;
private Double trackSimilarityScore;
private Double visitScore6h;
private Double proximityScore6h;
private Double activitySyncScore6h;
private Double stabilityScore;
private Double registryBonus;
private Double marginFromTop;
private Boolean trackAvailable;
private Map<String, Object> evidence;
}

파일 보기

@ -0,0 +1,22 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentInferenceSummaryDto {
private String status;
private String normalizedParentName;
private String selectedParentMmsi;
private String selectedParentName;
private Double confidence;
private String decisionSource;
private Double topScore;
private Double scoreMargin;
private Integer stableCycles;
private String skipReason;
private String statusReason;
}

파일 보기

@ -0,0 +1,95 @@
package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vessel-analysis/parent-inference")
@RequiredArgsConstructor
public class ParentInferenceWorkflowController {
private final GroupPolygonService groupPolygonService;
@GetMapping("/candidate-exclusions")
public ResponseEntity<Map<String, Object>> getCandidateExclusions(
@RequestParam(required = false) String scopeType,
@RequestParam(required = false) String groupKey,
@RequestParam(required = false) Integer subClusterId,
@RequestParam(required = false) String candidateMmsi,
@RequestParam(defaultValue = "true") boolean activeOnly,
@RequestParam(defaultValue = "100") int limit) {
List<ParentCandidateExclusionDto> items = groupPolygonService.getCandidateExclusions(
scopeType,
groupKey,
subClusterId,
candidateMmsi,
activeOnly,
limit
);
return ResponseEntity.ok(Map.of("count", items.size(), "items", items));
}
@PostMapping("/candidate-exclusions/global")
public ResponseEntity<?> createGlobalCandidateExclusion(@RequestBody GlobalParentCandidateExclusionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGlobalCandidateExclusion(request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/candidate-exclusions/{exclusionId}/release")
public ResponseEntity<?> releaseCandidateExclusion(
@PathVariable long exclusionId,
@RequestBody ParentWorkflowActionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.releaseCandidateExclusion(exclusionId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/label-sessions")
public ResponseEntity<Map<String, Object>> getLabelSessions(
@RequestParam(required = false) String groupKey,
@RequestParam(required = false) Integer subClusterId,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "true") boolean activeOnly,
@RequestParam(defaultValue = "100") int limit) {
List<ParentLabelSessionDto> items = groupPolygonService.getLabelSessions(
groupKey,
subClusterId,
status,
activeOnly,
limit
);
return ResponseEntity.ok(Map.of("count", items.size(), "items", items));
}
@PostMapping("/label-sessions/{labelSessionId}/cancel")
public ResponseEntity<?> cancelLabelSession(
@PathVariable long labelSessionId,
@RequestBody ParentWorkflowActionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.cancelLabelSession(labelSessionId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/label-sessions/{labelSessionId}/tracking")
public ResponseEntity<Map<String, Object>> getLabelSessionTracking(
@PathVariable long labelSessionId,
@RequestParam(defaultValue = "200") int limit) {
List<ParentLabelTrackingCycleDto> items = groupPolygonService.getLabelSessionTracking(labelSessionId, limit);
return ResponseEntity.ok(Map.of(
"labelSessionId", labelSessionId,
"count", items.size(),
"items", items
));
}
}

파일 보기

@ -0,0 +1,31 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentLabelSessionDto {
private Long id;
private String groupKey;
private Integer subClusterId;
private String labelParentMmsi;
private String labelParentName;
private Integer labelParentVesselId;
private Integer durationDays;
private String status;
private String activeFrom;
private String activeUntil;
private String actor;
private String comment;
private String anchorSnapshotTime;
private Double anchorCenterLat;
private Double anchorCenterLon;
private Integer anchorMemberCount;
private Boolean active;
private Map<String, Object> metadata;
}

파일 보기

@ -0,0 +1,31 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentLabelTrackingCycleDto {
private Long id;
private Long labelSessionId;
private String observedAt;
private String candidateSnapshotObservedAt;
private String autoStatus;
private String topCandidateMmsi;
private String topCandidateName;
private Double topCandidateScore;
private Double topCandidateMargin;
private Integer candidateCount;
private Boolean labeledCandidatePresent;
private Integer labeledCandidateRank;
private Double labeledCandidateScore;
private Double labeledCandidatePreBonusScore;
private Double labeledCandidateMarginFromTop;
private Boolean matchedTop1;
private Boolean matchedTop3;
private Map<String, Object> evidenceSummary;
}

파일 보기

@ -0,0 +1,11 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ParentWorkflowActionRequest {
private String actor;
private String comment;
}

파일 보기

@ -9,7 +9,6 @@ import java.time.Instant;
@Entity @Entity
@Table( @Table(
name = "osint_feeds", name = "osint_feeds",
schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"}) uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"})
) )
@Getter @Getter

파일 보기

@ -6,7 +6,7 @@ import lombok.*;
import java.time.Instant; import java.time.Instant;
@Entity @Entity
@Table(name = "satellite_tle", schema = "kcg") @Table(name = "satellite_tle")
@Getter @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor

파일 보기

@ -8,7 +8,6 @@ import java.time.Instant;
@Entity @Entity
@Table( @Table(
name = "pressure_readings", name = "pressure_readings",
schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"}) uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"})
) )
@Getter @Getter

파일 보기

@ -6,7 +6,7 @@ import lombok.*;
import java.time.Instant; import java.time.Instant;
@Entity @Entity
@Table(name = "seismic_events", schema = "kcg") @Table(name = "seismic_events")
@Getter @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor

파일 보기

@ -1,16 +1,19 @@
spring: spring:
datasource: datasource:
url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg url: ${DB_URL:jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg,public}
username: kcg_user username: ${DB_USERNAME:kcg_user}
password: kcg_pass password: ${DB_PASSWORD:kcg_pass}
app: app:
jwt: jwt:
secret: local-dev-secret-key-32chars-minimum!! secret: ${JWT_SECRET:local-dev-secret-key-32chars-minimum!!}
expiration-ms: 86400000 expiration-ms: ${JWT_EXPIRATION_MS:86400000}
google: google:
client-id: YOUR_GOOGLE_CLIENT_ID client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID}
auth: auth:
allowed-domain: gcsc.co.kr allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
collector: collector:
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID open-sky-client-id: ${OPENSKY_CLIENT_ID:YOUR_OPENSKY_CLIENT_ID}
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:YOUR_OPENSKY_CLIENT_SECRET}
prediction-base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173}

파일 보기

@ -16,4 +16,4 @@ app:
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:} open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:}
prediction-base-url: ${PREDICTION_BASE_URL:http://192.168.1.18:8001} prediction-base-url: ${PREDICTION_BASE_URL:http://192.168.1.18:8001}
cors: cors:
allowed-origins: http://localhost:5173,https://kcg.gc-si.dev allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173,https://kcg.gc-si.dev}

파일 보기

@ -6,6 +6,6 @@ spring:
ddl-auto: none ddl-auto: none
properties: properties:
hibernate: hibernate:
default_schema: kcg default_schema: ${DB_SCHEMA:kcg}
server: server:
port: 8080 port: ${SERVER_PORT:8080}

파일 보기

@ -0,0 +1,176 @@
-- 012: 어구 그룹 모선 추론 저장소 + sub_cluster/resolution 스키마 정합성
SET search_path TO kcg, public;
-- ── live lab과 repo 마이그레이션 정합성 맞추기 ─────────────────────
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS resolution VARCHAR(20) NOT NULL DEFAULT '6h';
CREATE INDEX IF NOT EXISTS idx_gps_type_res_time
ON kcg.group_polygon_snapshots(group_type, resolution, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_key_res_time
ON kcg.group_polygon_snapshots(group_key, resolution, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_key_sub_time
ON kcg.group_polygon_snapshots(group_key, sub_cluster_id, snapshot_time DESC);
ALTER TABLE kcg.gear_correlation_raw_metrics
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_raw_metrics_group_sub_time
ON kcg.gear_correlation_raw_metrics(group_key, sub_cluster_id, observed_at DESC);
ALTER TABLE kcg.gear_correlation_scores
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_correlation_scores
DROP CONSTRAINT IF EXISTS gear_correlation_scores_model_id_group_key_target_mmsi_key;
DROP INDEX IF EXISTS kcg.gear_correlation_scores_model_id_group_key_target_mmsi_key;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE connamespace = 'kcg'::regnamespace
AND conrelid = 'kcg.gear_correlation_scores'::regclass
AND conname = 'gear_correlation_scores_unique'
) THEN
ALTER TABLE kcg.gear_correlation_scores
ADD CONSTRAINT gear_correlation_scores_unique
UNIQUE (model_id, group_key, sub_cluster_id, target_mmsi);
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE INDEX IF NOT EXISTS idx_gc_model_group_sub
ON kcg.gear_correlation_scores(model_id, group_key, sub_cluster_id, current_score DESC);
-- ── 그룹 단위 모선 추론 저장소 ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_candidate_snapshots (
id BIGSERIAL PRIMARY KEY,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
parent_name TEXT NOT NULL,
candidate_mmsi VARCHAR(20) NOT NULL,
candidate_name VARCHAR(200),
candidate_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
rank INT NOT NULL,
candidate_source VARCHAR(100) NOT NULL,
model_id INT REFERENCES kcg.correlation_param_models(id) ON DELETE SET NULL,
model_name VARCHAR(100),
base_corr_score DOUBLE PRECISION DEFAULT 0,
name_match_score DOUBLE PRECISION DEFAULT 0,
track_similarity_score DOUBLE PRECISION DEFAULT 0,
visit_score_6h DOUBLE PRECISION DEFAULT 0,
proximity_score_6h DOUBLE PRECISION DEFAULT 0,
activity_sync_score_6h DOUBLE PRECISION DEFAULT 0,
stability_score DOUBLE PRECISION DEFAULT 0,
registry_bonus DOUBLE PRECISION DEFAULT 0,
final_score DOUBLE PRECISION DEFAULT 0,
margin_from_top DOUBLE PRECISION DEFAULT 0,
evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (observed_at, group_key, sub_cluster_id, candidate_mmsi)
);
CREATE INDEX IF NOT EXISTS idx_ggpcs_group_time
ON kcg.gear_group_parent_candidate_snapshots(group_key, sub_cluster_id, observed_at DESC, rank ASC);
CREATE INDEX IF NOT EXISTS idx_ggpcs_candidate
ON kcg.gear_group_parent_candidate_snapshots(candidate_mmsi, observed_at DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_resolution (
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
parent_name TEXT NOT NULL,
normalized_parent_name VARCHAR(200),
status VARCHAR(40) NOT NULL,
selected_parent_mmsi VARCHAR(20),
selected_parent_name VARCHAR(200),
selected_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
confidence DOUBLE PRECISION,
decision_source VARCHAR(40),
top_score DOUBLE PRECISION DEFAULT 0,
second_score DOUBLE PRECISION DEFAULT 0,
score_margin DOUBLE PRECISION DEFAULT 0,
stable_cycles INT DEFAULT 0,
last_evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_promoted_at TIMESTAMPTZ,
approved_by VARCHAR(100),
approved_at TIMESTAMPTZ,
manual_comment TEXT,
rejected_candidate_mmsi VARCHAR(20),
rejected_at TIMESTAMPTZ,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_key, sub_cluster_id)
);
CREATE INDEX IF NOT EXISTS idx_ggpr_status
ON kcg.gear_group_parent_resolution(status, last_evaluated_at DESC);
CREATE INDEX IF NOT EXISTS idx_ggpr_parent
ON kcg.gear_group_parent_resolution(selected_parent_mmsi);
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_review_log (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
action VARCHAR(20) NOT NULL,
selected_parent_mmsi VARCHAR(20),
actor VARCHAR(100) NOT NULL,
comment TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ggprl_group_time
ON kcg.gear_group_parent_review_log(group_key, sub_cluster_id, created_at DESC);
-- ── copied schema 환경의 sequence 정렬 ─────────────────────────────
SELECT setval(
pg_get_serial_sequence('kcg.fleet_companies', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_companies), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.fleet_vessels', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_vessels), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.gear_identity_log', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.gear_identity_log), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.fleet_tracking_snapshot', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_tracking_snapshot), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.group_polygon_snapshots', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.group_polygon_snapshots), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.gear_correlation_scores', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.gear_correlation_scores), 1),
TRUE
);

파일 보기

@ -0,0 +1,23 @@
SET search_path TO kcg, public;
DELETE FROM kcg.gear_group_parent_candidate_snapshots
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_group_parent_review_log
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_group_parent_resolution
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_correlation_raw_metrics
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_correlation_scores
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.group_polygon_snapshots
WHERE group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')
AND LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_identity_log
WHERE LENGTH(REGEXP_REPLACE(UPPER(COALESCE(parent_name, name)), '[ _%\\-]', '', 'g')) < 4;

파일 보기

@ -0,0 +1,125 @@
-- 014: 어구 모선 검토 워크플로우 v2 phase 1
SET search_path TO kcg, public;
-- ── 그룹/전역 후보 제외 ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL,
group_key VARCHAR(100),
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL,
duration_days INT,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ,
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
CONSTRAINT chk_gpce_group_scope CHECK (
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
OR
(scope_type = 'GLOBAL' AND duration_days IS NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
ON kcg.gear_parent_candidate_exclusions(active_until);
-- ── 기간형 정답 라벨 세션 ────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
);
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
ON kcg.gear_parent_label_sessions(active_until);
-- ── 라벨 세션 기간 중 cycle별 자동 추론 기록 ─────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
-- ── active view ────────────────────────────────────────────────
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
SELECT *
FROM kcg.gear_parent_candidate_exclusions
WHERE released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW());
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
SELECT *
FROM kcg.gear_parent_label_sessions
WHERE status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW();

파일 보기

@ -0,0 +1,111 @@
-- 015: 어구 모선 추론 episode continuity + prior bonus
SET search_path TO kcg, public;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS normalized_parent_name VARCHAR(200);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS episode_id VARCHAR(64);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS episode_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS lineage_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS label_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
UPDATE kcg.gear_group_parent_candidate_snapshots
SET normalized_parent_name = regexp_replace(upper(COALESCE(parent_name, '')), '[[:space:]_%-]+', '', 'g')
WHERE normalized_parent_name IS NULL;
CREATE INDEX IF NOT EXISTS idx_ggpcs_episode_time
ON kcg.gear_group_parent_candidate_snapshots(episode_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_ggpcs_lineage_time
ON kcg.gear_group_parent_candidate_snapshots(normalized_parent_name, observed_at DESC);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS episode_id VARCHAR(64);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS continuity_source VARCHAR(32);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS continuity_score DOUBLE PRECISION;
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS prior_bonus_total DOUBLE PRECISION NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_ggpr_episode
ON kcg.gear_group_parent_resolution(episode_id);
ALTER TABLE kcg.gear_parent_label_sessions
ADD COLUMN IF NOT EXISTS normalized_parent_name VARCHAR(200);
UPDATE kcg.gear_parent_label_sessions
SET normalized_parent_name = regexp_replace(upper(COALESCE(group_key, '')), '[[:space:]_%-]+', '', 'g')
WHERE normalized_parent_name IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpls_lineage_active
ON kcg.gear_parent_label_sessions(normalized_parent_name, active_from DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_episodes (
episode_id VARCHAR(64) PRIMARY KEY,
lineage_key VARCHAR(200) NOT NULL,
group_key VARCHAR(100) NOT NULL,
normalized_parent_name VARCHAR(200) NOT NULL,
current_sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
continuity_source VARCHAR(32) NOT NULL DEFAULT 'NEW',
continuity_score DOUBLE PRECISION,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_snapshot_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
current_member_count INT NOT NULL DEFAULT 0,
current_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
current_center_point geometry(Point, 4326),
split_from_episode_id VARCHAR(64),
merged_from_episode_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
merged_into_episode_id VARCHAR(64),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gge_status CHECK (status IN ('ACTIVE', 'MERGED', 'EXPIRED')),
CONSTRAINT chk_gge_continuity CHECK (continuity_source IN ('NEW', 'CONTINUED', 'SPLIT_CONTINUE', 'SPLIT_NEW', 'MERGE_NEW', 'DIRECT_PARENT_MATCH'))
);
CREATE INDEX IF NOT EXISTS idx_gge_lineage_status_time
ON kcg.gear_group_episodes(lineage_key, status, last_snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gge_group_time
ON kcg.gear_group_episodes(group_key, current_sub_cluster_id, last_snapshot_time DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_episode_snapshots (
id BIGSERIAL PRIMARY KEY,
episode_id VARCHAR(64) NOT NULL REFERENCES kcg.gear_group_episodes(episode_id) ON DELETE CASCADE,
lineage_key VARCHAR(200) NOT NULL,
group_key VARCHAR(100) NOT NULL,
normalized_parent_name VARCHAR(200) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
member_count INT NOT NULL DEFAULT 0,
member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
center_point geometry(Point, 4326),
continuity_source VARCHAR(32) NOT NULL,
continuity_score DOUBLE PRECISION,
parent_episode_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
top_candidate_mmsi VARCHAR(20),
top_candidate_score DOUBLE PRECISION,
resolution_status VARCHAR(40),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gges_episode_observed UNIQUE (episode_id, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_gges_lineage_observed
ON kcg.gear_group_episode_snapshots(lineage_key, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gges_group_observed
ON kcg.gear_group_episode_snapshots(group_key, sub_cluster_id, observed_at DESC);

파일 보기

@ -0,0 +1,514 @@
# Gear Parent Inference Algorithm Spec
## 문서 목적
이 문서는 현재 구현된 어구 모선 추적 알고리즘을 모듈, 메서드, 파라미터, 판단 기준, 저장소, 엔드포인트, 영향 관계 기준으로 정리한 구현 명세다. `GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md`가 서술형 통합 문서라면, 이 문서는 구현과 후속 변경 작업에 바로 연결할 수 있는 참조 스펙이다.
## 1. 시스템 요약
### 1.1 현재 목적
- 최근 24시간 한국 수역 AIS를 캐시에 유지한다.
- 어구 이름 패턴과 위치를 기준으로 어구 그룹을 만든다.
- 주변 선박/오분류 어구를 correlation 후보로 평가한다.
- 후보 중 대표 모선 가능성이 높은 선박을 추론한다.
- 사람의 라벨/제외를 별도 저장소에 남겨 향후 모델 평가와 자동화 전환에 활용한다.
### 1.2 현재 점수 구조의 역할 구분
- `gear_correlation_scores.current_score`
- 후보 스크리닝용 correlation score
- EMA 기반 단기 메모리
- `gear_group_parent_candidate_snapshots.final_score`
- 모선 추론용 최종 후보 점수
- coverage-aware 보정과 이름/안정성/episode/lineage/label prior 반영
- `gear_group_parent_resolution`
- 그룹별 현재 추론 상태
- `gear_group_episodes`, `gear_group_episode_snapshots`
- `sub_cluster_id`와 분리된 continuity memory
- `gear_parent_label_tracking_cycles`
- 라벨 세션 동안의 자동 추론 성능 추적
## 2. 현재 DB 저장소와 유지 기간
| 저장소 | 역할 | 현재 유지 규칙 |
| --- | --- | --- |
| `group_polygon_snapshots` | `1h/1h-fb/6h` 그룹 스냅샷 | `7일` cleanup |
| `gear_correlation_raw_metrics` | correlation raw metric 시계열 | `7일` retention partition |
| `gear_correlation_scores` | correlation EMA score 현재 상태 | `30일` 미관측 시 cleanup |
| `gear_group_parent_candidate_snapshots` | cycle별 parent candidate snapshot | 현재 자동 cleanup 없음 |
| `gear_group_parent_resolution` | 그룹별 현재 추론 상태 1행 | 현재 자동 cleanup 없음 |
| `gear_group_episodes` | active/merged/expired episode 현재 상태 | 현재 자동 cleanup 없음 |
| `gear_group_episode_snapshots` | cycle별 episode continuity 스냅샷 | 현재 자동 cleanup 없음 |
| `gear_parent_candidate_exclusions` | 그룹/전역 후보 제외 | 기간 종료 또는 수동 해제까지 |
| `gear_parent_label_sessions` | 정답 라벨 세션 | 만료 시 `EXPIRED`, row는 유지 |
| `gear_parent_label_tracking_cycles` | 라벨 세션 cycle별 추적 | 현재 자동 cleanup 없음 |
## 3. 모듈 인덱스
### 3.1 시간/원천 적재
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/time_bucket.py` | `compute_safe_bucket()` | DB 적재 완료 전 bucket 차단 |
| `prediction/time_bucket.py` | `compute_initial_window_start()` | 초기 24h window 시작점 |
| `prediction/time_bucket.py` | `compute_incremental_window_start()` | overlap backfill 시작점 |
| `prediction/db/snpdb.py` | `fetch_all_tracks()` | safe bucket까지 초기 bulk 적재 |
| `prediction/db/snpdb.py` | `fetch_incremental()` | backfill 포함 증분 적재 |
| `prediction/cache/vessel_store.py` | `load_initial()` | 초기 메모리 캐시 구성 |
| `prediction/cache/vessel_store.py` | `merge_incremental()` | 증분 merge + dedupe |
| `prediction/cache/vessel_store.py` | `evict_stale()` | 24h sliding window 유지 |
### 3.2 어구 identity / 그룹
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/fleet_tracker.py` | `track_gear_identity()` | 어구 이름 파싱, identity log 관리 |
| `prediction/algorithms/gear_name_rules.py` | `normalize_parent_name()` | 모선명 정규화 |
| `prediction/algorithms/gear_name_rules.py` | `is_trackable_parent_name()` | 짧은 이름 제외 |
| `prediction/algorithms/polygon_builder.py` | `detect_gear_groups()` | 어구 그룹 및 서브클러스터 생성 |
| `prediction/algorithms/polygon_builder.py` | `build_all_group_snapshots()` | `1h/1h-fb/6h` 스냅샷 저장용 생성 |
### 3.3 correlation / parent inference
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/algorithms/gear_correlation.py` | `run_gear_correlation()` | raw metric + EMA score 계산 |
| `prediction/algorithms/gear_correlation.py` | `_compute_gear_vessel_metrics()` | proximity/visit/activity 계산 |
| `prediction/algorithms/gear_correlation.py` | `update_score()` | EMA + freeze/decay 상태 전이 |
| `prediction/algorithms/gear_parent_episode.py` | `build_episode_plan()` | continuity source와 episode assignment 계산 |
| `prediction/algorithms/gear_parent_episode.py` | `compute_prior_bonus_components()` | episode/lineage/label prior bonus 계산 |
| `prediction/algorithms/gear_parent_episode.py` | `sync_episode_states()` | `gear_group_episodes` upsert |
| `prediction/algorithms/gear_parent_episode.py` | `insert_episode_snapshots()` | episode snapshot append |
| `prediction/algorithms/gear_parent_inference.py` | `run_gear_parent_inference()` | 최종 모선 추론 실행 |
| `prediction/algorithms/gear_parent_inference.py` | `_build_candidate_scores()` | 후보별 상세 점수 계산 |
| `prediction/algorithms/gear_parent_inference.py` | `_name_match_score()` | 이름 점수 규칙 |
| `prediction/algorithms/gear_parent_inference.py` | `_build_track_coverage_metrics()` | coverage-aware evidence 계산 |
| `prediction/algorithms/gear_parent_inference.py` | `_select_status()` | 상태 전이 규칙 |
### 3.4 backend read model / workflow
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `GroupPolygonService.java` | group list/review/detail SQL | 최신 `1h` live + stale suppression read model |
| `ParentInferenceWorkflowController.java` | exclusion/label API | 사람 판단 저장소 API |
## 4. 메서드 상세
## 4.1 `prediction/time_bucket.py`
### `compute_safe_bucket(now: datetime | None = None) -> datetime`
- 입력:
- 현재 시각
- 출력:
- `safe_delay`를 뺀 뒤 5분 단위로 내림한 KST naive bucket
- 기준:
- `SNPDB_SAFE_DELAY_MIN`
- 영향:
- 초기 적재, 증분 적재, eviction 기준점
### `compute_incremental_window_start(last_bucket: datetime) -> datetime`
- 입력:
- 현재 캐시의 마지막 처리 bucket
- 출력:
- `last_bucket - SNPDB_BACKFILL_BUCKETS * 5m`
- 의미:
- 늦게 들어온 같은 bucket row 재흡수
## 4.2 `prediction/db/snpdb.py`
### `fetch_all_tracks(hours: int = 24) -> pd.DataFrame`
- 역할:
- safe bucket까지 최근 `N`시간 full load
- 핵심 쿼리 조건:
- bbox: `122,31,132,39`
- `time_bucket > window_start`
- `time_bucket <= safe_bucket`
- 출력 컬럼:
- `mmsi`, `timestamp`, `time_bucket`, `lat`, `lon`, `raw_sog`
### `fetch_incremental(last_bucket: datetime) -> pd.DataFrame`
- 역할:
- overlap backfill 포함 증분 load
- 핵심 쿼리 조건:
- `time_bucket > from_bucket`
- `time_bucket <= safe_bucket`
- 주의:
- 이미 본 bucket도 일부 다시 읽는 구조다
## 4.3 `prediction/cache/vessel_store.py`
### `load_initial(hours: int = 24) -> None`
- 역할:
- 초기 bulk DataFrame을 MMSI별 track cache로 구성
- 파생 효과:
- `_last_bucket` 갱신
- static info refresh
- permit registry refresh
### `merge_incremental(df_new: pd.DataFrame) -> None`
- 역할:
- 증분 batch merge
- 기준:
- `timestamp`, `time_bucket` 정렬
- `timestamp` 기준 dedupe
- 영향:
- 같은 bucket overlap backfill에서도 최종 row만 유지
### `evict_stale(hours: int = 24) -> None`
- 역할:
- sliding 24h 유지
- 기준:
- `time_bucket` 있으면 bucket 기준
- 없으면 timestamp fallback
## 4.4 `prediction/fleet_tracker.py`
### `track_gear_identity(gear_signals, conn) -> None`
- 역할:
- 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2` 추출
- `gear_identity_log` insert/update
- 입력:
- gear signal list
- 주요 기준:
- 정규화 길이 `< 4`면 건너뜀
- 같은 이름, 다른 MMSI는 identity migration 처리
- 영향:
- `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전 가능
## 4.5 `prediction/algorithms/polygon_builder.py`
### `detect_gear_groups(vessel_store) -> list[dict]`
- 역할:
- 어구 이름 기반 raw group 생성
- 거리 기반 서브클러스터 분리
- 근접 병합
- 입력:
- `all_positions`
- 주요 기준:
- 어구 패턴 매칭
- `440/441` 제외
- `is_trackable_parent_name()`
- `MAX_DIST_DEG = 0.15`
- 출력:
- `parent_name`, `parent_mmsi`, `sub_cluster_id`, `members`
### `build_all_group_snapshots(vessel_store, company_vessels, companies) -> list[dict]`
- 역할:
- `FLEET`, `GEAR_IN_ZONE`, `GEAR_OUT_ZONE``1h/1h-fb/6h` snapshot 생성
- 주요 기준:
- 같은 `parent_name` 전체 기준 1h active member 수
- `GEAR_OUT_ZONE` 최소 멤버 수
- parent nearby 시 `isParent=true`
## 4.6 `prediction/algorithms/gear_correlation.py`
### `run_gear_correlation(vessel_store, gear_groups, conn) -> dict`
- 역할:
- 그룹당 후보 탐색
- raw metric 저장
- EMA score 갱신
- 입력:
- `gear_groups`
- 출력:
- `updated`, `models`, `raw_inserted`
### `_compute_gear_vessel_metrics(gear_center_lat, gear_center_lon, gear_radius_nm, vessel_track, params) -> dict`
- 출력 metric:
- `proximity_ratio`
- `visit_score`
- `activity_sync`
- `composite`
- 한계:
- raw metric은 짧은 항적에 과대 우호적일 수 있음
- 이 문제는 parent inference 단계의 coverage-aware 보정에서 완화
### `update_score(prev_score, raw_score, streak, last_observed, now, gear_group_active_ratio, shadow_bonus, params) -> tuple`
- 상태:
- `ACTIVE`
- `PATTERN_DIVERGE`
- `GROUP_QUIET`
- `NORMAL_GAP`
- `SIGNAL_LOSS`
- 의미:
- correlation score는 장기 기억보다 short-memory EMA에 가깝다
## 4.7 `prediction/algorithms/gear_parent_inference.py`
### `run_gear_parent_inference(vessel_store, gear_groups, conn) -> dict[str, int]`
- 역할:
- direct parent 보강
- active exclusion/label 적용
- 후보 점수 계산
- 상태 전이
- snapshot/resolution/tracking 저장
### `_load_existing_resolution(conn, group_keys) -> dict`
- 역할:
- 현재 그룹의 이전 resolution 상태 로드
- 현재 쓰임:
- `PREVIOUS_SELECTION` 후보 seed
- `stable_cycles`
- `MANUAL_CONFIRMED` 유지
- reject cooldown
### `_build_candidate_scores(...) -> list[CandidateScore]`
- 후보 집합 원천:
- 상위 correlation 후보
- registry name exact bucket
- previous selection
- 제거 규칙:
- global exclusion
- group exclusion
- reject cooldown
- 점수 항목:
- `base_corr_score`
- `name_match_score`
- `track_similarity_score`
- `visit_score_6h`
- `proximity_score_6h`
- `activity_sync_score_6h`
- `stability_score`
- `registry_bonus`
- `china_mmsi_bonus` 후가산
### `_name_match_score(parent_name, candidate_name, registry) -> float`
- 규칙:
- 원문 동일 `1.0`
- 정규화 동일 `0.8`
- prefix/contains `0.5`
- 숫자 제거 후 문자 부분 동일 `0.3`
- else `0.0`
### `_build_track_coverage_metrics(center_track, vessel_track, gear_center_lat, gear_center_lon) -> dict`
- 역할:
- short-track 과대평가 방지용 증거 강도 계산
- 핵심 출력:
- `trackCoverageFactor`
- `visitCoverageFactor`
- `activityCoverageFactor`
- `coverageFactor`
- downstream:
- `track`, `visit`, `proximity`, `activity` raw score에 곱해 effective score 생성
## 4.8 `prediction/algorithms/gear_parent_episode.py`
### `build_episode_plan(groups, previous_by_lineage) -> EpisodePlan`
- 역할:
- 현재 cycle group을 이전 active episode와 매칭
- `NEW`, `CONTINUED`, `SPLIT_CONTINUE`, `SPLIT_NEW`, `MERGE_NEW` 결정
- 입력:
- `GroupEpisodeInput[]`
- 최근 `6h` active `EpisodeState[]`
- continuity score:
- `0.75 * member_jaccard + 0.25 * center_support`
- 기준:
- `member_jaccard`
- 중심점 거리 `12nm`
- continuity score threshold `0.45`
- merge score threshold `0.35`
- 출력:
- assignment map
- expired episode set
- merged target map
### `compute_prior_bonus_components(...) -> dict[str, float]`
- 역할:
- 동일 candidate에 대한 episode/lineage/label prior bonus 계산
- 입력 집계 범위:
- episode prior: `24h`
- lineage prior: `7d`
- label prior: `30d`
- cap:
- `episode <= 0.10`
- `lineage <= 0.05`
- `label <= 0.10`
- `total <= 0.20`
- 출력:
- `episodePriorBonus`
- `lineagePriorBonus`
- `labelPriorBonus`
- `priorBonusTotal`
### `sync_episode_states(conn, observed_at, plan) -> None`
- 역할:
- active/merged/expired episode 상태를 `gear_group_episodes`에 반영
- 기준:
- merge 대상은 `MERGED`
- continuity 없는 old episode는 `EXPIRED`
### `insert_episode_snapshots(conn, observed_at, plan, payloads) -> int`
- 역할:
- cycle별 continuity 결과와 top candidate/result를 `gear_group_episode_snapshots`에 저장
- 기록:
- `episode_id`
- `parent_episode_ids`
- `top_candidate_mmsi`
- `top_candidate_score`
- `resolution_status`
### `_select_status(top_candidate, margin, stable_cycles) -> tuple[str, str]`
- 상태:
- `NO_CANDIDATE`
- `AUTO_PROMOTED`
- `REVIEW_REQUIRED`
- `UNRESOLVED`
- auto promotion 조건:
- `target_type == VESSEL`
- `CORRELATION` source 포함
- `final_score >= 0.72`
- `margin >= 0.15`
- `stable_cycles >= 3`
- review 조건:
- `final_score >= 0.60`
## 5. 현재 엔드포인트 스펙
## 5.1 조회 계열
### `/api/kcg/vessel-analysis/groups/parent-inference/review`
- 역할:
- 최신 전역 `1h` 기준 검토 대기 목록
- 조건:
- stale resolution 숨김
- candidate count는 latest candidate snapshot 기준
### `/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
- 역할:
- 특정 그룹의 현재 live sub-cluster 상세
- 주의:
- “현재 최신 전역 `1h`에 실제 존재하는 sub-cluster만” 반환
### `/api/kcg/vessel-analysis/parent-inference/candidate-exclusions`
- 역할:
- 그룹/전역 제외 목록 조회
### `/api/kcg/vessel-analysis/parent-inference/label-sessions`
- 역할:
- active 또는 전체 라벨 세션 조회
## 5.2 액션 계열
### `POST /candidate-exclusions/global`
- 역할:
- 전역 후보 제외 생성
- 영향:
- 다음 cycle부터 모든 그룹에서 해당 MMSI 제거
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/exclude`
- 역할:
- 그룹 단위 후보 제외 생성
- 영향:
- 다음 cycle부터 해당 그룹에서만 제거
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/label`
- 역할:
- 기간형 정답 라벨 세션 생성
- 영향:
- 다음 cycle부터 tracking row 누적
## 6. 현재 기억 구조와 prior bonus
### 6.1 short-memory와 long-memory의 분리
- `gear_correlation_scores`
- EMA short-memory
- 미관측 시 decay
- 현재 후보 seed 역할
- `gear_group_parent_resolution`
- 현재 상태 1행
- same-episode가 아니면 `PREVIOUS_SELECTION` carry를 직접 사용하지 않음
- `gear_group_episodes`
- continuity memory
- `candidate_snapshots`
- bonus 집계 원천
### 6.2 현재 final score의 장기 기억 반영
현재는 과거 점수를 직접 carry하지 않고, 약한 prior bonus만 후가산한다.
```text
final_score =
current_signal_score
+ china_mmsi_bonus
+ prior_bonus_total
```
여기서 `prior_bonus_total`은:
- `episode_prior_bonus`
- `lineage_prior_bonus`
- `label_prior_bonus`
의 합이며 총합 cap은 `0.20`이다.
### 6.3 왜 weak prior인가
과거 점수를 그대로 넘기면:
- 다른 episode로 잘못 관성이 전이될 수 있다
- split/merge 이후 잘못된 top1이 고착될 수 있다
- 오래된 오답이 장기 drift로 남을 수 있다
그래서 현재 구현은 과거 점수를 “현재 score 자체”가 아니라 “작은 bonus”로만 쓴다.
## 7. 현재 continuity / prior 동작
### 7.1 episode continuity
- 같은 lineage 안에서 최근 `6h` active episode를 불러온다.
- continuity score가 높은 이전 episode가 있으면 `CONTINUED`
- 1개 parent episode가 여러 current cluster로 이어지면 `SPLIT_CONTINUE` + `SPLIT_NEW`
- 여러 previous episode가 하나 current cluster로 모이면 `MERGE_NEW`
- 어떤 current와도 연결되지 못한 old episode는 `EXPIRED`
### 7.2 prior 집계
| prior | 참조 범위 | 현재 집계 값 |
| --- | --- | --- |
| episode prior | 최근 동일 episode `24h` | seen_count, top1_count, avg_score, last_seen_at |
| lineage prior | 동일 이름 lineage `7d` | seen_count, top1_count, top3_count, avg_score, last_seen_at |
| label prior | 라벨 세션 `30d` | session_count, last_labeled_at |
### 7.3 구현 시 주의
- 과거 점수를 직접 누적하지 말 것
- prior는 bonus로만 사용하고 cap을 둘 것
- split/merge 이후 parent 후보 관성은 약하게만 상속할 것
- stale live sub-cluster와 vanished old sub-cluster를 혼동하지 않도록, aggregation도 최신 episode anchor를 기준으로 할 것
## 8. 참조 문서
- [GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md)
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md)
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md)

파일 보기

@ -0,0 +1,677 @@
# Gear Parent Inference Dataflow Paper
## 초록
이 문서는 `iran-airstrike-replay-codex`의 한국 수역 어구 모선 추적 체계를 코드 기준으로 복원하는 통합 기술 문서다. 범위는 `snpdb` 5분 궤적 적재, 인메모리 캐시 유지, 어구 그룹 검출, 서브클러스터 생성, `1h/1h-fb/6h` 폴리곤 스냅샷 저장, correlation 기반 후보 점수화, coverage-aware parent inference, `episode_id` 기반 연속성 계층, backend read model, review/exclusion/label v2까지 포함한다. 문서의 목적은 “현재 무엇이 구현되어 있고, 각 경우의 수에서 어떤 분기 규칙이 적용되는가”를 한 문서에서 복원 가능하게 만드는 것이다.
## 1. 범위와 전제
### 1.1 구현 기준
- frontend: `frontend/`
- backend: `backend/`
- prediction: `prediction/`
- schema migration: `database/migration/012_gear_parent_inference.sql`, `database/migration/014_gear_parent_workflow_v2_phase1.sql`, `database/migration/015_gear_parent_episode_tracking.sql`
### 1.2 실행 환경
- lab backend: `rocky-211:18083`
- lab prediction: `redis-211:18091`
- lab schema: `kcg_lab`
- 로컬 프론트 진입점: `yarn dev:lab`, `yarn dev:lab:ssh`
### 1.3 문서의 구분
- 구현됨:
- 현재 repo 코드와 lab 배포에 이미 반영된 규칙
- 후속 확장 후보:
- episode continuity 위에서 추가로 올릴 `focus mode`, richer episode lineage API, calibration report
## 2. 문제 정의
이 시스템은 한국 수역에서 AIS 신호를 이용해 아래 문제를 단계적으로 푼다.
1. 최근 24시간의 선박/어구 궤적을 메모리 캐시에 유지한다.
2. 동일한 어구 이름 계열을 공간적으로 묶어 어구 그룹을 만든다.
3. 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 생성한다.
4. 주변 선박 또는 잘못 분류된 어구 AIS를 후보로 수집하고 correlation 점수를 만든다.
5. 후보를 모선 추론 점수로 다시 환산한다.
6. 사람이 라벨/제외를 누적해 모델 정확도 고도화용 데이터셋을 만든다.
핵심 난점은 아래 세 가지다.
- DB 적재 지연 때문에 live incremental cache와 fresh reload가 다를 수 있다.
- 같은 `parent_name` 아래에서도 실제로는 여러 공간 덩어리로 갈라질 수 있다.
- 짧은 항적이 `track/proximity/activity`에서 과대평가될 수 있다.
## 3. 전체 아키텍처 흐름
```mermaid
flowchart LR
A["signal.t_vessel_tracks_5min<br/>5분 bucket linestringM"] --> B["prediction/db/snpdb.py<br/>safe bucket + overlap backfill"]
B --> C["prediction/cache/vessel_store.py<br/>24h in-memory cache"]
C --> D["prediction/fleet_tracker.py<br/>gear_identity_log / snapshot"]
C --> E["prediction/algorithms/polygon_builder.py<br/>gear group detect + sub-cluster + snapshots"]
E --> F["kcg_lab.group_polygon_snapshots"]
C --> G["prediction/algorithms/gear_correlation.py<br/>raw metrics + EMA score"]
G --> H["kcg_lab.gear_correlation_raw_metrics"]
G --> I["kcg_lab.gear_correlation_scores"]
F --> J["prediction/algorithms/gear_parent_inference.py<br/>candidate build + scoring + status"]
H --> J
I --> J
K["v2 exclusions / labels"] --> J
J --> L["kcg_lab.gear_group_parent_candidate_snapshots"]
J --> M["kcg_lab.gear_group_parent_resolution"]
J --> N["kcg_lab.gear_parent_label_tracking_cycles"]
F --> O["backend GroupPolygonService"]
L --> O
M --> O
N --> O
O --> P["frontend ParentReviewPanel"]
```
## 4. 원천 데이터와 시간 모델
### 4.1 원천 데이터 형식
원천은 `signal.t_vessel_tracks_5min`이며, `1 row = 1 MMSI = 5분 구간의 궤적 전체``LineStringM`으로 보관한다. 실제 위치 포인트는 `ST_DumpPoints(track_geom)`로 분해하고, 각 점의 timestamp는 `ST_M((dp).geom)`에서 꺼낸다. 구현 위치는 `prediction/db/snpdb.py`다.
### 4.2 safe watermark
현재 구현은 “DB 적재가 완료된 bucket만 읽는다”는 원칙을 따른다.
- `prediction/time_bucket.py`
- `compute_safe_bucket()`
- `compute_initial_window_start()`
- `compute_incremental_window_start()`
- 기본값:
- `SNPDB_SAFE_DELAY_MIN`
- `SNPDB_BACKFILL_BUCKETS`
핵심 규칙:
1. 초기 적재는 `now - safe_delay`를 5분 내림한 `safe_bucket`까지만 읽는다.
2. 증분 적재는 `last_bucket - backfill_window`부터 `safe_bucket`까지 다시 읽는다.
3. live cache는 `timestamp`가 아니라 `time_bucket` 기준으로 24시간 cutoff를 맞춘다.
### 4.3 왜 safe watermark가 필요한가
`time_bucket > last_bucket`만 사용하면, 늦게 들어온 같은 bucket row를 영구히 놓칠 수 있다. 현재 구현은 overlap backfill과 dedupe로 이 drift를 줄인다.
- 조회: `prediction/db/snpdb.py`
- 병합 dedupe: `prediction/cache/vessel_store.py`
## 5. Stage 1: 캐시 적재와 유지
### 5.1 초기 적재
`prediction/main.py`는 시작 시 `vessel_store.load_initial(24)`를 호출한다.
`prediction/cache/vessel_store.py`의 규칙:
1. `snpdb.fetch_all_tracks(hours)`로 최근 24시간을 safe bucket까지 읽는다.
2. MMSI별 DataFrame으로 `_tracks`를 구성한다.
3. 최대 `time_bucket``_last_bucket`으로 저장한다.
4. static info와 permit registry를 함께 refresh한다.
### 5.2 증분 병합
스케줄러는 `snpdb.fetch_incremental(vessel_store.last_bucket)`로 overlap backfill 구간을 다시 읽는다.
`merge_incremental()` 규칙:
1. 기존 DataFrame과 새 batch를 합친다.
2. `timestamp`, `time_bucket`으로 정렬한다.
3. `timestamp` 기준 중복은 `keep='last'`로 제거한다.
4. batch의 최대 `time_bucket`이 더 크면 `_last_bucket`을 갱신한다.
### 5.3 stale eviction
`evict_stale()`는 safe bucket 기준 24시간 이전 포인트를 제거한다. `time_bucket`이 있으면 bucket 기준, 없으면 timestamp 기준으로 fallback한다.
## 6. Stage 2: 어구 identity 추출
`prediction/fleet_tracker.py`는 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2`를 파싱하고 `gear_identity_log`를 관리한다.
### 6.1 이름 기반 필터
공통 규칙은 `prediction/algorithms/gear_name_rules.py`에 있다.
- 정규화:
- 대문자화
- 공백, `_`, `-`, `%` 제거
- 추적 가능 최소 길이:
- 정규화 길이 `>= 4`
`fleet_tracker.py``polygon_builder.py`는 모두 `is_trackable_parent_name()`을 사용한다. 즉 짧은 이름은 추론 이전, 그룹 생성 이전 단계부터 제외된다.
### 6.2 identity log 동작
`fleet_tracker.py`의 핵심 분기:
1. 같은 MMSI + 같은 이름:
- 기존 활성 row의 `last_seen_at`, 위치만 갱신
2. 같은 MMSI + 다른 이름:
- 기존 row 비활성화
- 새 row insert
3. 다른 MMSI + 같은 이름:
- 기존 row 비활성화
- 새 MMSI로 row insert
- 기존 `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전
## 7. Stage 3: 어구 그룹 생성과 서브클러스터
실제 어구 그룹은 `prediction/algorithms/polygon_builder.py``detect_gear_groups()`가 만든다.
### 7.1 1차 그룹화
규칙:
1. 최신 position 이름이 어구 패턴에 맞아야 한다.
2. `STALE_SEC`를 넘는 오래된 신호는 제외한다.
3. `440`, `441` MMSI는 어구 AIS 미사용으로 간주해 제외한다.
4. `is_trackable_parent_name(parent_raw)`를 만족해야 한다.
5. 같은 `parent_name`은 공백 제거 버전으로 묶는다.
### 7.2 서브클러스터 생성
같은 이름 아래에서도 거리 기반 연결성으로 덩어리를 나눈다.
- 거리 임계치: `MAX_DIST_DEG = 0.15`
- 연결 규칙:
- 각 어구가 클러스터 내 최소 1개와 `MAX_DIST_DEG` 이내면 같은 연결 요소
- 구현:
- Union-Find
모선이 이미 있으면, 모선과 가장 가까운 클러스터를 seed cluster로 간주한다.
### 7.3 `sub_cluster_id` 부여 규칙
현재 구현은 아래와 같다.
1. 클러스터가 1개면 `sub_cluster_id = 0`
2. 클러스터가 여러 개면 `1..N`
3. 이후 동일 `parent_key`의 두 서브그룹이 다시 근접 병합되면 `sub_cluster_id = 0`
`sub_cluster_id`는 영구 식별자가 아니라 “그 시점의 공간 분리 라벨”이다.
### 7.4 병합 규칙
동일 `parent_key`의 두 그룹이 다시 가까워지면:
1. 멤버를 합친다.
2. 부모 MMSI가 없는 큰 그룹에 작은 그룹의 `parent_mmsi`를 승계할 수 있다.
3. `sub_cluster_id = 0`으로 재설정한다.
### 7.5 스냅샷 생성 규칙
`build_all_group_snapshots()`는 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 만든다.
- `1h`
- 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `>= 2`
- `1h-fb`
- 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `< 2`
- 리플레이/일치율 추적용
- 라이브 현황에서 제외
- `6h`
- 6시간 내 stale이 아니어야 함
추가 규칙:
1. 서브클러스터 내 1h 활성 멤버가 2개 미만이면 최신 2개로 fallback display를 만든다.
2. 수역 외(`GEAR_OUT_ZONE`)인데 멤버 수가 `MIN_GEAR_GROUP_SIZE` 미만이면 스킵한다.
3. 모선이 있고, 멤버와 충분히 근접하면 `members[].isParent = true`로 같이 넣는다.
## 8. Stage 4: correlation 모델
`prediction/algorithms/gear_correlation.py`는 어구 그룹별 raw metric과 EMA score를 만든다.
### 8.1 후보 생성
입력:
- group center
- group radius
- active ratio
- group member MMSI set
출력 후보:
- 선박 후보(`VESSEL`)
- 잘못 분류된 어구 후보(`GEAR_BUOY`)
후보 수는 그룹당 최대 `30`개로 제한된다.
### 8.2 raw metric
선박 후보는 최근 6시간 항적 기반으로 아래 값을 만든다.
- `proximity_ratio`
- `visit_score`
- `activity_sync`
- `dtw_similarity`
어구 후보는 단순 거리 기반 `proximity_ratio`만 사용한다.
### 8.3 EMA score
모델 파라미터(`gear_correlation_param_models`)별로 아래를 수행한다.
1. composite score 계산
2. 이전 score와 streak를 읽는다
3. `update_score()`로 EMA 갱신
4. threshold 이상이거나 기존 row가 있으면 upsert
반대로 이번 사이클 후보군에서 빠진 기존 항목은 `OUT_OF_RANGE`로 fast decay된다.
### 8.4 correlation 산출물
- `gear_correlation_raw_metrics`
- `gear_correlation_scores`
여기까지는 “잠재적 모선/근접 대상”의 score이고, 최종 parent inference는 아직 아니다.
## 9. Stage 5: parent inference
`prediction/algorithms/gear_parent_inference.py`가 최종 모선 추론을 수행한다.
전체 진입점은 `run_gear_parent_inference(vessel_store, gear_groups, conn)`이다.
### 9.1 전체 분기 개요
```mermaid
flowchart TD
A["active gear group"] --> B{"direct parent member<br/>exists?"}
B -- yes --> C["DIRECT_PARENT_MATCH<br/>fresh resolution upsert"]
B -- no --> D{"trackable parent name?"}
D -- no --> E["SKIPPED_SHORT_NAME"]
D -- yes --> F["build candidate set"]
F --> G{"candidate exists?"}
G -- no --> H["NO_CANDIDATE"]
G -- yes --> I["score + rank + margin + stable cycles"]
I --> J{"auto promotion rule?"}
J -- yes --> K["AUTO_PROMOTED"]
J -- no --> L{"top score >= 0.60?"}
L -- yes --> M["REVIEW_REQUIRED"]
L -- no --> N["UNRESOLVED"]
```
### 9.1.1 episode continuity 선행 단계
현재 구현에서 `run_gear_parent_inference()`는 후보 점수를 만들기 전에 먼저 `prediction/algorithms/gear_parent_episode.py`를 호출해 active 그룹의 continuity를 계산한다.
입력:
- 현재 cycle `gear_groups`
- 정규화된 `parent_name`
- 최근 `6h` active `gear_group_episodes`
- 최근 `24h` episode prior, `7d` lineage prior, `30d` label prior 집계
핵심 규칙:
1. continuity score는 `0.75 * member_jaccard + 0.25 * center_support`다.
2. 중심점 지원값은 `12nm` 이내일수록 커진다.
3. continuity score가 충분하거나, overlap member가 있고 거리 조건을 만족하면 연결 후보로 본다.
4. 두 개 이상 active episode가 하나의 현재 cluster로 들어오면 `MERGE_NEW`다.
5. 하나의 episode가 여러 현재 cluster로 갈라지면 하나는 `SPLIT_CONTINUE`, 나머지는 `SPLIT_NEW`다.
6. 아무 previous episode와도 연결되지 않으면 `NEW`다.
7. 현재 cycle과 연결되지 못한 active episode는 `EXPIRED` 또는 `MERGED`로 종료한다.
현재 저장되는 continuity 메타데이터:
- `gear_group_parent_candidate_snapshots.episode_id`
- `gear_group_parent_resolution.episode_id`
- `gear_group_parent_resolution.continuity_source`
- `gear_group_parent_resolution.continuity_score`
- `gear_group_parent_resolution.prior_bonus_total`
- `gear_group_episodes`
- `gear_group_episode_snapshots`
### 9.2 direct parent 보강
최신 어구 그룹에 아래 중 하나가 있으면 후보 추론 대신 직접 모선 매칭으로 처리한다.
1. `members[].isParent = true`
2. `group.parent_mmsi` 존재
이 경우:
- `status = DIRECT_PARENT_MATCH`
- `decision_source = DIRECT_PARENT_MATCH`
- `confidence = 1.0`
- `candidateCount = 0`
단, 기존 상태가 `MANUAL_CONFIRMED`면 그 수동 상태를 유지한다.
### 9.3 짧은 이름 스킵
정규화 이름 길이 `< 4`면:
- 후보 생성 자체를 수행하지 않는다.
- `status = SKIPPED_SHORT_NAME`
- `decision_source = AUTO_SKIP`
### 9.4 후보 집합
후보 집합은 아래의 합집합이다.
1. default correlation model 상위 후보
2. registry name exact bucket
3. 기존 resolution의 `selected_parent_mmsi` 또는 이전 top candidate
여기에 아래를 적용한다.
- active global exclusion 제거
- active group exclusion 제거
- 최근 reject cooldown 후보 제거
### 9.5 이름 점수
현재 구현 규칙:
1. 원문 완전일치: `1.0`
2. 정규화 완전일치: `0.8`
3. prefix/contains: `0.5`
4. 숫자를 제거한 순수 문자 부분만 동일: `0.3`
5. 그 외: `0.0`
비교 대상:
- `parent_name`
- 후보 AIS 이름
- registry `name_cn`
- registry `name_en`
### 9.6 coverage-aware evidence
짧은 항적 과대평가를 막기 위해 raw score와 effective score를 분리한다.
evidence에 남는 값:
- `trackPointCount`
- `trackSpanMinutes`
- `overlapPointCount`
- `overlapSpanMinutes`
- `inZonePointCount`
- `inZoneSpanMinutes`
- `trackCoverageFactor`
- `visitCoverageFactor`
- `activityCoverageFactor`
- `coverageFactor`
현재 최종 점수에는 raw가 아니라 adjusted score가 들어간다.
### 9.7 점수 식
가중치 합은 아래다.
- `0.40 * base_corr`
- `0.15 * name_match`
- `0.15 * track_similarity_effective`
- `0.10 * visit_effective`
- `0.05 * proximity_effective`
- `0.05 * activity_effective`
- `0.10 * stability`
- `+ registry_bonus(0.05)`
그 다음 별도 후가산:
- `412/413` MMSI 보너스 `+0.15`
- 단, `preBonusScore >= 0.30`일 때만 적용
- `episode/lineage/label prior bonus`
- 최근 동일 episode `24h`
- 동일 lineage `7d`
- 라벨 세션 `30d`
- 총합 cap `0.20`
### 9.8 상태 전이
분기 조건:
- `NO_CANDIDATE`
- 후보가 하나도 없을 때
- `AUTO_PROMOTED`
- `target_type == VESSEL`
- candidate source에 `CORRELATION` 포함
- `final_score >= auto_promotion_threshold`
- `margin >= auto_promotion_margin`
- `stable_cycles >= auto_promotion_stable_cycles`
- `REVIEW_REQUIRED`
- `final_score >= 0.60`
- `UNRESOLVED`
- 나머지
추가 예외:
- 기존 상태가 `MANUAL_CONFIRMED`면 수동 상태를 유지한다.
- active label session이 있으면 tracking row를 별도로 적재한다.
### 9.9 산출물
- `gear_group_parent_candidate_snapshots`
- `gear_group_parent_resolution`
- `gear_parent_label_tracking_cycles`
- `gear_group_episodes`
- `gear_group_episode_snapshots`
## 10. Stage 6: backend read model
backend의 중심은 `backend/.../GroupPolygonService.java`다.
### 10.1 최신 1h만 라이브로 간주
group list, review queue, detail API는 모두 최신 전역 `1h` 스냅샷만 기준으로 삼는다.
핵심 효과:
1. `1h-fb`는 라이브 현황에서 기본 제외된다.
2. 이미 사라진 과거 sub-cluster는 detail API에서 다시 보이지 않는다.
### 10.2 stale inference 차단
`resolution.last_evaluated_at >= group.snapshot_time`인 경우만 join한다.
즉 최신 group snapshot보다 오래된 candidate/resolution은 detail/review/list에서 숨긴다. 이 규칙이 `ZHEDAIYU02433`, `ZHEDAIYU02394` 유형 stale 표시를 막는다.
### 10.3 detail API 의미
`/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
현재 의미:
- 해당 그룹의 최신 전역 `1h` live sub-cluster 집합
- 각 sub-cluster의 fresh resolution
- 각 sub-cluster의 latest candidate snapshot
## 11. Stage 7: review / exclusion / label v2
v2 Phase 1은 “자동 추론 결과”와 “사람 판단 데이터”를 분리하는 구조다.
### 11.1 사람 판단 저장소
- `gear_parent_candidate_exclusions`
- `gear_parent_label_sessions`
- `gear_parent_label_tracking_cycles`
### 11.2 액션 의미
- 그룹 제외:
- 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 제거
- 전체 후보 제외:
- 특정 MMSI를 모든 그룹 후보군에서 제거
- 정답 라벨:
- 특정 그룹에 대해 정답 parent MMSI를 `1/3/5일` 세션으로 지정
- prediction은 이후 cycle마다 top1/top3 여부를 추적
### 11.3 why v2
기존 `MANUAL_CONFIRMED`/`REJECT`는 운영 override 성격이 강했고, “모델 정확도 평가용 백데이터”와 섞였다. v2는 이 둘을 분리해 라벨을 평가 데이터로 쓰도록 한다.
## 12. 실제 경우의 수 분기표
| 경우 | 구현 위치 | 현재 동작 |
| --- | --- | --- |
| 이름 길이 `< 4` | `gear_name_rules.py`, `fleet_tracker.py`, `polygon_builder.py`, `gear_parent_inference.py` | identity/grouping/inference 단계에서 제외 또는 `SKIPPED_SHORT_NAME` |
| 직접 모선 포함 | `polygon_builder.py`, `gear_parent_inference.py` | `DIRECT_PARENT_MATCH` fresh resolution |
| 같은 이름, 멀리 떨어진 어구 | `polygon_builder.py` | 별도 sub-cluster 생성 |
| 두 서브클러스터가 다시 근접 | `polygon_builder.py` | 하나로 병합, `sub_cluster_id = 0` |
| group 전체 1h 활성 멤버 `< 2` | `polygon_builder.py` | `1h-fb` 생성, live 현황 제외 |
| 후보가 하나도 없음 | `gear_parent_inference.py` | `NO_CANDIDATE` |
| 짧은 항적이 우연히 근접 | `gear_parent_inference.py` | coverage-aware 보정으로 effective score 감소 |
| stale old inference가 남아 있음 | `GroupPolygonService.java` | 최신 group snapshot보다 오래되면 숨김 |
| 직접 parent가 이미 있음 | `gear_parent_inference.py` | 후보 계산 대신 direct parent resolution |
## 13. `sub_cluster_id`의 한계
현재 코드에서 `sub_cluster_id`는 영구 identity가 아니다.
이유:
1. 같은 이름 그룹의 공간 분리 수가 cycle마다 달라질 수 있다.
2. 병합되면 `0`으로 재설정된다.
3. 멤버가 추가/이탈해도 기존 번호 의미가 유지된다고 보장할 수 없다.
따라서 `group_key + sub_cluster_id`는 “현재 cycle의 공간 덩어리”를 가리키는 키로는 유효하지만, 장기 연속 추적 키로는 부적합하다.
## 14. Stage 8: `episode_id` continuity + prior bonus
### 14.1 목적
현재 구현의 `episode_id`는 “같은 어구 덩어리의 시간적 연속성”을 추적하는 별도 식별자다. `sub_cluster_id`를 대체하지 않고, 그 위에 얹는 계층이다.
핵심 목적:
- 작은 멤버 변화는 같은 episode로 이어 붙인다.
- 구조적 split/merge는 continuity source로 기록한다.
- long-memory는 `stable_cycles` 직접 승계가 아니라 약한 prior bonus로만 전달한다.
### 14.2 현재 저장소
- `gear_group_episodes`
- active/merged/expired episode 현재 상태
- `gear_group_episode_snapshots`
- cycle별 episode 스냅샷
- `gear_group_parent_candidate_snapshots`
- `episode_id`, `normalized_parent_name`,
`episode_prior_bonus`, `lineage_prior_bonus`, `label_prior_bonus`
- `gear_group_parent_resolution`
- `episode_id`, `continuity_source`, `continuity_score`, `prior_bonus_total`
### 14.3 continuity score
현재 continuity score는 아래다.
```text
continuity_score =
0.75 * member_jaccard
+ 0.25 * center_support
```
- `member_jaccard`
- 현재/이전 episode 멤버 MMSI Jaccard
- `center_support`
- 중심점 거리 `12nm` 이내일수록 높아지는 값
연결 후보 판단:
- continuity score `>= 0.45`
- 또는 overlap member가 있고 거리 조건을 만족하면 연결 후보로 인정
### 14.4 continuity source 규칙
- `NEW`
- 어떤 이전 episode와도 연결되지 않음
- `CONTINUED`
- 1:1 continuity
- `SPLIT_CONTINUE`
- 하나의 이전 episode가 여러 현재 cluster로 갈라졌고, 그중 주 가지
- `SPLIT_NEW`
- split로 새로 생성된 가지
- `MERGE_NEW`
- 2개 이상 active episode가 의미 있게 하나의 현재 cluster로 합쳐짐
- `DIRECT_PARENT_MATCH`
- 직접 모선 포함 그룹이 fresh resolution으로 정리되는 경우의 최종 resolution source
### 14.5 merge / split / expire
현재 구현 규칙:
1. split
- 가장 유사한 현재 cluster 1개는 기존 episode 유지
- 나머지는 새 episode 생성
- 새 episode에는 `split_from_episode_id` 저장
2. merge
- 2개 이상 previous episode가 같은 현재 cluster로 의미 있게 들어오면 새 episode 생성
- 이전 episode들은 `MERGED`, `merged_into_episode_id = 새 episode`
3. expire
- 최근 `6h` active episode가 현재 cycle 어떤 cluster와도 연결되지 않으면 `EXPIRED`
### 14.6 prior bonus 계층
현재 final score에는 signal score 뒤에 아래 prior bonus가 후가산된다.
- `episode_prior_bonus`
- 최근 동일 episode `24h`
- cap `0.10`
- `lineage_prior_bonus`
- 동일 정규화 이름 lineage `7d`
- cap `0.05`
- `label_prior_bonus`
- 동일 lineage 라벨 세션 `30d`
- cap `0.10`
- 총합 cap
- `0.20`
현재 후보가 이미 candidate set에 들어온 경우에만 적용하며, 과거 점수를 직접 carry하는 대신 약한 보너스로만 사용한다.
### 14.7 병합 후 후보 관성
질문 사례처럼 `A` episode 후보 `a`, `B` episode 후보 `b`가 있다가 병합 후 `b`가 더 적합해질 수 있다. 현재 구현은 병합 시 무조건 `A`를 유지하지 않고 새 episode를 생성해 `A/B` 둘 다의 history를 prior bonus 풀에서 재평가한다. 따라서 `b`는 완전 신규 후보처럼 0에서 시작하지 않지만, `A`의 과거 `stable_cycles`가 그대로 지배하지도 않는다.
## 15. 현재 episode 상태 흐름
```mermaid
stateDiagram-v2
[*] --> Active
Active --> Active: "CONTINUED / 소규모 멤버 변동"
Active --> Active: "SPLIT_CONTINUE"
Active --> Active: "MERGE_NEW로 새 episode 생성 후 연결"
Active --> Merged: "merged_into_episode_id 기록"
Active --> Expired: "최근 6h continuity 없음"
Merged --> [*]
Expired --> [*]
```
## 16. 결론
현재 구현은 아래를 모두 포함한다.
- safe watermark + overlap backfill 기반 incremental 안정화
- 짧은 이름 그룹 제거
- 거리 기반 sub-cluster와 `1h/1h-fb/6h` 스냅샷
- correlation + parent inference 분리
- coverage-aware score 보정
- stale inference 차단
- direct parent supplement
- v2 exclusion/label/tracking 저장소
- `episode_id` continuity와 prior bonus
남은 과제는 `episode` 자체보다, 이 continuity 계층을 read model과 시각화에서 더 설명력 있게 노출하는 것이다. 즉 다음 단계의 핵심은 episode 도입이 아니라, `episode lineage API`, calibration report, richer review analytics를 얹는 일이다.
## 17. 참고 코드
- `prediction/main.py`
- `prediction/time_bucket.py`
- `prediction/db/snpdb.py`
- `prediction/cache/vessel_store.py`
- `prediction/fleet_tracker.py`
- `prediction/algorithms/gear_name_rules.py`
- `prediction/algorithms/polygon_builder.py`
- `prediction/algorithms/gear_correlation.py`
- `prediction/algorithms/gear_parent_episode.py`
- `prediction/algorithms/gear_parent_inference.py`
- `backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java`
- `backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java`
- `database/migration/012_gear_parent_inference.sql`
- `database/migration/014_gear_parent_workflow_v2_phase1.sql`
- `database/migration/015_gear_parent_episode_tracking.sql`

파일 보기

@ -0,0 +1,706 @@
# Gear Parent Inference Workflow V2 Phase 1 Spec
## 목적
이 문서는 `GEAR-PARENT-INFERENCE-WORKFLOW-V2.md`의 첫 구현 단계를 바로 개발할 수 있는 수준으로 구체화한 명세다.
Phase 1 범위는 아래로 제한한다.
- DB 마이그레이션
- backend API 계약
- prediction exclusion/label read-write 지점
- 프론트의 최소 계약 변화
이번 단계에서는 실제 자동화/LLM 연결은 다루지 않는다.
## 범위 요약
### 포함
- 그룹 단위 후보 제외 `1/3/5일`
- 전역 후보 제외
- 정답 라벨 세션 `1/3/5일`
- 라벨 세션 기간 동안 cycle별 tracking 기록
- active exclusion을 parent inference 후보 생성에 반영
- exclusion/label 관리 API
### 제외
- 운영 `kcg` 스키마 반영
- 기존 `gear_correlation_scores` 산식 변경
- LLM reviewer
- label session의 anchor 기반 재매칭 보강
- UI 고도화 화면 전부
## 구현 원칙
1. 기존 자동 추론 저장소는 유지한다.
2. 새 사람 판단 데이터는 별도 테이블에 저장한다.
3. Phase 1에서는 `group_key + sub_cluster_id`를 세션 식별 기준으로 고정한다.
4. 기존 `CONFIRM/REJECT/RESET` API는 삭제하지 않지만, 새 UI에서는 사용하지 않는다.
5. 새 API와 prediction 로직은 `kcg_lab` 기준으로만 먼저 구현한다.
## DB 명세
## 1. `gear_parent_candidate_exclusions`
### 목적
- 그룹 단위 후보 제외와 전역 후보 제외를 단일 저장소에서 관리
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL,
group_key VARCHAR(100),
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL,
duration_days INT,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ,
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
CONSTRAINT chk_gpce_group_scope CHECK (
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
OR
(scope_type = 'GLOBAL' AND duration_days IS NULL)
)
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
ON kcg.gear_parent_candidate_exclusions(active_until);
```
### active 판정 규칙
active exclusion은 아래를 만족해야 한다.
```sql
released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW())
```
### 해석 규칙
- `GROUP`
- 특정 그룹에서만 해당 후보 제거
- `GLOBAL`
- 모든 그룹에서 해당 후보 제거
## 2. `gear_parent_label_sessions`
### 목적
- 정답 라벨 세션 저장
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
ON kcg.gear_parent_label_sessions(active_until);
```
### active 판정 규칙
```sql
status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW()
```
### 만료 처리 규칙
prediction 또는 backend batch에서 아래를 주기적으로 실행한다.
```sql
UPDATE kcg.gear_parent_label_sessions
SET status = 'EXPIRED', updated_at = NOW()
WHERE status = 'ACTIVE'
AND active_until <= NOW();
```
## 3. `gear_parent_label_tracking_cycles`
### 목적
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
```
## 4. 기존 `gear_group_parent_review_log` action 확장
### 새 action 목록
- `LABEL_PARENT`
- `EXCLUDE_GROUP`
- `EXCLUDE_GLOBAL`
- `RELEASE_EXCLUSION`
- `CANCEL_LABEL`
기존 action과 공존한다.
## migration 파일 제안
- `014_gear_parent_workflow_v2_phase1.sql`
구성 순서:
1. 새 테이블 3개 생성
2. 인덱스 생성
3. review log action 확장은 schema 변경 불필요
4. optional helper view 추가
## optional view 제안
### `vw_active_gear_parent_candidate_exclusions`
```sql
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
SELECT *
FROM kcg.gear_parent_candidate_exclusions
WHERE released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW());
```
### `vw_active_gear_parent_label_sessions`
```sql
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
SELECT *
FROM kcg.gear_parent_label_sessions
WHERE status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW();
```
## backend API 명세
## 공통 정책
- 모든 write API는 `actor` 필수
- `group_key`, `sub_cluster_id`, `candidate_mmsi`, `selected_parent_mmsi`는 trim 후 저장
- 잘못된 기간은 `400 Bad Request`
- 중복 active session/exclusion 생성 시 `409 Conflict` 대신 동일 active row를 반환해도 됨
- Phase 1에서는 멱등성을 우선한다
## 1. 정답 라벨 세션 생성
### endpoint
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-sessions`
### request
```json
{
"selectedParentMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
```
### validation
- `selectedParentMmsi` 필수
- `durationDays in (1,3,5)`
- 동일 `groupKey + subClusterId`에 active label session이 이미 있으면 새 row 생성 금지
### response
```json
{
"groupKey": "58399",
"subClusterId": 0,
"action": "LABEL_PARENT",
"labelSession": {
"id": 12,
"status": "ACTIVE",
"labelParentMmsi": "412333326",
"labelParentName": "UWEIJINGYU51015",
"durationDays": 3,
"activeFrom": "2026-04-03T10:00:00+09:00",
"activeUntil": "2026-04-06T10:00:00+09:00",
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
}
```
## 2. 그룹 후보 제외 생성
### endpoint
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions`
### request
```json
{
"candidateMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "이 그룹에서는 오답"
}
```
### 생성 규칙
- 내부적으로 `scopeType='GROUP'`
- `reasonType='GROUP_WRONG_PARENT'`
- 동일 `groupKey + subClusterId + candidateMmsi` active row가 있으면 재사용
### response
```json
{
"groupKey": "58399",
"subClusterId": 0,
"action": "EXCLUDE_GROUP",
"exclusion": {
"id": 33,
"scopeType": "GROUP",
"candidateMmsi": "412333326",
"durationDays": 3,
"activeFrom": "2026-04-03T10:00:00+09:00",
"activeUntil": "2026-04-06T10:00:00+09:00"
}
}
```
## 3. 전역 후보 제외 생성
### endpoint
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/global`
### request
```json
{
"candidateMmsi": "412333326",
"actor": "analyst-01",
"comment": "모든 어구에서 후보 제외"
}
```
### 생성 규칙
- `scopeType='GLOBAL'`
- `reasonType='GLOBAL_NOT_PARENT_TARGET'`
- `activeUntil = NULL`
- 동일 candidate active global exclusion이 있으면 재사용
## 4. exclusion 해제
### endpoint
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release`
### request
```json
{
"actor": "analyst-01",
"comment": "해제"
}
```
### 동작
- `released_at = NOW()`
- `released_by = actor`
- `updated_at = NOW()`
## 5. label session 종료
### endpoint
`POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel`
### request
```json
{
"actor": "analyst-01",
"comment": "조기 종료"
}
```
### 동작
- `status='CANCELLED'`
- `updated_at = NOW()`
## 6. active exclusion 조회
### endpoint
`GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GROUP|GLOBAL&candidateMmsi=...&groupKey=...`
### response 필드
- `id`
- `scopeType`
- `groupKey`
- `subClusterId`
- `candidateMmsi`
- `reasonType`
- `durationDays`
- `activeFrom`
- `activeUntil`
- `releasedAt`
- `actor`
- `comment`
- `isActive`
## 7. label session 목록
### endpoint
`GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE|EXPIRED|CANCELLED&groupKey=...`
### response 필드
- `id`
- `groupKey`
- `subClusterId`
- `labelParentMmsi`
- `labelParentName`
- `durationDays`
- `activeFrom`
- `activeUntil`
- `status`
- `actor`
- `comment`
- `latestTrackingSummary`
## 8. label tracking 상세
### endpoint
`GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking`
### response 필드
- `session`
- `count`
- `items[]`
- `observedAt`
- `autoStatus`
- `topCandidateMmsi`
- `topCandidateScore`
- `topCandidateMargin`
- `candidateCount`
- `labeledCandidatePresent`
- `labeledCandidateRank`
- `labeledCandidateScore`
- `labeledCandidatePreBonusScore`
- `matchedTop1`
- `matchedTop3`
## backend 구현 위치
### 새 DTO/Request 제안
- `GroupParentLabelSessionRequest`
- `GroupParentCandidateExclusionRequest`
- `ReleaseParentCandidateExclusionRequest`
- `CancelParentLabelSessionRequest`
- `ParentCandidateExclusionDto`
- `ParentLabelSessionDto`
- `ParentLabelTrackingCycleDto`
### service 추가 메서드 제안
- `createGroupCandidateExclusion(...)`
- `createGlobalCandidateExclusion(...)`
- `releaseCandidateExclusion(...)`
- `createLabelSession(...)`
- `cancelLabelSession(...)`
- `listCandidateExclusions(...)`
- `listLabelSessions(...)`
- `getLabelSessionTracking(...)`
## prediction 명세
## 적용 함수
중심 파일은 [prediction/algorithms/gear_parent_inference.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/gear_parent_inference.py)다.
### 새 load 함수
- `_load_active_candidate_exclusions(conn, group_keys)`
- `_load_active_label_sessions(conn, group_keys)`
### 반환 구조
`_load_active_candidate_exclusions`
```python
{
"global": {"412333326", "413000111"},
"group": {("58399", 0): {"412333326"}}
}
```
`_load_active_label_sessions`
```python
{
("58399", 0): {
"id": 12,
"label_parent_mmsi": "412333326",
"active_until": ...,
...
}
}
```
### 후보 pruning 순서
1. 기존 candidate union 생성
2. `GLOBAL` exclusion 제거
3. 해당 그룹의 `GROUP` exclusion 제거
4. 남은 후보만 scoring
### tracking row write 규칙
각 그룹 처리 후:
- active label session이 없으면 skip
- 있으면 현재 cycle 결과를 `gear_parent_label_tracking_cycles`에 upsert-like insert
필수 기록값:
- `label_session_id`
- `observed_at`
- `candidate_snapshot_observed_at`
- `auto_status`
- `top_candidate_mmsi`
- `top_candidate_score`
- `top_candidate_margin`
- `candidate_count`
- `labeled_candidate_present`
- `labeled_candidate_rank`
- `labeled_candidate_score`
- `labeled_candidate_pre_bonus_score`
- `matched_top1`
- `matched_top3`
### pre-bonus score 취득
현재 candidate evidence에 이미 아래가 있다.
- `evidence.scoreBreakdown.preBonusScore`
tracking row에서는 이 값을 직접 읽어 저장한다.
### resolution 처리 원칙
Phase 1에서는 다음을 적용한다.
- `LABEL_PARENT`, `EXCLUDE_GROUP`, `EXCLUDE_GLOBAL``gear_group_parent_resolution` 상태를 바꾸지 않는다.
- 자동 추론은 기존 상태 전이를 그대로 사용한다.
- legacy `MANUAL_CONFIRMED` 로직은 남겨두되, 새 UI에서는 호출하지 않는다.
## 프론트 최소 계약
## 기존 패널 액션 치환
현재:
- `확정`
- `24시간 제외`
Phase 1 새 기본 액션:
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
### 기간 선택 UI
- `정답 라벨`: `1일`, `3일`, `5일`
- `이 그룹에서 제외`: `1일`, `3일`, `5일`
- `전체 후보 제외`: 기간 없음
### 표시 정보
후보 card badge:
- `이 그룹 제외 중`
- `전체 후보 제외 중`
- `정답 라벨 대상`
그룹 summary box:
- active label session 여부
- active group exclusion count
## API 에러 규약
### 400
- 잘못된 duration
- 필수 필드 누락
- groupKey/subClusterId 없음
### 404
- 대상 group 없음
- exclusion/session id 없음
### 409
- active label session 중복 생성
단, Phase 1에서는 backend에서 충돌 시 기존 active row를 그대로 반환하는 방식도 허용한다.
## 테스트 기준
## DB
- GROUP exclusion active query가 정확히 동작
- GLOBAL exclusion active query가 정확히 동작
- label session 만료 시 `EXPIRED` 전환
## backend
- create/release exclusion API
- create/cancel label session API
- list APIs 필터 조건
## prediction
- active exclusion candidate pruning
- global/group exclusion 우선 적용
- label session tracking row 생성
- labeled candidate absent/present/top1/top3 케이스
## 수용 기준
1. 특정 그룹에서 후보 제외를 걸면 다음 cycle부터 그 그룹 후보 목록에서만 빠진다.
2. 전역 후보 제외를 걸면 모든 그룹 후보 목록에서 빠진다.
3. 정답 라벨 세션 생성 후 다음 cycle부터 tracking row가 쌓인다.
4. 자동 resolution은 계속 자동 상태를 유지한다.
5. 기존 manual override API를 쓰지 않아도 review/label/exclusion 흐름이 독립적으로 동작한다.
## Phase 1 이후 바로 이어질 일
### Phase 2
- 라벨 추적 대시보드
- exclusion 관리 화면
- 지표 요약 endpoint
- episode continuity read model 노출
- prior bonus calibration report
### Phase 3
- label session anchor 기반 재매칭
- group case/episode lineage API 확장
- calibration report
## 권장 구현 순서
1. `014_gear_parent_workflow_v2_phase1.sql`
2. backend DTO + controller/service
3. prediction active exclusion/load + tracking write
4. frontend 버튼 교체와 최소 조회 화면
이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.

파일 보기

@ -0,0 +1,693 @@
# Gear Parent Inference Workflow V2
## 문서 목적
이 문서는 lab 환경의 어구 모선 추적 워크플로우를 v1 운영 override 중심 구조에서,
`평가 데이터 축적 + 후보 제외 관리 + 기간형 정답 라벨 추적` 중심 구조로 재정의하는 설계서다.
대상 범위는 아래와 같다.
- `kcg_lab` 스키마
- `backend-lab` (`192.168.1.20:18083`)
- `prediction-lab` (`192.168.1.18:18091`)
- 로컬 프론트 `yarn dev:lab`
운영 `kcg` 스키마와 기존 데모 동작은 이번 설계 단계에서 변경하지 않는다.
현재 구현 기준으로는 v2 Phase 1 저장소/API가 이미 lab에 반영되어 있고, 그 위에 `015_gear_parent_episode_tracking.sql``prediction/algorithms/gear_parent_episode.py`를 통해 `episode continuity + prior bonus` 계층이 추가되었다. 이 문서는 여전히 워크플로우 설계서지만, 사람 판단 저장소와 자동 추론 저장소 분리 원칙은 현재 코드의 실제 기준이기도 하다.
## 배경
현재 v1은 자동 추론 결과와 사람 판단이 같은 저장소에 섞여 있다.
- `확정``gear_group_parent_resolution``MANUAL_CONFIRMED`로 덮어쓴다.
- `24시간 제외`는 특정 그룹에서 후보 1개를 24시간 숨긴다.
- 자동 추론은 계속 돌지만, 수동 판단이 최종 상태를 override한다.
이 구조는 단기 운용에는 편하지만, 아래 목적에는 맞지 않는다.
- 사람이 보면서 모델 가중치와 후보 생성 품질을 평가
- 정답/오답 사례를 데이터셋으로 축적
- 충분한 정확도 확보 후 자동화 또는 LLM 연결
따라서 v2에서는 `자동 추론`, `사람 라벨`, `후보 제외`를 분리한다.
## 핵심 목표
1. 자동 추론 상태는 계속 독립적으로 유지한다.
2. 사람 판단은 override가 아니라 별도 라벨/제외 데이터로 저장한다.
3. 그룹 단위 오답 라벨은 `1일 / 3일 / 5일` 기간형 후보 제외로 관리한다.
4. 전역 후보 제외는 모든 어구 그룹에서 동일 MMSI를 후보군에서 제거한다.
5. 정답 라벨은 `1일 / 3일 / 5일` 세션으로 만들고, 활성 기간 동안 자동 추론 결과를 별도 추적 로그로 남긴다.
6. 알고리즘은 DB exclusion/label 정보를 읽어 다음 cycle부터 바로 반영한다.
7. 향후 threshold 튜닝, 가산점 실험, LLM 연결 평가에 쓰일 수 있는 정량 지표를 만든다.
## 용어
- 자동 추론
- `gear_parent_inference`가 계산한 현재 cycle의 후보 점수와 추천 결과
- 그룹 제외
- 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 후보군에서 제거
- 전역 후보 제외
- 특정 MMSI를 모든 어구 그룹의 모선 후보군에서 제거
- 정답 라벨 세션
- 특정 어구 그룹에 대해 “이 MMSI가 정답 모선”이라고 사람이 지정하고, 일정 기간 자동 추론 결과를 추적하는 세션
- 라벨 추적
- 정답 라벨 세션 활성 기간 동안 자동 추론이 정답 후보를 어떻게 rank/score하는지 누적 저장하는 기록
## 현재 v1의 한계
### 1. `확정`이 평가 라벨이 아니라 운영 override다
- 현재 `CONFIRM`은 resolution을 `MANUAL_CONFIRMED`로 덮어쓴다.
- 이 경우 자동 추론의 실제 성능과 사람 판단이 섞여, 나중에 모델 정확도를 평가하기 어렵다.
### 2. `24시간 제외`는 기간과 범위가 너무 좁다
- 현재는 그룹 단위 24시간 mute만 가능하다.
- `1/3/5일`처럼 길이를 다르게 두고 비교할 수 없다.
- “이 MMSI는 아예 모선 후보 대상이 아니다”라는 전역 규칙을 넣을 수 없다.
### 3. 백데이터 축적 구조가 없다
- 현재는 review log는 남지만, “정답 후보가 cycle별로 몇 위였는지”, “점수가 어떻게 변했는지”, “후보군에 들어왔는지”를 체계적으로 저장하지 않는다.
### 4. 장기 세션에 대한 그룹 스코프가 약하다
- 현재 그룹 기준은 `group_key + sub_cluster_id`다.
- 기간형 라벨/제외를 도입하면 subcluster 재편성 리스크를 고려해야 한다.
## v2 설계 원칙
### 1. 자동 추론 저장소는 그대로 유지한다
아래 기존 저장소는 계속 자동 추론 전용으로 유지한다.
- `gear_group_parent_candidate_snapshots`
- `gear_group_parent_resolution`
- `gear_group_parent_review_log`
단, `review_log`의 의미는 “UI action audit”로 바꾸고, 더 이상 최종 라벨 저장소로 보지 않는다.
### 2. 사람 판단은 새 저장소로 분리한다
사람이 내린 판단은 아래 두 축으로 분리한다.
- 제외 축
- 이 그룹에서 제외
- 전체 후보 제외
- 정답 축
- 기간형 정답 라벨 세션
### 3. 제외는 후보 생성 이후의 gating layer로 둔다
전역 후보 제외는 raw correlation이나 원시 선박 분류를 지우지 않는다.
- `gear_correlation_scores`는 계속 쌓는다.
- exclusion은 parent inference candidate set에서만 hard filter로 적용한다.
이렇게 해야 원시 모델 출력과 사람 개입의 차이를 비교할 수 있다.
### 4. 라벨 세션 동안 자동 추론은 계속 돈다
정답 라벨 세션이 활성화되어도 자동 추론은 그대로 수행한다.
- UI의 기본 검토 대기에서는 숨길 수 있다.
- 하지만 prediction은 계속 candidate snapshot과 tracking record를 남긴다.
### 5. lab에서는 override보다 평가를 우선한다
v2 이후 lab에서 사람 버튼은 기본적으로 자동 resolution을 덮어쓰지 않는다.
- 운영 override가 필요해지면 추후 별도 action으로 분리한다.
- lab의 기본 목적은 평가 데이터 생성이다.
## 사용자 액션 재정의
### `정답 라벨`
의미:
- 해당 어구 그룹의 정답 모선으로 특정 MMSI를 지정
- `1일 / 3일 / 5일` 중 하나의 기간 동안 자동 추론 결과를 추적
동작:
1. `gear_parent_label_sessions`에 active session 생성
2. 다음 cycle부터 prediction이 이 그룹에 대한 추적 로그를 `gear_parent_label_tracking_cycles`에 누적
3. 기본 review queue에서는 해당 그룹을 숨기고, 별도 `라벨 추적` 목록으로 이동
4. 세션 종료 후에는 completed label dataset으로 남음
중요:
- 자동 resolution은 계속 자동 상태를 유지
- 점수에 수동 가산점/감점은 넣지 않음
### `이 그룹에서 제외`
의미:
- 해당 어구 그룹에서만 특정 후보 MMSI를 일정 기간 후보군에서 제외
기간:
- `1일`
- `3일`
- `5일`
동작:
1. `gear_parent_candidate_exclusions``scope_type='GROUP'` row 생성
2. 다음 cycle부터 해당 그룹의 candidate set에서 제거
3. 다른 그룹에는 영향 없음
4. 기간이 끝나면 자동으로 inactive 처리
용도:
- 이 후보는 이 어구 그룹의 모선이 아니라고 사람이 판단한 경우
- 단기/중기 관찰을 위해 일정 기간만 빼고 싶을 때
### `전체 후보 제외`
의미:
- 특정 MMSI는 모든 어구 그룹에서 모선 후보 대상이 아님
동작:
1. `gear_parent_candidate_exclusions``scope_type='GLOBAL'` row 생성
2. prediction candidate generation에서 모든 그룹에 대해 hard filter
3. 해제 전까지 계속 적용
초기 정책:
- 전역 후보 제외는 기본적으로 기간 없이 active 상태 유지
- 수동 `해제` 전까지 유지
용도:
- 패턴 분류상 선박으로 들어왔지만 실제 모선 후보가 아니라고 판단한 AIS
- 잘못된 유형의 신호가 반복적으로 후보군에 유입되는 경우
### `해제`
의미:
- 활성 그룹 제외, 전역 제외, 정답 라벨 세션을 조기 종료
동작:
- exclusion/session row에 `released_at`, `released_by` 또는 `status='CANCELLED'`를 기록
- 다음 cycle부터 알고리즘 적용 대상에서 빠짐
## DB 설계
### 1. `gear_parent_candidate_exclusions`
역할:
- 그룹 단위 제외와 전역 후보 제외를 모두 저장
- active list의 단일 진실원
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL, -- GROUP | GLOBAL
group_key VARCHAR(100), -- GROUP scope에서만 사용
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL, -- GROUP_WRONG_PARENT | GLOBAL_NOT_PARENT_TARGET
duration_days INT, -- GROUP scope는 1|3|5, GLOBAL은 NULL 허용
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ, -- GROUP scope는 필수, GLOBAL은 NULL 가능
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
권장 인덱스:
- `(scope_type, candidate_mmsi)`
- `(group_key, sub_cluster_id, active_from DESC)`
- `(released_at, active_until)`
조회 규칙:
active exclusion은 아래 조건으로 판단한다.
```sql
released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW())
```
### 2. `gear_parent_label_sessions`
역할:
- 특정 그룹에 대한 정답 라벨 세션 저장
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg_lab.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL, -- 1 | 3 | 5
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | EXPIRED | CANCELLED
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
설명:
- `anchor_*` 컬럼은 기간형 라벨 동안 subcluster가 재편성될 가능성에 대비한 보조 식별자다.
- phase 1에서는 실제 매칭은 `group_key + sub_cluster_id`를 기본으로 쓰고, anchor 정보는 저장만 한다.
### 3. `gear_parent_label_tracking_cycles`
역할:
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
- 향후 정확도 지표의 기준 데이터
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg_lab.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
설명:
- 전체 후보 상세는 기존 `gear_group_parent_candidate_snapshots`를 그대로 사용한다.
- 여기에는 지표 계산에 직접 필요한 값만 요약 저장한다.
### 4. 기존 `gear_group_parent_review_log` 재사용
새 action 이름 예시:
- `LABEL_PARENT`
- `EXCLUDE_GROUP`
- `EXCLUDE_GLOBAL`
- `RELEASE_EXCLUSION`
- `CANCEL_LABEL`
즉, 별도 audit table를 또 만들기보다 기존 review log를 action log로 재사용한다.
## prediction 변경 설계
### 적용 지점
핵심 변경 지점은 [gear_parent_inference.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/gear_parent_inference.py), [fleet_tracker.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/fleet_tracker.py), [polygon_builder.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/polygon_builder.py) 중 `gear_parent_inference.py`가 중심이다.
### 1. active exclusion load
cycle 시작 시 아래 두 집합을 읽는다.
- `global_excluded_mmsis`
- `group_excluded_mmsis[(group_key, sub_cluster_id)]`
적용 위치:
- `_build_candidate_scores()`에서 candidate union 이후, 실제 scoring 전에 hard filter
규칙:
- GLOBAL exclusion은 모든 그룹에 적용
- GROUP exclusion은 해당 그룹에만 적용
- exclusion된 후보는 candidate snapshot에도 남기지 않음
중요:
- raw correlation score는 그대로 계산/저장
- exclusion은 parent inference candidate set에서만 적용
### 2. active label session load
cycle 시작 시 현재 unresolved/active gear group에 매칭되는 active label session을 읽는다.
phase 1 매칭 기준:
- `group_key`
- `sub_cluster_id`
phase 2 보강 기준:
- member overlap
- center distance
- anchor snapshot similarity
### 3. tracking cycle write
각 그룹의 자동 추론이 끝난 뒤, active label session이 있으면 `gear_parent_label_tracking_cycles`에 1 row를 쓴다.
기록 항목:
- 현재 auto top-1 후보
- auto top-1 점수/격차
- 후보 수
- 라벨 대상 MMSI가 현재 후보군에 존재하는지
- 존재한다면 rank/score/pre-bonus score
- top1/top3 일치 여부
### 4. resolution 저장 원칙 변경
v2 이후 lab에서는 아래를 원칙으로 한다.
- 자동 resolution은 자동 추론만 반영
- 사람 라벨은 resolution을 덮어쓰지 않음
즉 아래 legacy 상태는 새로 만들지 않는다.
- `MANUAL_CONFIRMED`
- `MANUAL_REJECT`
기존 row는 읽기 전용으로 남겨둘 수 있지만, v2 새 액션은 이 상태를 만들지 않는다.
### 5. exclusion이 적용된 경우의 상태 전이
후보 pruning 이후:
- 후보가 남으면 기존 자동 상태 전이 사용
- top1이 제외되어 후보가 비면 `NO_CANDIDATE`
- top1이 제외되어 top2가 승격되면 새 top1 기준으로 `AUTO_PROMOTED / REVIEW_REQUIRED / UNRESOLVED` 재판정
## backend API 설계
### 1. 정답 라벨 세션 생성
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-session`
request:
```json
{
"selectedParentMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "수동 확인"
}
```
response:
- 생성된 label session
- 현재 active label summary
### 2. 그룹 후보 제외 생성
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions`
request:
```json
{
"candidateMmsi": "412333326",
"scopeType": "GROUP",
"durationDays": 3,
"actor": "analyst-01",
"comment": "이 그룹에서는 오답"
}
```
### 3. 전역 후보 제외 생성
`POST /api/vessel-analysis/parent-inference/candidate-exclusions`
request:
```json
{
"candidateMmsi": "412333326",
"scopeType": "GLOBAL",
"actor": "analyst-01",
"comment": "모든 어구에서 모선 후보 대상 제외"
}
```
### 4. exclusion 해제
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release`
### 5. label session 종료
`POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel`
### 6. active exclusion 조회
`GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GLOBAL`
용도:
- “대상 선박이 어느 어구에서 제외중인지” 목록 관리
- 운영자 관리 화면
### 7. active label tracking 조회
`GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE`
`GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking`
### 8. 기존 review/detail API 확장
기존 `GroupParentInferenceDto`에 아래 요약을 추가한다.
- `activeLabelSession`
- `groupExclusionCount`
- `hasGlobalExclusionCandidate`
- `availableActions`
`ParentInferenceCandidateDto`에는 아래를 추가한다.
- `isExcludedInGroup`
- `isExcludedGlobally`
- `activeExclusionIds`
## 프론트엔드 설계
### 버튼 재구성
현재:
- `확정`
- `24시간 제외`
v2:
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
- `해제`
### 기간 선택
`정답 라벨``이 그룹에서 제외`는 버튼 클릭 후 아래 중 하나를 고르게 한다.
- `1일`
- `3일`
- `5일`
### 우측 모선 검토 패널 변화
- 후보 카드 상단 action area를 아래처럼 재구성
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
- 현재 후보에 active exclusion이 있으면 badge 표시
- `이 그룹 제외 중`
- `전체 후보 제외 중`
- 현재 그룹에 active label session이 있으면 summary box 표시
- 라벨 MMSI
- 남은 기간
- 최근 top1 일치율
### 새 목록
- `검토 대기`
- active label session이 없는 그룹만 기본 표시
- `라벨 추적`
- active label session이 있는 그룹
- `제외 대상 관리`
- active group/global exclusions
### 지도 표시 원칙
- active label session 그룹은 기본 review 색과 다른 badge 색을 사용
- globally excluded candidate는 raw correlation 패널에서는 참고로 보일 수 있지만, parent-review actionable candidate 목록에서는 숨김
## 지표 설계
정답 라벨 세션을 기반으로 최소 아래 지표를 계산한다.
### 핵심 지표
- top1 exact match rate
- top3 hit rate
- labeled candidate mean rank
- labeled candidate mean score
- time-to-first-top1
- session duration 동안 top1 일치 지속률
### 보정/실험 지표
- `412/413` 가산점 적용 전후 top1/top3 uplift
- pre-bonus score 대비 final score uplift
- global exclusion 적용 전후 오탐 감소량
- group exclusion 이후 대체 top1 품질 변화
### 운영 준비 지표
- auto-promoted 후보 중 라벨과 일치하는 비율
- high-confidence (`>= 0.72`) 구간 calibration
- label session 종료 시점 기준 `실무 참고 가능` threshold
## 단계별 구현 순서
### Phase 1. DB/Backend 계약
- 마이그레이션 추가
- `gear_parent_candidate_exclusions`
- `gear_parent_label_sessions`
- `gear_parent_label_tracking_cycles`
- backend DTO/API 추가
- 기존 `CONFIRM/REJECT/RESET`는 lab UI에서 숨기고 legacy로만 남김
### Phase 2. prediction 연동
- active exclusion load
- candidate pruning
- active label session load
- tracking cycle write
### Phase 3. 프론트 UI 전환
- 버튼 재구성
- 기간 선택 UI
- 라벨 추적 목록
- 제외 대상 관리 화면
### Phase 4. 지표와 리포트
- label session summary endpoint
- exclusion usage summary endpoint
- 실험 리포트 화면 또는 문서 산출
## 마이그레이션 전략
### 기존 v1 상태 처리
- `MANUAL_CONFIRMED`, `MANUAL_REJECT`는 새로 생성하지 않는다.
- 기존 row는 history로 남긴다.
- 필요하면 one-time migration으로 legacy `MANUAL_CONFIRMED``expired label session`으로 변환할 수 있다.
### 운영 영향 제한
- v2는 우선 `kcg_lab`에만 적용
- 운영 `kcg` 반영 전에는 사람이 직접 누르는 흐름과 tracking 지표가 충분히 쌓여야 함
## 수용 기준
### 기능 기준
- 그룹 제외가 다음 cycle부터 해당 그룹에서만 적용된다.
- 전역 후보 제외가 다음 cycle부터 모든 그룹에 적용된다.
- active exclusion list가 DB/API/UI에서 동일하게 보인다.
- 정답 라벨 세션 동안 cycle별 tracking row가 누락 없이 쌓인다.
### 데이터 기준
- label session당 최소 아래 값이 저장된다.
- top1 후보
- labeled candidate rank
- labeled candidate score
- candidate count
- observed_at
- exclusion row에는 scope, duration, actor, comment, active 기간이 남는다.
### 평가 기준
- `412/413` 가산점, threshold, exclusion 정책 변경 전후를 label session 데이터로 비교 가능해야 한다.
- 일정 기간 후 “자동 top1을 운영 참고값으로 써도 되는지”를 정량으로 판단할 수 있어야 한다.
## 열린 이슈
### 1. 그룹 스코프 안정성
`group_key + sub_cluster_id`가 며칠 동안 완전히 안정적인지 추가 확인이 필요하다.
현재 권장:
- phase 1은 기존 키를 그대로 사용
- 대신 `anchor_snapshot_time`, `anchor_center_point`, `anchor_member_mmsis`를 저장
### 2. 전역 후보 제외의 기간 정책
현재 제안은 “수동 해제 전까지 유지”다.
이유:
- 전역 제외는 단기 오답보다 “이 AIS는 parent candidate class가 아님”에 가깝다.
필요 시 추후 `1/3/5일` 옵션을 추가할 수 있다.
### 3. raw correlation UI 노출
전역 제외된 후보를 모델 패널에서 완전히 숨길지, `참고 제외` badge만 붙여 남길지는 사용성 확인이 필요하다.
현재 권장은 아래다.
- parent-review actionable 후보 목록에서는 숨김
- raw model/correlation 참고 패널에서는 badge와 함께 유지
## 권장 결론
v2의 핵심은 `사람 판단을 자동 추론의 override가 아니라 평가 데이터로 축적하는 것`이다.
따라서 다음 구현 우선순위는 아래가 맞다.
1. exclusion/label DB 추가
2. prediction candidate gating + tracking write
3. UI 액션 재정의
4. 지표 산출
그 다음 단계에서만 threshold 자동화, 가산점 조정, LLM 연결을 검토하는 것이 안전하다.

파일 보기

@ -4,20 +4,35 @@
## [Unreleased] ## [Unreleased]
## [2026-04-01.2] ## [2026-04-04]
### 추가 ### 추가
- 한국 현황 위성지도/ENC 토글 (gcnautical 벡터 타일 연동) - 어구 모선 추론(Gear Parent Inference) 시스템 — 다층 점수 모델 + Episode 연속성 + 자동 승격/검토 워크플로우
- ENC 스타일 설정 패널 (12개 심볼 토글 + 8개 색상 수정 + 초기화) - Python: gear_parent_inference(1,428줄), gear_parent_episode(631줄), gear_name_rules
- Backend: ParentInferenceWorkflowController + GroupPolygonService 15개 API
- Frontend: ParentReviewPanel (모선 검토 대시보드) + React Flow 흐름도 시각화
- DB: migration 012~015 (후보 스냅샷, resolution, episode, 라벨 세션, 제외 관리)
- LoginPage DEV_LOGIN 환경변수 지원 (VITE_ENABLE_DEV_LOGIN)
### 수정 ### 수정
- 라이브 어구 현황에서 fallback 그룹 제외 (1h-fb resolution 분리) - 모선 검토 대기 목록을 폴리곤 5분 폴링 데이터에서 파생하여 동기화 문제 해소
- FLEET 타입 resolution='1h' 누락 수정 - 후보 소스 배지 축약 (CORRELATION→CORR, PREVIOUS_SELECTION→PREV 등)
- DB resolution 컬럼 VARCHAR(4)→VARCHAR(8) 확장 - 1h 활성 판정을 parent_name 전체 합산 기준으로 변경
- vessel_store의 _last_bucket 타임존 오류 수정 (tz-naive KST 유지)
- time_bucket 수집 안전 윈도우 도입 — safe_bucket(12분 지연) + 3 bucket 백필
- 모선 추론 점수 가중치 조정 — 100%는 DIRECT_PARENT_MATCH 전용
- prediction proxy target을 nginx 경유로 변경
### 변경
- fleet_tracker: SQL 테이블명 qualified_table() 동적화 + is_trackable_parent_name 필터
- gear_correlation: 후보 track에 timestamp 필드 추가
- kcgdb: SQL 스키마 하드코딩 → qualified_table() 패턴 전환
## [2026-04-01] ## [2026-04-01]
### 추가 ### 추가
- 한국 현황 위성지도/ENC 토글 (gcnautical 벡터 타일 연동)
- ENC 스타일 설정 패널 (12개 심볼 토글 + 8개 색상 수정 + 초기화)
- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더) - 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더)
- 리플레이 컨트롤러 A-B 구간 반복 기능 - 리플레이 컨트롤러 A-B 구간 반복 기능
- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정) - 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정)
@ -42,6 +57,9 @@
- 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정 - 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정
### 수정 ### 수정
- 라이브 어구 현황에서 fallback 그룹 제외 (1h-fb resolution 분리)
- FLEET 타입 resolution='1h' 누락 수정
- DB resolution 컬럼 VARCHAR(4)→VARCHAR(8) 확장
- 어구 group_key 변동 → 이력 불연속 문제 해결 (sub_cluster_id 구조 전환) - 어구 group_key 변동 → 이력 불연속 문제 해결 (sub_cluster_id 구조 전환)
- 한국 국적 선박(440/441) 어구 오탐 제외 - 한국 국적 선박(440/441) 어구 오탐 제외
- Backend correlation API 서브클러스터 중복 제거 (DISTINCT ON CTE) - Backend correlation API 서브클러스터 중복 제거 (DISTINCT ON CTE)
@ -97,6 +115,9 @@
- 현장분석 항적 미니맵: 선박 클릭 시 72시간 항적 + 현재 위치 표시 - 현장분석 항적 미니맵: 선박 클릭 시 72시간 항적 + 현재 위치 표시
- 현장분석 위험도 점수 기준 섹션 - 현장분석 위험도 점수 기준 섹션
- Python 경량 분석: 파이프라인 미통과 412* 선박 간이 위험도 - Python 경량 분석: 파이프라인 미통과 412* 선박 간이 위험도
- 폴리곤 히스토리 애니메이션: 12시간 타임라인 기반 재생 (중심 궤적 + 어구별 궤적 + 가상 아이콘)
- 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭) + 신호없음 구간 표시
- nginx /api/gtts 프록시 (Google TTS CORS 우회)
### 변경 ### 변경
- 위험도 용어 통일: HIGH→WATCH, MEDIUM→MONITOR, LOW→NORMAL (전체) - 위험도 용어 통일: HIGH→WATCH, MEDIUM→MONITOR, LOW→NORMAL (전체)
@ -104,15 +125,6 @@
- 보고서: Python riskCounts 실데이터 기반 위험 평가 - 보고서: Python riskCounts 실데이터 기반 위험 평가
- 현장분석: AI 파이프라인 ON/OFF 실상태 + BD-09 실측 탐지 수 - 현장분석: AI 파이프라인 ON/OFF 실상태 + BD-09 실측 탐지 수
- 보고서 버튼: 현장분석 내부로 이동, 수역별 허가업종 동적 참조 - 보고서 버튼: 현장분석 내부로 이동, 수역별 허가업종 동적 참조
## [2026-03-25]
### 추가
- 폴리곤 히스토리 애니메이션: 12시간 타임라인 기반 재생 (중심 궤적 + 어구별 궤적 + 가상 아이콘)
- 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭) + 신호없음 구간 표시
- nginx /api/gtts 프록시 (Google TTS CORS 우회)
### 변경
- 분석 파이프라인: MIN_TRAJ_POINTS 100→20 (16척→684척 분석 대상 확대) - 분석 파이프라인: MIN_TRAJ_POINTS 100→20 (16척→684척 분석 대상 확대)
- risk.py: SOG 급변 count 위험도 점수 반영 - risk.py: SOG 급변 count 위험도 점수 반영
- spoofing.py: BD09 오프셋 중국 MMSI(412*) 예외 처리 - spoofing.py: BD09 오프셋 중국 MMSI(412*) 예외 처리

파일 보기

@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/kcg.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gear-parent-flow-viewer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/gearParentFlowMain.tsx"></script>
</body>
</html>

파일 보기

@ -19,6 +19,7 @@
"@turf/boolean-point-in-polygon": "^7.3.4", "@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4", "@turf/helpers": "^7.3.4",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@xyflow/react": "^12.10.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"i18next": "^25.8.18", "i18next": "^25.8.18",
@ -383,6 +384,7 @@
"resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.11.tgz", "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.11.tgz",
"integrity": "sha512-2FSb0Qa6YR+Rg6GWhYOGTUug3vtZ4uKcFdnrdiJoVXGyibKJMScKZIsivY0r/yQQZsaBjYqty5QuVJvdtEHxSA==", "integrity": "sha512-2FSb0Qa6YR+Rg6GWhYOGTUug3vtZ4uKcFdnrdiJoVXGyibKJMScKZIsivY0r/yQQZsaBjYqty5QuVJvdtEHxSA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@loaders.gl/images": "~4.3.4", "@loaders.gl/images": "~4.3.4",
"@loaders.gl/schema": "~4.3.4", "@loaders.gl/schema": "~4.3.4",
@ -2512,6 +2514,15 @@
"version": "3.1.3", "version": "3.1.3",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-ease": { "node_modules/@types/d3-ease": {
"version": "3.0.2", "version": "3.0.2",
"license": "MIT" "license": "MIT"
@ -2534,6 +2545,12 @@
"@types/d3-time": "*" "@types/d3-time": "*"
} }
}, },
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"license": "MIT", "license": "MIT",
@ -2549,6 +2566,25 @@
"version": "3.0.2", "version": "3.0.2",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"license": "MIT" "license": "MIT"
@ -2951,6 +2987,66 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/a5-js": { "node_modules/a5-js": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz",
@ -3192,6 +3288,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"license": "MIT", "license": "MIT",
@ -3288,6 +3390,28 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": { "node_modules/d3-ease": {
"version": "3.0.1", "version": "3.0.1",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
@ -3333,6 +3457,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"license": "ISC", "license": "ISC",
@ -3370,6 +3504,41 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.1.0",
"license": "MIT", "license": "MIT",

파일 보기

@ -21,6 +21,7 @@
"@turf/boolean-point-in-polygon": "^7.3.4", "@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4", "@turf/helpers": "^7.3.4",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@xyflow/react": "^12.10.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"i18next": "^25.8.18", "i18next": "^25.8.18",

파일 보기

@ -106,6 +106,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
> >
MON MON
</button> </button>
<a
className="header-toggle-btn"
href="/gear-parent-flow.html"
target="_blank"
rel="noreferrer"
title="어구 모선 추적 흐름도"
>
FLOW
</a>
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language"> <button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
{i18n.language === 'ko' ? 'KO' : 'EN'} {i18n.language === 'ko' ? 'KO' : 'EN'}
</button> </button>

파일 보기

@ -8,6 +8,7 @@ interface LoginPageProps {
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const IS_DEV = import.meta.env.DEV; const IS_DEV = import.meta.env.DEV;
const DEV_LOGIN_ENABLED = IS_DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
function useGoogleIdentity(onCredential: (credential: string) => void) { function useGoogleIdentity(onCredential: (credential: string) => void) {
const btnRef = useRef<HTMLDivElement>(null); const btnRef = useRef<HTMLDivElement>(null);
@ -136,7 +137,7 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
)} )}
{/* Dev Login */} {/* Dev Login */}
{IS_DEV && ( {DEV_LOGIN_ENABLED && (
<> <>
<div className="w-full border-t border-kcg-border pt-4 text-center"> <div className="w-full border-t border-kcg-border pt-4 text-center">
<span className="text-xs font-mono tracking-wider text-kcg-dim"> <span className="text-xs font-mono tracking-wider text-kcg-dim">

파일 보기

@ -6,6 +6,8 @@ import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants'; import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useTranslation } from 'react-i18next';
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
interface CorrelationPanelProps { interface CorrelationPanelProps {
selectedGearGroup: string; selectedGearGroup: string;
@ -17,6 +19,8 @@ interface CorrelationPanelProps {
enabledVessels: Set<string>; enabledVessels: Set<string>;
correlationLoading: boolean; correlationLoading: boolean;
hoveredTarget: { mmsi: string; model: string } | null; hoveredTarget: { mmsi: string; model: string } | null;
hasRightReviewPanel?: boolean;
reviewDriven?: boolean;
onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void; onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void; onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void; onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void;
@ -35,11 +39,19 @@ const CorrelationPanel = ({
enabledVessels, enabledVessels,
correlationLoading, correlationLoading,
hoveredTarget, hoveredTarget,
hasRightReviewPanel = false,
reviewDriven = false,
onEnabledModelsChange, onEnabledModelsChange,
onEnabledVesselsChange, onEnabledVesselsChange,
onHoveredTargetChange, onHoveredTargetChange,
}: CorrelationPanelProps) => { }: CorrelationPanelProps) => {
const { t } = useTranslation();
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0); const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
const layout = useReplayCenterPanelLayout({
minWidth: 252,
maxWidth: 966,
hasRightReviewPanel,
});
// Local tooltip state // Local tooltip state
const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null); const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null);
@ -193,16 +205,30 @@ const CorrelationPanel = ({
key={`${modelName}-${c.targetMmsi}`} key={`${modelName}-${c.targetMmsi}`}
style={{ style={{
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3, fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 2px', borderRadius: 2, cursor: 'pointer', padding: '1px 2px', borderRadius: 2, cursor: reviewDriven ? 'default' : 'pointer',
background: isHovered ? `${color}22` : 'transparent', background: isHovered ? `${color}22` : 'transparent',
opacity: isEnabled ? 1 : 0.5, opacity: reviewDriven ? 1 : isEnabled ? 1 : 0.5,
}} }}
onClick={() => toggleVessel(c.targetMmsi)} onClick={reviewDriven ? undefined : () => toggleVessel(c.targetMmsi)}
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })} onMouseEnter={reviewDriven ? undefined : () => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
onMouseLeave={() => onHoveredTargetChange(null)} onMouseLeave={reviewDriven ? undefined : () => onHoveredTargetChange(null)}
> >
{reviewDriven ? (
<span
title={t('parentInference.reference.reviewDriven')}
style={{
width: 9,
height: 9,
borderRadius: 999,
background: color,
flexShrink: 0,
opacity: 0.9,
}}
/>
) : (
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시" <input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} /> style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
)}
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}> <span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
{isVessel ? '⛴' : '◆'} {isVessel ? '⛴' : '◆'}
</span> </span>
@ -219,6 +245,15 @@ const CorrelationPanel = ({
); );
}; };
const visibleModelNames = useMemo(() => {
if (reviewDriven) {
return availableModels
.filter(model => (correlationByModel.get(model.name) ?? []).length > 0)
.map(model => model.name);
}
return availableModels.filter(model => enabledModels.has(model.name)).map(model => model.name);
}, [availableModels, correlationByModel, enabledModels, reviewDriven]);
// Member row renderer (identity model — no score, independent hover) // Member row renderer (identity model — no score, independent hover)
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => { const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => {
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity'; const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
@ -251,10 +286,8 @@ const CorrelationPanel = ({
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
bottom: historyActive ? 120 : 20, bottom: historyActive ? 120 : 20,
left: 'calc(50% + 100px)', left: `${layout.left}px`,
transform: 'translateX(-50%)', width: `${layout.width}px`,
width: 'calc(100vw - 880px)',
maxWidth: 1320,
display: 'flex', display: 'flex',
gap: 6, gap: 6,
alignItems: 'flex-end', alignItems: 'flex-end',
@ -270,6 +303,7 @@ const CorrelationPanel = ({
border: '1px solid rgba(249,115,22,0.3)', border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8, borderRadius: 8,
padding: '8px 10px', padding: '8px 10px',
width: 165,
minWidth: 165, minWidth: 165,
flexShrink: 0, flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
@ -278,6 +312,22 @@ const CorrelationPanel = ({
<span style={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span> <span style={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span>
<span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}</span> <span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}</span>
</div> </div>
<div style={{
marginBottom: 7,
padding: '6px 7px',
borderRadius: 6,
background: 'rgba(15,23,42,0.72)',
border: '1px solid rgba(249,115,22,0.14)',
color: '#cbd5e1',
fontSize: 8,
lineHeight: 1.45,
whiteSpace: 'normal',
wordBreak: 'keep-all',
}}>
{reviewDriven
? t('parentInference.reference.reviewDriven')
: t('parentInference.reference.shipOnly')}
</div>
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}> </div> <div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
<input <input
@ -300,7 +350,10 @@ const CorrelationPanel = ({
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length; const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
const am = availableModels.find(m => m.name === mn); const am = availableModels.find(m => m.name === mn);
return ( return (
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}> <label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: reviewDriven ? 'default' : hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
{reviewDriven ? (
<span style={{ width: 11, height: 11, borderRadius: 999, background: hasData ? color : 'rgba(148,163,184,0.2)', flexShrink: 0 }} />
) : (
<input type="checkbox" checked={enabledModels.has(mn)} <input type="checkbox" checked={enabledModels.has(mn)}
disabled={!hasData} disabled={!hasData}
onChange={() => onEnabledModelsChange(prev => { onChange={() => onEnabledModelsChange(prev => {
@ -309,6 +362,7 @@ const CorrelationPanel = ({
return next; return next;
})} })}
style={{ accentColor: color, width: 11, height: 11 }} title={mn} /> style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
)}
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} /> <span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} />
<span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span> <span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}${gc}` : '—'}</span> <span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}${gc}` : '—'}</span>
@ -324,7 +378,7 @@ const CorrelationPanel = ({
}}> }}>
{/* 이름 기반 카드 (체크 시) */} {/* 이름 기반 카드 (체크 시) */}
{enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && ( {(reviewDriven || enabledModels.has('identity')) && (identityVessels.length > 0 || identityGear.length > 0) && (
<div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}> <div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
<div style={getCardBodyStyle('identity')}> <div style={getCardBodyStyle('identity')}>
{identityVessels.length > 0 && ( {identityVessels.length > 0 && (
@ -335,7 +389,9 @@ const CorrelationPanel = ({
)} )}
{identityGear.length > 0 && ( {identityGear.length > 0 && (
<> <>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({identityGear.length})</div> <div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
{t('parentInference.reference.referenceGear')} ({identityGear.length})
</div>
{identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))} {identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
</> </>
)} )}
@ -355,7 +411,9 @@ const CorrelationPanel = ({
)} )}
{/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */} {/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */}
{availableModels.filter(m => enabledModels.has(m.name)).map(m => { {visibleModelNames.map(modelName => {
const m = availableModels.find(model => model.name === modelName);
if (!m) return null;
const color = MODEL_COLORS[m.name] ?? '#94a3b8'; const color = MODEL_COLORS[m.name] ?? '#94a3b8';
const items = correlationByModel.get(m.name) ?? []; const items = correlationByModel.get(m.name) ?? [];
const vessels = items.filter(c => c.targetType === 'VESSEL'); const vessels = items.filter(c => c.targetType === 'VESSEL');
@ -372,7 +430,9 @@ const CorrelationPanel = ({
)} )}
{gears.length > 0 && ( {gears.length > 0 && (
<> <>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({gears.length})</div> <div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
{t('parentInference.reference.referenceGear')} ({gears.length})
</div>
{gears.map(c => renderRow(c, color, m.name))} {gears.map(c => renderRow(c, color, m.name))}
</> </>
)} )}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -4,6 +4,7 @@ import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type { FleetListItem } from './fleetClusterTypes'; import type { FleetListItem } from './fleetClusterTypes';
import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants'; import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import GearGroupSection from './GearGroupSection'; import GearGroupSection from './GearGroupSection';
import { useTranslation } from 'react-i18next';
interface FleetGearListPanelProps { interface FleetGearListPanelProps {
fleetList: FleetListItem[]; fleetList: FleetListItem[];
@ -42,14 +43,15 @@ const FleetGearListPanel = ({
onExpandGearGroup, onExpandGearGroup,
onShipSelect, onShipSelect,
}: FleetGearListPanelProps) => { }: FleetGearListPanelProps) => {
const { t } = useTranslation();
return ( return (
<div style={panelStyle}> <div style={panelStyle}>
{/* ── 선단 현황 섹션 ── */} {/* ── 선단 현황 섹션 ── */}
<div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}> <div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}> <span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
({fleetList.length}) {t('fleetGear.fleetSection', { count: fleetList.length })}
</span> </span>
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기"> <button type="button" style={toggleButtonStyle} aria-label={t('fleetGear.toggleFleetSection')}>
{activeSection === 'fleet' ? '▲' : '▼'} {activeSection === 'fleet' ? '▲' : '▼'}
</button> </button>
</div> </div>
@ -57,12 +59,12 @@ const FleetGearListPanel = ({
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}> <div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{fleetList.length === 0 ? ( {fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}> <div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
{t('fleetGear.emptyFleet')}
</div> </div>
) : ( ) : (
fleetList.map(({ id, mmsiList, label, color, members }) => { fleetList.map(({ id, mmsiList, label, color, members }) => {
const company = companies.get(id); const company = companies.get(id);
const companyName = company?.nameCn ?? label ?? `선단 #${id}`; const companyName = company?.nameCn ?? label ?? t('fleetGear.fleetFallback', { id });
const isOpen = expandedFleet === id; const isOpen = expandedFleet === id;
const isHovered = hoveredFleetId === id; const isHovered = hoveredFleetId === id;
@ -95,17 +97,19 @@ const FleetGearListPanel = ({
title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}> title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}>
{companyName} {companyName}
</span> </span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({mmsiList.length})</span> <span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
{t('fleetGear.vesselCountCompact', { count: mmsiList.length })}
</span>
<button type="button" onClick={e => { e.stopPropagation(); onFleetZoom(id); }} <button type="button" onClick={e => { e.stopPropagation(); onFleetZoom(id); }}
style={{ background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 3, color: '#63b3ed', fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }} style={{ background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 3, color: '#63b3ed', fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }}
title="이 선단으로 지도 이동"> title={t('fleetGear.moveToFleet')}>
zoom {t('fleetGear.zoom')}
</button> </button>
</div> </div>
{isOpen && ( {isOpen && (
<div style={{ paddingLeft: 22, paddingRight: 10, paddingBottom: 6, fontSize: 10, color: '#94a3b8', borderLeft: `2px solid ${color}33`, marginLeft: 10 }}> <div style={{ paddingLeft: 22, paddingRight: 10, paddingBottom: 6, fontSize: 10, color: '#94a3b8', borderLeft: `2px solid ${color}33`, marginLeft: 10 }}>
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>:</div> <div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>{t('fleetGear.shipList')}:</div>
{displayMembers.map(m => { {displayMembers.map(m => {
const dto = analysisMap.get(m.mmsi); const dto = analysisMap.get(m.mmsi);
const role = dto?.algorithms.fleetRole.role ?? m.role; const role = dto?.algorithms.fleetRole.role ?? m.role;
@ -116,11 +120,11 @@ const FleetGearListPanel = ({
{displayName} {displayName}
</span> </span>
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}> <span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
({role === 'LEADER' ? 'MAIN' : 'SUB'}) ({role === 'LEADER' ? t('fleetGear.roleMain') : t('fleetGear.roleSub')})
</span> </span>
<button type="button" onClick={() => onShipSelect(m.mmsi)} <button type="button" onClick={() => onShipSelect(m.mmsi)}
style={{ background: 'none', border: 'none', color: '#63b3ed', fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }} style={{ background: 'none', border: 'none', color: '#63b3ed', fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }}
title="선박으로 이동" aria-label={`${displayName} 선박으로 이동`}> title={t('fleetGear.moveToShip')} aria-label={t('fleetGear.moveToShipItem', { name: displayName })}>
</button> </button>
</div> </div>
@ -139,7 +143,7 @@ const FleetGearListPanel = ({
<GearGroupSection <GearGroupSection
groups={inZoneGearGroups} groups={inZoneGearGroups}
sectionKey="inZone" sectionKey="inZone"
sectionLabel={`조업구역내 어구 (${inZoneGearGroups.length}개)`} sectionLabel={t('fleetGear.inZoneSection', { count: inZoneGearGroups.length })}
accentColor="#dc2626" accentColor="#dc2626"
hoverBgColor="rgba(220,38,38,0.06)" hoverBgColor="rgba(220,38,38,0.06)"
isActive={activeSection === 'inZone'} isActive={activeSection === 'inZone'}
@ -154,7 +158,7 @@ const FleetGearListPanel = ({
<GearGroupSection <GearGroupSection
groups={outZoneGearGroups} groups={outZoneGearGroups}
sectionKey="outZone" sectionKey="outZone"
sectionLabel={`비허가 어구 (${outZoneGearGroups.length}개)`} sectionLabel={t('fleetGear.outZoneSection', { count: outZoneGearGroups.length })}
accentColor="#f97316" accentColor="#f97316"
hoverBgColor="rgba(255,255,255,0.04)" hoverBgColor="rgba(255,255,255,0.04)"
isActive={activeSection === 'outZone'} isActive={activeSection === 'outZone'}

파일 보기

@ -1,6 +1,7 @@
import type { GroupPolygonDto } from '../../services/vesselAnalysis'; import type { GroupPolygonDto } from '../../services/vesselAnalysis';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import { headerStyle, toggleButtonStyle } from './fleetClusterConstants'; import { headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import { useTranslation } from 'react-i18next';
interface GearGroupSectionProps { interface GearGroupSectionProps {
groups: GroupPolygonDto[]; groups: GroupPolygonDto[];
@ -29,8 +30,47 @@ const GearGroupSection = ({
onGroupZoom, onGroupZoom,
onShipSelect, onShipSelect,
}: GearGroupSectionProps) => { }: GearGroupSectionProps) => {
const { t } = useTranslation();
const isInZoneSection = sectionKey === 'inZone'; const isInZoneSection = sectionKey === 'inZone';
const getInferenceBadge = (status: string | null | undefined) => {
switch (status) {
case 'AUTO_PROMOTED':
return { label: t('parentInference.badges.AUTO_PROMOTED'), color: '#22c55e' };
case 'MANUAL_CONFIRMED':
return { label: t('parentInference.badges.MANUAL_CONFIRMED'), color: '#38bdf8' };
case 'DIRECT_PARENT_MATCH':
return { label: t('parentInference.badges.DIRECT_PARENT_MATCH'), color: '#2dd4bf' };
case 'REVIEW_REQUIRED':
return { label: t('parentInference.badges.REVIEW_REQUIRED'), color: '#f59e0b' };
case 'SKIPPED_SHORT_NAME':
return { label: t('parentInference.badges.SKIPPED_SHORT_NAME'), color: '#94a3b8' };
case 'NO_CANDIDATE':
return { label: t('parentInference.badges.NO_CANDIDATE'), color: '#c084fc' };
case 'UNRESOLVED':
return { label: t('parentInference.badges.UNRESOLVED'), color: '#64748b' };
default:
return null;
}
};
const getInferenceStatusLabel = (status: string | null | undefined) => {
if (!status) return '';
return t(`parentInference.status.${status}`, { defaultValue: status });
};
const getInferenceReason = (inference: GroupPolygonDto['parentInference']) => {
if (!inference) return '';
switch (inference.status) {
case 'SKIPPED_SHORT_NAME':
return t('parentInference.reasons.shortName');
case 'NO_CANDIDATE':
return t('parentInference.reasons.noCandidate');
default:
return inference.statusReason || inference.skipReason || '';
}
};
return ( return (
<> <>
<div <div
@ -42,7 +82,7 @@ const GearGroupSection = ({
onClick={onToggleSection} onClick={onToggleSection}
> >
<span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}> <span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}>
{sectionLabel} ({groups.length}) {sectionLabel}
</span> </span>
<button <button
type="button" type="button"
@ -61,6 +101,8 @@ const GearGroupSection = ({
const parentMember = g.members.find(m => m.isParent); const parentMember = g.members.find(m => m.isParent);
const gearMembers = g.members.filter(m => !m.isParent); const gearMembers = g.members.filter(m => !m.isParent);
const zoneName = g.zoneName ?? ''; const zoneName = g.zoneName ?? '';
const inference = g.parentInference ?? null;
const badge = getInferenceBadge(inference?.status);
return ( return (
<div key={name} id={`gear-row-${name}`}> <div key={name} id={`gear-row-${name}`}>
@ -117,6 +159,25 @@ const GearGroupSection = ({
</span> </span>
)} )}
{badge && (
<span
style={{
color: badge.color,
border: `1px solid ${badge.color}55`,
borderRadius: 3,
padding: '0 4px',
fontSize: 8,
flexShrink: 0,
}}
title={
inference?.selectedParentName
? `${getInferenceStatusLabel(inference.status)}: ${inference.selectedParentName}`
: getInferenceReason(inference) || getInferenceStatusLabel(inference?.status) || ''
}
>
{badge.label}
</span>
)}
{isInZoneSection && zoneName && ( {isInZoneSection && zoneName && (
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span> <span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
)} )}
@ -139,9 +200,9 @@ const GearGroupSection = ({
padding: '1px 4px', padding: '1px 4px',
flexShrink: 0, flexShrink: 0,
}} }}
title="이 어구 그룹으로 지도 이동" title={t('fleetGear.moveToGroup')}
> >
zoom {t('fleetGear.zoom')}
</button> </button>
</div> </div>
@ -158,10 +219,17 @@ const GearGroupSection = ({
}}> }}>
{parentMember && ( {parentMember && (
<div style={{ color: '#fbbf24', marginBottom: 2 }}> <div style={{ color: '#fbbf24', marginBottom: 2 }}>
: {parentMember.name || parentMember.mmsi} {t('parentInference.summary.recommendedParent')}: {parentMember.name || parentMember.mmsi}
</div> </div>
)} )}
<div style={{ color: '#64748b', marginBottom: 2 }}> :</div> {inference && (
<div style={{ marginBottom: 4, color: inference.status === 'AUTO_PROMOTED' ? '#22c55e' : '#94a3b8' }}>
{t('parentInference.summary.label')}: {getInferenceStatusLabel(inference.status)}
{inference.selectedParentName ? ` / ${inference.selectedParentName}` : ''}
{getInferenceReason(inference) ? ` / ${getInferenceReason(inference)}` : ''}
</div>
)}
<div style={{ color: '#64748b', marginBottom: 2 }}>{t('fleetGear.gearList')}:</div>
{gearMembers.map(m => ( {gearMembers.map(m => (
<div key={m.mmsi} style={{ <div key={m.mmsi} style={{
display: 'flex', display: 'flex',
@ -190,8 +258,8 @@ const GearGroupSection = ({
padding: '0 2px', padding: '0 2px',
flexShrink: 0, flexShrink: 0,
}} }}
title="어구 위치로 이동" title={t('fleetGear.moveToGear')}
aria-label={`${m.name || m.mmsi} 위치로 이동`} aria-label={t('fleetGear.moveToGearItem', { name: m.name || m.mmsi })}
> >
</button> </button>

파일 보기

@ -1,16 +1,40 @@
import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { MODEL_COLORS } from './fleetClusterConstants'; import { MODEL_COLORS } from './fleetClusterConstants';
import type { HistoryFrame } from './fleetClusterTypes'; import type { HistoryFrame } from './fleetClusterTypes';
import type { GearCorrelationItem } from '../../services/vesselAnalysis'; import type { GearCorrelationItem } from '../../services/vesselAnalysis';
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
interface HistoryReplayControllerProps { interface HistoryReplayControllerProps {
onClose: () => void; onClose: () => void;
onFilterByScore: (minPct: number | null) => void; hasRightReviewPanel?: boolean;
} }
const MIN_AB_GAP_MS = 2 * 3600_000; const MIN_AB_GAP_MS = 2 * 3600_000;
const BASE_PLAYBACK_SPEED = 0.5;
const SPEED_MULTIPLIERS = [1, 2, 5, 10] as const;
interface ReplayUiPrefs {
showTrails: boolean;
showLabels: boolean;
focusMode: boolean;
show1hPolygon: boolean;
show6hPolygon: boolean;
abLoop: boolean;
speedMultiplier: 1 | 2 | 5 | 10;
}
const DEFAULT_REPLAY_UI_PREFS: ReplayUiPrefs = {
showTrails: true,
showLabels: true,
focusMode: false,
show1hPolygon: true,
show6hPolygon: false,
abLoop: false,
speedMultiplier: 1,
};
// 멤버 정보 + 소속 모델 매핑 // 멤버 정보 + 소속 모델 매핑
interface TooltipMember { interface TooltipMember {
@ -70,7 +94,7 @@ function buildTooltipMembers(
return [...map.values()]; return [...map.values()];
} }
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => { const HistoryReplayController = ({ onClose, hasRightReviewPanel = false }: HistoryReplayControllerProps) => {
const isPlaying = useGearReplayStore(s => s.isPlaying); const isPlaying = useGearReplayStore(s => s.isPlaying);
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h); const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h);
@ -78,6 +102,9 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h); const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
const frameCount = historyFrames.length; const frameCount = historyFrames.length;
const frameCount6h = historyFrames6h.length; const frameCount6h = historyFrames6h.length;
const dataStartTime = useGearReplayStore(s => s.dataStartTime);
const dataEndTime = useGearReplayStore(s => s.dataEndTime);
const playbackSpeed = useGearReplayStore(s => s.playbackSpeed);
const showTrails = useGearReplayStore(s => s.showTrails); const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const focusMode = useGearReplayStore(s => s.focusMode); const focusMode = useGearReplayStore(s => s.focusMode);
@ -95,11 +122,15 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
const [hoveredTooltip, setHoveredTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null); const [hoveredTooltip, setHoveredTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
const [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null); const [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
const [dragging, setDragging] = useState<'A' | 'B' | null>(null); const [dragging, setDragging] = useState<'A' | 'B' | null>(null);
const [replayUiPrefs, setReplayUiPrefs] = useLocalStorage<ReplayUiPrefs>('gearReplayUiPrefs', DEFAULT_REPLAY_UI_PREFS);
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null); const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null); const timeDisplayRef = useRef<HTMLSpanElement>(null);
const store = useGearReplayStore; const store = useGearReplayStore;
const speedMultiplier = SPEED_MULTIPLIERS.includes(replayUiPrefs.speedMultiplier)
? replayUiPrefs.speedMultiplier
: 1;
// currentTime → 진행 인디케이터 // currentTime → 진행 인디케이터
useEffect(() => { useEffect(() => {
@ -123,6 +154,34 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
if (isPlaying) setPinnedTooltip(null); if (isPlaying) setPinnedTooltip(null);
}, [isPlaying]); }, [isPlaying]);
useEffect(() => {
const replayStore = store.getState();
replayStore.setShowTrails(replayUiPrefs.showTrails);
replayStore.setShowLabels(replayUiPrefs.showLabels);
replayStore.setFocusMode(replayUiPrefs.focusMode);
replayStore.setShow1hPolygon(replayUiPrefs.show1hPolygon);
replayStore.setShow6hPolygon(has6hData ? replayUiPrefs.show6hPolygon : false);
}, [
has6hData,
replayUiPrefs.focusMode,
replayUiPrefs.show1hPolygon,
replayUiPrefs.show6hPolygon,
replayUiPrefs.showLabels,
replayUiPrefs.showTrails,
store,
]);
useEffect(() => {
store.getState().setAbLoop(replayUiPrefs.abLoop);
}, [dataEndTime, dataStartTime, replayUiPrefs.abLoop, store]);
useEffect(() => {
const nextSpeed = BASE_PLAYBACK_SPEED * speedMultiplier;
if (Math.abs(playbackSpeed - nextSpeed) > 1e-9) {
store.getState().setPlaybackSpeed(nextSpeed);
}
}, [playbackSpeed, speedMultiplier, store]);
const posToProgress = useCallback((clientX: number) => { const posToProgress = useCallback((clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect(); const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return 0; if (!rect) return 0;
@ -258,13 +317,17 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
const btnActiveStyle: React.CSSProperties = { const btnActiveStyle: React.CSSProperties = {
...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd', ...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd',
}; };
const layout = useReplayCenterPanelLayout({
minWidth: 266,
maxWidth: 966,
hasRightReviewPanel,
});
return ( return (
<div style={{ <div style={{
position: 'absolute', bottom: 20, position: 'absolute', bottom: 20,
left: 'calc(50% + 100px)', transform: 'translateX(-50%)', left: `${layout.left}px`,
width: 'calc(100vw - 880px)', width: `${layout.width}px`,
minWidth: 380, maxWidth: 1320,
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)', background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4, borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0', zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
@ -452,38 +515,44 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button> style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span> <span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
<span style={{ color: '#475569' }}>|</span> <span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)} <button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showTrails: !prev.showTrails }))}
style={showTrails ? btnActiveStyle : btnStyle} title="항적"></button> style={showTrails ? btnActiveStyle : btnStyle} title="항적"></button>
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)} <button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showLabels: !prev.showLabels }))}
style={showLabels ? btnActiveStyle : btnStyle} title="이름"></button> style={showLabels ? btnActiveStyle : btnStyle} title="이름"></button>
<button type="button" onClick={() => store.getState().setFocusMode(!focusMode)} <button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, focusMode: !prev.focusMode }))}
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle} style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle}
title="집중 모드"></button> title="집중 모드"></button>
<span style={{ color: '#475569' }}>|</span> <span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => store.getState().setShow1hPolygon(!show1hPolygon)} <button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, show1hPolygon: !prev.show1hPolygon }))}
style={show1hPolygon ? { ...btnActiveStyle, background: 'rgba(251,191,36,0.15)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.4)' } : btnStyle} style={show1hPolygon ? { ...btnActiveStyle, background: 'rgba(251,191,36,0.15)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.4)' } : btnStyle}
title="1h 폴리곤">1h</button> title="1h 폴리곤">1h</button>
<button type="button" onClick={() => store.getState().setShow6hPolygon(!show6hPolygon)} <button type="button" onClick={() => has6hData && setReplayUiPrefs(prev => ({ ...prev, show6hPolygon: !prev.show6hPolygon }))}
style={!has6hData ? { ...btnStyle, opacity: 0.3, cursor: 'not-allowed' } style={!has6hData ? { ...btnStyle, opacity: 0.3, cursor: 'not-allowed' }
: show6hPolygon ? { ...btnActiveStyle, background: 'rgba(147,197,253,0.15)', color: '#93c5fd', border: '1px solid rgba(147,197,253,0.4)' } : btnStyle} : show6hPolygon ? { ...btnActiveStyle, background: 'rgba(147,197,253,0.15)', color: '#93c5fd', border: '1px solid rgba(147,197,253,0.4)' } : btnStyle}
disabled={!has6hData} title="6h 폴리곤">6h</button> disabled={!has6hData} title="6h 폴리곤">6h</button>
<span style={{ color: '#475569' }}>|</span> <span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => store.getState().setAbLoop(!abLoop)} <button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, abLoop: !prev.abLoop }))}
style={abLoop ? { ...btnStyle, background: 'rgba(34,197,94,0.15)', color: '#22c55e', border: '1px solid rgba(34,197,94,0.4)' } : btnStyle} style={abLoop ? { ...btnStyle, background: 'rgba(34,197,94,0.15)', color: '#22c55e', border: '1px solid rgba(34,197,94,0.4)' } : btnStyle}
title="A-B 구간 반복">A-B</button> title="A-B 구간 반복">A-B</button>
<span style={{ color: '#475569' }}>|</span> <span style={{ color: '#475569' }}>|</span>
<span style={{ color: '#64748b', fontSize: 9 }}></span> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<select defaultValue="70" {SPEED_MULTIPLIERS.map(multiplier => {
onChange={e => { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }} const active = speedMultiplier === multiplier;
style={{ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO, padding: '1px 4px', cursor: 'pointer' }} return (
title="일치율 필터" aria-label="일치율 필터"> <button
<option value=""> (30%+)</option> key={multiplier}
<option value="50">50%+</option> type="button"
<option value="60">60%+</option> onClick={() => setReplayUiPrefs(prev => ({ ...prev, speedMultiplier: multiplier }))}
<option value="70">70%+</option> style={active
<option value="80">80%+</option> ? { ...btnActiveStyle, background: 'rgba(250,204,21,0.16)', color: '#fde68a', border: '1px solid rgba(250,204,21,0.32)' }
<option value="90">90%+</option> : btnStyle}
</select> title={`재생 속도 x${multiplier}`}
>
x{multiplier}
</button>
);
})}
</div>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<span style={{ color: '#64748b', fontSize: 9 }}> <span style={{ color: '#64748b', fontSize: 9 }}>
<span style={{ color: '#fbbf24' }}>{frameCount}</span> <span style={{ color: '#fbbf24' }}>{frameCount}</span>

파일 보기

@ -241,11 +241,19 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
useEncMapSettings(maplibreRef, mapMode, encSettings, encSyncEpoch); useEncMapSettings(maplibreRef, mapMode, encSettings, encSyncEpoch);
const replayLayerRef = useRef<DeckLayer[]>([]); const replayLayerRef = useRef<DeckLayer[]>([]);
const fleetClusterLayerRef = useRef<DeckLayer[]>([]); const fleetClusterLayerRef = useRef<DeckLayer[]>([]);
const fleetMapClickHandlerRef = useRef<((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null>(null);
const fleetMapMoveHandlerRef = useRef<((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null>(null);
const requestRenderRef = useRef<(() => void) | null>(null); const requestRenderRef = useRef<(() => void) | null>(null);
const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => { const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => {
fleetClusterLayerRef.current = layers; fleetClusterLayerRef.current = layers;
requestRenderRef.current?.(); requestRenderRef.current?.();
}, []); }, []);
const registerFleetMapClickHandler = useCallback((handler: ((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null) => {
fleetMapClickHandlerRef.current = handler;
}, []);
const registerFleetMapMoveHandler = useCallback((handler: ((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null) => {
fleetMapMoveHandlerRef.current = handler;
}, []);
const [infra, setInfra] = useState<PowerFacility[]>([]); const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null); const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null); const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
@ -684,6 +692,24 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
mapStyle={activeMapStyle} mapStyle={activeMapStyle}
onZoom={handleZoom} onZoom={handleZoom}
onLoad={handleMapLoad} onLoad={handleMapLoad}
onClick={event => {
const handler = fleetMapClickHandlerRef.current;
if (handler) {
handler({
coordinate: [event.lngLat.lng, event.lngLat.lat],
screen: [event.point.x, event.point.y],
});
}
}}
onMouseMove={event => {
const handler = fleetMapMoveHandlerRef.current;
if (handler) {
handler({
coordinate: [event.lngLat.lng, event.lngLat.lat],
screen: [event.point.x, event.point.y],
});
}
}}
> >
<NavigationControl position="top-right" /> <NavigationControl position="top-right" />
@ -825,10 +851,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
groupPolygons={groupPolygons} groupPolygons={groupPolygons}
zoomScale={zoomScale} zoomScale={zoomScale}
onDeckLayersChange={handleFleetDeckLayers} onDeckLayersChange={handleFleetDeckLayers}
registerMapClickHandler={registerFleetMapClickHandler}
registerMapMoveHandler={registerFleetMapMoveHandler}
onShipSelect={handleAnalysisShipSelect} onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom} onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData} onSelectedGearChange={setSelectedGearData}
onSelectedFleetChange={setSelectedFleetData} onSelectedFleetChange={setSelectedFleetData}
autoOpenReviewPanel={koreaFilters.cnFishing}
/> />
)} )}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && ( {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -36,6 +36,9 @@ export interface HoverTooltipState {
lat: number; lat: number;
type: 'fleet' | 'gear'; type: 'fleet' | 'gear';
id: number | string; id: number | string;
groupKey?: string;
subClusterId?: number;
compositeKey?: string;
} }
export interface PickerCandidate { export interface PickerCandidate {

파일 보기

@ -0,0 +1,15 @@
export const MIN_PARENT_REVIEW_SCORE = 0.3;
export const MIN_PARENT_REVIEW_SCORE_PCT = 30;
export const MIN_PARENT_REVIEW_MEMBER_COUNT = 2;
export const REPLAY_COMPARE_PANEL_WIDTH_RATIO = 0.7;
export const KOREA_SIDE_PANEL_WIDTH = 300;
export const FLEET_LIST_PANEL_MAX_WIDTH = 300;
export const FLEET_LIST_PANEL_LEFT_OFFSET = 10;
export const ANALYSIS_PANEL_MAX_WIDTH = 280;
export const ANALYSIS_PANEL_RIGHT_OFFSET = 50;
export const REVIEW_PANEL_MAX_WIDTH = 560;
export const REVIEW_PANEL_RIGHT_OFFSET = 16;
export const REPLAY_CENTER_SAFE_GAP = 8;
export const REPLAY_LEFT_RESERVED_WIDTH = FLEET_LIST_PANEL_LEFT_OFFSET + FLEET_LIST_PANEL_MAX_WIDTH + REPLAY_CENTER_SAFE_GAP;
export const REPLAY_ANALYSIS_RESERVED_WIDTH = ANALYSIS_PANEL_MAX_WIDTH + ANALYSIS_PANEL_RIGHT_OFFSET + REPLAY_CENTER_SAFE_GAP;
export const REPLAY_REVIEW_RESERVED_WIDTH = REVIEW_PANEL_MAX_WIDTH + REVIEW_PANEL_RIGHT_OFFSET + REPLAY_CENTER_SAFE_GAP;

파일 보기

@ -0,0 +1,13 @@
const PARENT_REVIEW_CANDIDATE_COLORS = [
'#22d3ee',
'#f59e0b',
'#a78bfa',
'#34d399',
'#fb7185',
'#60a5fa',
] as const;
export function getParentReviewCandidateColor(rank: number): string {
const index = Math.max(0, (rank || 1) - 1) % PARENT_REVIEW_CANDIDATE_COLORS.length;
return PARENT_REVIEW_CANDIDATE_COLORS[index];
}

파일 보기

@ -13,7 +13,10 @@ export interface UseFleetClusterGeoJsonParams {
groupPolygons: UseGroupPolygonsResult | undefined; groupPolygons: UseGroupPolygonsResult | undefined;
analysisMap: Map<string, VesselAnalysisDto>; analysisMap: Map<string, VesselAnalysisDto>;
hoveredFleetId: number | null; hoveredFleetId: number | null;
hoveredGearCompositeKey?: string | null;
visibleGearCompositeKeys?: Set<string> | null;
selectedGearGroup: string | null; selectedGearGroup: string | null;
selectedGearCompositeKey?: string | null;
pickerHoveredGroup: string | null; pickerHoveredGroup: string | null;
historyActive: boolean; historyActive: boolean;
correlationData: GearCorrelationItem[]; correlationData: GearCorrelationItem[];
@ -32,6 +35,7 @@ export interface FleetClusterGeoJsonResult {
memberMarkersGeoJson: GeoJSON; memberMarkersGeoJson: GeoJSON;
pickerHighlightGeoJson: GeoJSON; pickerHighlightGeoJson: GeoJSON;
selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null; selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
hoveredGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
// correlation GeoJSON // correlation GeoJSON
correlationVesselGeoJson: GeoJSON; correlationVesselGeoJson: GeoJSON;
correlationTrailGeoJson: GeoJSON; correlationTrailGeoJson: GeoJSON;
@ -74,7 +78,10 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
shipMap, shipMap,
groupPolygons, groupPolygons,
hoveredFleetId, hoveredFleetId,
hoveredGearCompositeKey = null,
visibleGearCompositeKeys = null,
selectedGearGroup, selectedGearGroup,
selectedGearCompositeKey = null,
pickerHoveredGroup, pickerHoveredGroup,
historyActive, historyActive,
correlationData, correlationData,
@ -195,10 +202,15 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!groupPolygons) return { type: 'FeatureCollection', features }; if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) { for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) {
if (!g.polygon) continue; if (!g.polygon) continue;
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) continue;
features.push({ features.push({
type: 'Feature', type: 'Feature',
properties: { properties: {
name: g.groupKey, name: g.groupKey,
groupKey: g.groupKey,
subClusterId: g.subClusterId ?? 0,
compositeKey,
gearCount: g.memberCount, gearCount: g.memberCount,
inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0, inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
}, },
@ -206,7 +218,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
}); });
} }
return { type: 'FeatureCollection', features }; return { type: 'FeatureCollection', features };
}, [groupPolygons]); }, [groupPolygons, visibleGearCompositeKeys]);
// 가상 선박 마커 GeoJSON (API members + shipMap heading 보정) // 가상 선박 마커 GeoJSON (API members + shipMap heading 보정)
const memberMarkersGeoJson = useMemo((): GeoJSON => { const memberMarkersGeoJson = useMemo((): GeoJSON => {
@ -248,10 +260,12 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
} }
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316'; const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) continue;
for (const m of g.members) addMember(m, g.groupKey, g.groupType, color); for (const m of g.members) addMember(m, g.groupKey, g.groupType, color);
} }
return { type: 'FeatureCollection', features }; return { type: 'FeatureCollection', features };
}, [groupPolygons, shipMap]); }, [groupPolygons, shipMap, visibleGearCompositeKeys]);
// picker 호버 하이라이트 (선단 + 어구 통합) // picker 호버 하이라이트 (선단 + 어구 통합)
const pickerHighlightGeoJson = useMemo((): GeoJSON => { const pickerHighlightGeoJson = useMemo((): GeoJSON => {
@ -270,17 +284,47 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const allGroups = groupPolygons const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: []; : [];
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon); const matches = allGroups.filter(g => {
if (!g.polygon || g.groupKey !== selectedGearGroup) return false;
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
if (selectedGearCompositeKey && compositeKey !== selectedGearCompositeKey) return false;
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) return false;
return true;
});
if (matches.length === 0) return null; if (matches.length === 0) return null;
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
features: matches.map(g => ({ features: matches.map(g => ({
type: 'Feature' as const, type: 'Feature' as const,
properties: { subClusterId: g.subClusterId }, properties: {
subClusterId: g.subClusterId,
compositeKey: `${g.groupKey}:${g.subClusterId ?? 0}`,
},
geometry: g.polygon!, geometry: g.polygon!,
})), })),
}; };
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]); }, [selectedGearGroup, selectedGearCompositeKey, enabledModels, historyActive, groupPolygons, visibleGearCompositeKeys]);
const hoveredGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
if (!hoveredGearCompositeKey || !groupPolygons) return null;
const group = groupPolygons.allGroups.find(
item => item.groupType !== 'FLEET' && `${item.groupKey}:${item.subClusterId ?? 0}` === hoveredGearCompositeKey && item.polygon,
);
if (!group?.polygon) return null;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {
groupKey: group.groupKey,
subClusterId: group.subClusterId ?? 0,
compositeKey: hoveredGearCompositeKey,
inZone: group.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
},
geometry: group.polygon,
}],
};
}, [groupPolygons, hoveredGearCompositeKey]);
// ── 연관 대상 마커 (ships[] fallback) ── // ── 연관 대상 마커 (ships[] fallback) ──
const correlationVesselGeoJson = useMemo((): GeoJSON => { const correlationVesselGeoJson = useMemo((): GeoJSON => {
@ -416,6 +460,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
memberMarkersGeoJson, memberMarkersGeoJson,
pickerHighlightGeoJson, pickerHighlightGeoJson,
selectedGearHighlightGeoJson, selectedGearHighlightGeoJson,
hoveredGearHighlightGeoJson,
correlationVesselGeoJson, correlationVesselGeoJson,
correlationTrailGeoJson, correlationTrailGeoJson,
modelBadgesGeoJson, modelBadgesGeoJson,

파일 보기

@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from 'react';
import {
KOREA_SIDE_PANEL_WIDTH,
REPLAY_ANALYSIS_RESERVED_WIDTH,
REPLAY_COMPARE_PANEL_WIDTH_RATIO,
REPLAY_LEFT_RESERVED_WIDTH,
REPLAY_REVIEW_RESERVED_WIDTH,
} from './parentInferenceConstants';
interface ReplayCenterPanelLayoutOptions {
minWidth: number;
maxWidth: number;
hasRightReviewPanel?: boolean;
}
interface ReplayCenterPanelLayout {
left: number;
width: number;
}
const FALLBACK_VIEWPORT_WIDTH = 1920;
const ABSOLUTE_MIN_WIDTH = 180;
export function useReplayCenterPanelLayout({
minWidth,
maxWidth,
hasRightReviewPanel = false,
}: ReplayCenterPanelLayoutOptions): ReplayCenterPanelLayout {
const [viewportWidth, setViewportWidth] = useState(
() => (typeof window === 'undefined' ? FALLBACK_VIEWPORT_WIDTH : window.innerWidth),
);
useEffect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => {
setViewportWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return useMemo(() => {
const mapPanelWidth = Math.max(ABSOLUTE_MIN_WIDTH, viewportWidth - KOREA_SIDE_PANEL_WIDTH);
const leftReserved = REPLAY_LEFT_RESERVED_WIDTH;
const rightReserved = Math.max(
REPLAY_ANALYSIS_RESERVED_WIDTH,
hasRightReviewPanel ? REPLAY_REVIEW_RESERVED_WIDTH : 0,
);
const availableWidth = Math.max(ABSOLUTE_MIN_WIDTH, mapPanelWidth - leftReserved - rightReserved);
let width: number;
if (availableWidth >= maxWidth) {
width = maxWidth;
} else if (availableWidth <= minWidth) {
width = Math.max(ABSOLUTE_MIN_WIDTH, availableWidth);
} else {
width = Math.min(maxWidth, Math.max(minWidth, availableWidth * REPLAY_COMPARE_PANEL_WIDTH_RATIO));
}
const left = leftReserved + Math.max(0, (availableWidth - width) / 2);
return {
left,
width,
};
}, [hasRightReviewPanel, maxWidth, minWidth, viewportWidth]);
}

파일 보기

@ -0,0 +1,415 @@
.gear-flow-app {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(43, 108, 176, 0.16), transparent 28%),
radial-gradient(circle at bottom right, rgba(15, 118, 110, 0.14), transparent 24%),
#07111f;
color: #dce7f3;
}
.gear-flow-shell {
display: grid;
grid-template-columns: 332px minmax(880px, 1fr) 392px;
min-height: 100vh;
}
.gear-flow-sidebar,
.gear-flow-detail {
backdrop-filter: blur(18px);
background: rgba(7, 17, 31, 0.86);
border-color: rgba(148, 163, 184, 0.16);
}
.gear-flow-sidebar {
border-right-width: 1px;
}
.gear-flow-detail {
border-left-width: 1px;
}
.gear-flow-hero {
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.gear-flow-sidebar .gear-flow-hero,
.gear-flow-detail .gear-flow-hero {
padding-right: 1.75rem;
padding-left: 1.75rem;
}
.gear-flow-panel-heading {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gear-flow-panel-kicker {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #94a3b8;
}
.gear-flow-panel-title {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.22;
color: #f8fafc;
}
.gear-flow-panel-description {
margin: 0;
font-size: 0.94rem;
line-height: 1.72;
color: #94a3b8;
}
.gear-flow-meta-card {
display: grid;
gap: 0.65rem;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(15, 23, 42, 0.64);
padding: 1rem 1.05rem;
}
.gear-flow-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-size: 0.9rem;
color: #94a3b8;
}
.gear-flow-meta-row span {
color: #e2e8f0;
font-weight: 700;
}
.gear-flow-input,
.gear-flow-select {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.84);
padding: 0.72rem 0.9rem;
color: #f8fafc;
outline: none;
}
.gear-flow-input:focus,
.gear-flow-select:focus {
border-color: rgba(96, 165, 250, 0.7);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
}
.gear-flow-node-card {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 16px;
background: rgba(15, 23, 42, 0.68);
overflow: hidden;
padding: 0.25rem;
}
.gear-flow-node-card[data-active="true"] {
border-color: rgba(96, 165, 250, 0.7);
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.4);
background: rgba(20, 35, 59, 0.86);
}
.gear-flow-chip {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.42rem 1rem;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.03em;
line-height: 1;
white-space: nowrap;
}
.gear-flow-chip[data-tone="implemented"] {
background: rgba(34, 197, 94, 0.14);
color: #86efac;
}
.gear-flow-chip[data-tone="proposed"] {
background: rgba(245, 158, 11, 0.16);
color: #fcd34d;
}
.gear-flow-chip[data-tone="neutral"] {
background: rgba(148, 163, 184, 0.14);
color: #cbd5e1;
}
.gear-flow-section {
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
.gear-flow-list {
display: grid;
gap: 0.65rem;
}
.gear-flow-list-item {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(15, 23, 42, 0.7);
padding: 0.75rem 0.88rem;
font-size: 0.82rem;
line-height: 1.55;
color: #cbd5e1;
overflow-wrap: anywhere;
}
.gear-flow-canvas {
position: relative;
min-width: 0;
}
.gear-flow-topbar {
position: absolute;
left: 24px;
right: 24px;
top: 20px;
z-index: 5;
display: flex;
justify-content: center;
pointer-events: none;
}
.gear-flow-topbar-card {
pointer-events: auto;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 18px;
background: rgba(7, 17, 31, 0.82);
backdrop-filter: blur(16px);
}
.gear-flow-topbar-card--wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.6rem;
max-width: min(1080px, calc(100vw - 840px));
}
.gear-flow-topbar-title {
font-weight: 700;
color: #f8fafc;
white-space: nowrap;
}
.gear-flow-topbar-pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(15, 23, 42, 0.58);
padding: 0.34rem 0.72rem;
white-space: nowrap;
}
.gear-flow-react-node {
overflow: visible !important;
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.gear-flow-react-node.is-selected {
z-index: 2;
}
.gear-flow-react-node--proposal {
border-style: dashed !important;
}
.gear-flow-node {
--gear-flow-node-text-offset: 0.98rem;
display: flex;
flex-direction: column;
gap: 1.02rem;
padding: 1.78rem 2.08rem 1.68rem;
position: relative;
text-align: left;
}
.gear-flow-node--function,
.gear-flow-node--table,
.gear-flow-node--component,
.gear-flow-node--artifact,
.gear-flow-node--proposal {
padding-right: 2rem;
padding-left: 2rem;
}
.gear-flow-node__accent {
position: absolute;
left: 1.18rem;
top: 1.18rem;
bottom: 1.18rem;
width: 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--gear-flow-accent) 82%, white 18%);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.gear-flow-node__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.1rem;
padding-left: var(--gear-flow-node-text-offset);
}
.gear-flow-node__heading {
min-width: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.3rem;
}
.gear-flow-node__stage {
font-size: 1.04rem;
font-weight: 700;
letter-spacing: 0.16em;
line-height: 1.2;
color: #94a3b8;
text-transform: uppercase;
}
.gear-flow-node__title {
margin-top: 0;
font-size: 1.54rem;
font-weight: 700;
line-height: 1.32;
color: #f8fafc;
padding-right: 0.25rem;
overflow-wrap: anywhere;
}
.gear-flow-node__symbol {
font-size: 1.14rem;
line-height: 1.55;
color: #cbd5e1;
overflow-wrap: anywhere;
padding-right: 0.2rem;
padding-left: var(--gear-flow-node-text-offset);
text-align: left;
}
.gear-flow-node__role {
font-size: 1.25rem;
line-height: 1.62;
color: #94a3b8;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
padding-right: 0.18rem;
padding-left: var(--gear-flow-node-text-offset);
text-align: left;
}
.gear-flow-summary {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.gear-flow-detail-empty {
border: 1px dashed rgba(148, 163, 184, 0.2);
border-radius: 18px;
background: rgba(15, 23, 42, 0.42);
padding: 1rem 1.1rem;
}
.gear-flow-detail-card {
display: grid;
gap: 0.9rem;
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(15, 23, 42, 0.62);
padding: 1.15rem 1.2rem;
}
.gear-flow-detail-title {
font-size: 1.45rem;
font-weight: 700;
line-height: 1.34;
color: #f8fafc;
overflow-wrap: anywhere;
}
.gear-flow-detail-symbol {
font-size: 0.86rem;
font-weight: 700;
line-height: 1.6;
color: #7dd3fc;
overflow-wrap: anywhere;
}
.gear-flow-detail-text {
font-size: 0.92rem;
line-height: 1.78;
color: #cbd5e1;
overflow-wrap: anywhere;
}
.gear-flow-detail-file {
font-size: 0.78rem;
line-height: 1.65;
color: #94a3b8;
overflow-wrap: anywhere;
}
.gear-flow-section-title {
margin-bottom: 0.78rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #94a3b8;
}
.gear-flow-link {
color: #93c5fd;
text-decoration: none;
}
.gear-flow-link:hover {
color: #bfdbfe;
}
.gear-flow-detail .gear-flow-section {
padding-right: 0.15rem;
}
.gear-flow-detail .space-y-6 {
padding-right: 0.25rem;
}
@media (max-width: 1680px) {
.gear-flow-shell {
grid-template-columns: 304px minmax(760px, 1fr) 348px;
}
.gear-flow-topbar-card--wrap {
max-width: min(860px, calc(100vw - 740px));
}
}

파일 보기

@ -0,0 +1,565 @@
import { useMemo, useState, type CSSProperties } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
MarkerType,
Position,
type Edge,
type Node,
type NodeMouseHandler,
type EdgeMouseHandler,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import manifest from './gearParentFlowManifest.json';
import './GearParentFlowViewer.css';
type FlowStatus = 'implemented' | 'proposed';
type FlowNodeMeta = {
id: string;
label: string;
stage: string;
kind: string;
position: { x: number; y: number };
file: string;
symbol: string;
role: string;
params: string[];
rules: string[];
storageReads: string[];
storageWrites: string[];
outputs: string[];
impacts: string[];
status: FlowStatus;
};
type FlowEdgeMeta = {
id: string;
source: string;
target: string;
label?: string;
detail?: string;
};
type FlowManifest = {
meta: {
title: string;
version: string;
updatedAt: string;
description: string;
};
nodes: FlowNodeMeta[];
edges: FlowEdgeMeta[];
};
const flowManifest = manifest as FlowManifest;
const stageColors: Record<string, string> = {
'원천': '#38bdf8',
'시간 모델': '#60a5fa',
'적재': '#818cf8',
'캐시': '#a78bfa',
'정규화': '#c084fc',
'그룹핑': '#f472b6',
'후보 추적': '#fb7185',
'검토 워크플로우': '#f97316',
'최종 추론': '#f59e0b',
'조회 계층': '#22c55e',
'프론트': '#14b8a6',
'문서': '#06b6d4',
'미래 설계': '#eab308',
};
const stageOrder = [
'원천',
'시간 모델',
'적재',
'캐시',
'정규화',
'그룹핑',
'후보 추적',
'검토 워크플로우',
'최종 추론',
'조회 계층',
'프론트',
'문서',
'미래 설계',
] as const;
const layoutConfig = {
startX: 52,
startY: 88,
columnGap: 816,
rowGap: 309,
};
const semanticSlots: Record<string, { col: number; row: number; yOffset?: number }> = {
source_tracks: { col: 0, row: 0 },
safe_window: { col: 0, row: 1, yOffset: 36 },
snpdb_fetch: { col: 0, row: 2, yOffset: 92 },
vessel_store: { col: 0, row: 3, yOffset: 156 },
gear_identity: { col: 1, row: 0 },
detect_groups: { col: 1, row: 1 },
group_snapshots: { col: 1, row: 2 },
gear_correlation: { col: 1, row: 3 },
workflow_exclusions: { col: 1, row: 4 },
score_breakdown: { col: 2, row: 0 },
parent_inference: { col: 2, row: 1 },
backend_read_model: { col: 2, row: 2 },
workflow_api: { col: 2, row: 3 },
review_ui: { col: 2, row: 4 },
future_episode: { col: 2, row: 5 },
mermaid_docs: { col: 0, row: 5, yOffset: 240 },
react_flow_viewer: { col: 1, row: 5, yOffset: 182 },
};
function summarizeNode(node: FlowNodeMeta): string {
return [node.symbol, node.role].filter(Boolean).join(' · ');
}
function matchesQuery(node: FlowNodeMeta, query: string): boolean {
if (!query) return true;
const normalizedQuery = query.replace(/\s+/g, '').toLowerCase();
const haystack = [
node.label,
node.stage,
node.kind,
node.file,
node.symbol,
node.role,
...(node.params ?? []),
...(node.rules ?? []),
...(node.outputs ?? []),
...(node.impacts ?? []),
]
.join(' ')
.replace(/\s+/g, '')
.toLowerCase();
return haystack.includes(normalizedQuery);
}
function stageTone(stage: string): string {
return stageColors[stage] ?? '#94a3b8';
}
function shapeClipPath(kind: string): string | undefined {
switch (kind) {
case 'function':
return 'polygon(4% 0, 100% 0, 96% 100%, 0 100%)';
case 'table':
return 'polygon(0 6%, 8% 0, 100% 0, 100% 94%, 92% 100%, 0 100%)';
case 'component':
return 'polygon(7% 0, 93% 0, 100% 16%, 100% 84%, 93% 100%, 7% 100%, 0 84%, 0 16%)';
case 'artifact':
return 'polygon(0 0, 86% 0, 100% 14%, 100% 100%, 0 100%)';
case 'proposal':
return 'polygon(7% 0, 93% 0, 100% 20%, 100% 80%, 93% 100%, 7% 100%, 0 80%, 0 20%)';
default:
return undefined;
}
}
function compareNodeOrder(a: FlowNodeMeta, b: FlowNodeMeta): number {
const stageGap = stageOrder.indexOf(a.stage as (typeof stageOrder)[number])
- stageOrder.indexOf(b.stage as (typeof stageOrder)[number]);
if (stageGap !== 0) return stageGap;
if (a.position.y !== b.position.y) return a.position.y - b.position.y;
if (a.position.x !== b.position.x) return a.position.x - b.position.x;
return a.label.localeCompare(b.label);
}
function layoutNodeMeta(nodes: FlowNodeMeta[], _edges: FlowEdgeMeta[]): FlowNodeMeta[] {
const sortedNodes = [...nodes].sort(compareNodeOrder);
const fallbackSlots = new Map<number, number>();
const positioned = new Map<string, { x: number; y: number }>();
for (const node of sortedNodes) {
const semantic = semanticSlots[node.id];
if (semantic) {
positioned.set(node.id, {
x: layoutConfig.startX + semantic.col * layoutConfig.columnGap,
y: layoutConfig.startY + semantic.row * layoutConfig.rowGap + (semantic.yOffset ?? 0),
});
continue;
}
const fallbackCol = Math.min(3, stageOrder.indexOf(node.stage as (typeof stageOrder)[number]) % 4);
const fallbackRow = fallbackSlots.get(fallbackCol) ?? 5;
fallbackSlots.set(fallbackCol, fallbackRow + 1);
positioned.set(node.id, {
x: layoutConfig.startX + fallbackCol * layoutConfig.columnGap,
y: layoutConfig.startY + fallbackRow * layoutConfig.rowGap,
});
}
return nodes.map((node) => ({ ...node, position: positioned.get(node.id) ?? node.position }));
}
function buildNodes(nodes: FlowNodeMeta[], selectedNodeId: string | null): Node[] {
return nodes.map((node) => {
const color = stageTone(node.stage);
const clipPath = shapeClipPath(node.kind);
const style = {
'--gear-flow-accent': color,
'--gear-flow-node-text-offset': '0.98rem',
width: 380,
borderRadius: node.kind === 'component' || node.kind === 'proposal' ? 22 : 18,
padding: 0,
color: '#e2e8f0',
border: `2px solid ${selectedNodeId === node.id ? color : `${color}88`}`,
background: 'linear-gradient(180deg, rgba(15,23,42,0.98), rgba(15,23,42,0.84))',
boxShadow: selectedNodeId === node.id
? `0 0 0 4px ${color}33, 0 26px 52px rgba(2, 6, 23, 0.4)`
: '0 18px 36px rgba(2, 6, 23, 0.24)',
overflow: 'visible',
...(clipPath ? { clipPath } : {}),
} as CSSProperties;
return {
id: node.id,
position: node.position,
draggable: true,
selectable: true,
className: `gear-flow-react-node gear-flow-react-node--${node.kind}${selectedNodeId === node.id ? ' is-selected' : ''}`,
style,
data: { label: (
<div className={`gear-flow-node gear-flow-node--${node.kind}`}>
<div className="gear-flow-node__accent" />
<div className="gear-flow-node__header">
<div className="gear-flow-node__heading">
<div className="gear-flow-node__stage">{node.stage}</div>
<div className="gear-flow-node__title">{node.label}</div>
</div>
<span
className="gear-flow-chip shrink-0"
data-tone={node.status === 'implemented' ? 'implemented' : 'proposed'}
>
{node.status === 'implemented' ? '구현됨' : '제안됨'}
</span>
</div>
<div className="gear-flow-node__symbol">{node.symbol}</div>
<div className="gear-flow-node__role">{node.role}</div>
</div>
)},
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
}
function buildEdges(edges: FlowEdgeMeta[], selectedEdgeId: string | null): Edge[] {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: 'smoothstep',
pathOptions: { borderRadius: 18, offset: 18 },
label: edge.label,
animated: selectedEdgeId === edge.id,
markerEnd: {
type: MarkerType.ArrowClosed,
color: selectedEdgeId === edge.id ? '#f8fafc' : '#94a3b8',
width: 20,
height: 20,
},
style: {
stroke: selectedEdgeId === edge.id ? '#f8fafc' : '#94a3b8',
strokeWidth: selectedEdgeId === edge.id ? 2.8 : 1.9,
},
labelStyle: {
fill: '#e2e8f0',
fontSize: 18,
fontWeight: 700,
letterSpacing: 0.2,
},
labelBgStyle: {
fill: 'rgba(7, 17, 31, 0.94)',
fillOpacity: 0.96,
stroke: 'rgba(148, 163, 184, 0.24)',
strokeWidth: 1,
},
labelBgPadding: [8, 5],
labelBgBorderRadius: 12,
}));
}
function DetailList({ items }: { items: string[] }) {
if (!items.length) {
return <div className="text-sm text-slate-500"></div>;
}
return (
<div className="gear-flow-list">
{items.map((item) => (
<div key={item} className="gear-flow-list-item">{item}</div>
))}
</div>
);
}
export function GearParentFlowViewer() {
const [search, setSearch] = useState('');
const [stageFilter, setStageFilter] = useState('전체');
const [statusFilter, setStatusFilter] = useState<'전체' | FlowStatus>('전체');
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(flowManifest.nodes[0]?.id ?? null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const filteredNodeMeta = useMemo(() => {
return flowManifest.nodes.filter((node) => {
if (stageFilter !== '전체' && node.stage !== stageFilter) return false;
if (statusFilter !== '전체' && node.status !== statusFilter) return false;
return matchesQuery(node, search);
});
}, [search, stageFilter, statusFilter]);
const visibleNodeIds = useMemo(() => new Set(filteredNodeMeta.map((node) => node.id)), [filteredNodeMeta]);
const filteredEdgeMeta = useMemo(() => {
return flowManifest.edges.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
}, [visibleNodeIds]);
const layoutedNodeMeta = useMemo(
() => layoutNodeMeta(filteredNodeMeta, filteredEdgeMeta),
[filteredNodeMeta, filteredEdgeMeta],
);
const reactFlowNodes = useMemo(
() => buildNodes(layoutedNodeMeta, selectedNodeId),
[layoutedNodeMeta, selectedNodeId],
);
const reactFlowEdges = useMemo(
() => buildEdges(filteredEdgeMeta, selectedEdgeId),
[filteredEdgeMeta, selectedEdgeId],
);
const selectedNode = useMemo(
() => flowManifest.nodes.find((node) => node.id === selectedNodeId) ?? null,
[selectedNodeId],
);
const selectedEdge = useMemo(
() => flowManifest.edges.find((edge) => edge.id === selectedEdgeId) ?? null,
[selectedEdgeId],
);
const onNodeClick: NodeMouseHandler = (_event, node) => {
setSelectedEdgeId(null);
setSelectedNodeId(node.id);
};
const onEdgeClick: EdgeMouseHandler = (_event, edge) => {
setSelectedNodeId(null);
setSelectedEdgeId(edge.id);
};
const onNodeMouseEnter: NodeMouseHandler = (_event, node) => {
if (!selectedNodeId) setSelectedNodeId(node.id);
};
const stageOptions = useMemo(
() => ['전체', ...Array.from(new Set(flowManifest.nodes.map((node) => node.stage)))],
[],
);
return (
<div className="gear-flow-app">
<div className="gear-flow-shell">
<aside className="gear-flow-sidebar flex flex-col">
<div className="gear-flow-hero space-y-4 px-6 py-6">
<div className="gear-flow-panel-heading">
<div className="gear-flow-panel-kicker">Flow Source</div>
<h1 className="gear-flow-panel-title">{flowManifest.meta.title}</h1>
<p className="gear-flow-panel-description">{flowManifest.meta.description}</p>
</div>
<div className="gear-flow-meta-card">
<div className="gear-flow-meta-row"> <span>{flowManifest.meta.version}</span></div>
<div className="gear-flow-meta-row"> <span>{flowManifest.meta.updatedAt}</span></div>
</div>
</div>
<div className="space-y-4 px-6 py-5">
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"></label>
<input
className="gear-flow-input"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="모듈, 메서드, 규칙, 파일 검색"
/>
</div>
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"></label>
<select className="gear-flow-select" value={stageFilter} onChange={(event) => setStageFilter(event.target.value)}>
{stageOptions.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"></label>
<select
className="gear-flow-select"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as '전체' | FlowStatus)}
>
<option value="전체"></option>
<option value="implemented"></option>
<option value="proposed"></option>
</select>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<div className="mb-3 flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
<span> </span>
<span>{filteredNodeMeta.length}</span>
</div>
<div className="space-y-3">
{layoutedNodeMeta.map((node) => (
<button
key={node.id}
type="button"
className="gear-flow-node-card w-full p-4 text-left transition"
data-active={selectedNodeId === node.id}
onClick={() => {
setSelectedEdgeId(null);
setSelectedNodeId(node.id);
}}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{node.stage}</div>
<div className="mt-1 text-base font-semibold text-slate-50">{node.label}</div>
<div className="gear-flow-summary mt-2 text-sm leading-6 text-slate-400">{summarizeNode(node)}</div>
</div>
<span className="gear-flow-chip shrink-0" data-tone={node.status === 'implemented' ? 'implemented' : 'proposed'}>
{node.status === 'implemented' ? '구현됨' : '제안됨'}
</span>
</div>
</button>
))}
</div>
</div>
</aside>
<main className="gear-flow-canvas">
<div className="gear-flow-topbar">
<div className="gear-flow-topbar-card gear-flow-topbar-card--wrap px-5 py-3 text-sm text-slate-300">
<span className="gear-flow-topbar-title">React Flow Viewer</span>
<span className="gear-flow-topbar-pill"> </span>
<span className="gear-flow-topbar-pill"> </span>
<span className="gear-flow-topbar-pill">// </span>
</div>
</div>
<ReactFlow
nodes={reactFlowNodes}
edges={reactFlowEdges}
fitView
fitViewOptions={{ padding: 0.03, minZoom: 0.7, maxZoom: 1.2 }}
onNodeClick={onNodeClick}
onNodeMouseEnter={onNodeMouseEnter}
onEdgeClick={onEdgeClick}
nodesConnectable={false}
elementsSelectable
minZoom={0.35}
maxZoom={1.8}
proOptions={{ hideAttribution: true }}
>
<MiniMap
pannable
zoomable
nodeStrokeColor={(node) => {
const meta = flowManifest.nodes.find((item) => item.id === node.id);
return meta ? stageTone(meta.stage) : '#94a3b8';
}}
nodeColor={(node) => {
const meta = flowManifest.nodes.find((item) => item.id === node.id);
return meta ? `${stageTone(meta.stage)}55` : '#334155';
}}
maskColor="rgba(7, 17, 31, 0.68)"
/>
<Controls showInteractive={false} />
<Background color="#20324d" gap={24} size={1.2} />
</ReactFlow>
</main>
<aside className="gear-flow-detail flex flex-col">
<div className="gear-flow-hero px-6 py-6">
<div className="gear-flow-panel-heading">
<div className="gear-flow-panel-kicker">Detail</div>
<h2 className="gear-flow-panel-title"> </h2>
<p className="gear-flow-panel-description">
, , , downstream .
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-6">
{selectedNode ? (
<div className="space-y-6">
<div className="gear-flow-detail-card">
<div className="flex flex-wrap items-center gap-3">
<span className="gear-flow-chip" data-tone={selectedNode.status === 'implemented' ? 'implemented' : 'proposed'}>
{selectedNode.status === 'implemented' ? '구현됨' : '제안됨'}
</span>
<span className="gear-flow-chip" data-tone="neutral">{selectedNode.stage}</span>
<span className="gear-flow-chip" data-tone="neutral">{selectedNode.kind}</span>
</div>
<div className="gear-flow-detail-title">{selectedNode.label}</div>
<div className="gear-flow-detail-symbol">{selectedNode.symbol}</div>
<div className="gear-flow-detail-text">{selectedNode.role}</div>
<div className="gear-flow-detail-file">{selectedNode.file}</div>
</div>
<section className="gear-flow-section pt-5">
<div className="gear-flow-section-title"></div>
<DetailList items={selectedNode.params} />
</section>
<section className="gear-flow-section pt-5">
<div className="gear-flow-section-title"> </div>
<DetailList items={selectedNode.rules} />
</section>
<section className="gear-flow-section pt-5">
<div className="gear-flow-section-title"> </div>
<DetailList items={selectedNode.storageReads} />
</section>
<section className="gear-flow-section pt-5">
<div className="gear-flow-section-title"> </div>
<DetailList items={selectedNode.storageWrites} />
</section>
<section className="gear-flow-section pt-5">
<div className="gear-flow-section-title"></div>
<DetailList items={selectedNode.outputs} />
</section>
<section className="gear-flow-section pt-5">
<div className="gear-flow-section-title"> </div>
<DetailList items={selectedNode.impacts} />
</section>
</div>
) : selectedEdge ? (
<div className="space-y-6">
<div className="gear-flow-detail-card">
<span className="gear-flow-chip" data-tone="neutral"></span>
<div className="gear-flow-detail-title">{selectedEdge.label || selectedEdge.id}</div>
<div className="gear-flow-detail-text">{selectedEdge.detail || '설명 없음'}</div>
<div className="gear-flow-detail-file">{selectedEdge.source} {selectedEdge.target}</div>
</div>
</div>
) : (
<div className="gear-flow-detail-empty px-5 py-6 text-sm leading-7 text-slate-400">
.
</div>
)}
</div>
</aside>
</div>
</div>
);
}
export default GearParentFlowViewer;

파일 보기

@ -0,0 +1,325 @@
{
"meta": {
"title": "어구 모선 추적 데이터 흐름",
"version": "2026-04-03",
"updatedAt": "2026-04-03",
"description": "snpdb 적재부터 review/label workflow와 episode continuity + prior bonus까지의 전체 흐름"
},
"nodes": [
{
"id": "source_tracks",
"label": "5분 원천 궤적",
"stage": "원천",
"kind": "table",
"position": { "x": 0, "y": 20 },
"file": "signal.t_vessel_tracks_5min",
"symbol": "signal.t_vessel_tracks_5min",
"role": "5분 bucket 단위 AIS 궤적 원천 테이블",
"params": ["1 row = 1 MMSI = 5분 linestringM"],
"rules": ["bbox 122,31,132,39", "LineStringM dump 후 point timestamp 사용"],
"storageReads": [],
"storageWrites": [],
"outputs": ["mmsi", "time_bucket", "timestamp", "lat", "lon", "raw_sog"],
"impacts": ["모든 그룹/점수 계산의 원천 입력"],
"status": "implemented"
},
{
"id": "safe_window",
"label": "safe watermark",
"stage": "시간 모델",
"kind": "function",
"position": { "x": 260, "y": 20 },
"file": "prediction/time_bucket.py",
"symbol": "compute_safe_bucket / compute_incremental_window_start",
"role": "미완결 bucket 차단과 overlap backfill 시작점 계산",
"params": ["SNPDB_SAFE_DELAY_MIN", "SNPDB_BACKFILL_BUCKETS"],
"rules": ["safe bucket까지만 조회", "last_bucket보다 과거도 일부 재조회"],
"storageReads": [],
"storageWrites": [],
"outputs": ["safe_bucket", "window_start", "from_bucket"],
"impacts": ["live cache drift 완화", "재기동 spike 억제"],
"status": "implemented"
},
{
"id": "snpdb_fetch",
"label": "snpdb 적재",
"stage": "적재",
"kind": "module",
"position": { "x": 520, "y": 20 },
"file": "prediction/db/snpdb.py",
"symbol": "fetch_all_tracks / fetch_incremental",
"role": "safe bucket까지 초기/증분 궤적 적재",
"params": ["hours=24", "last_bucket"],
"rules": ["time_bucket > from_bucket", "time_bucket <= safe_bucket"],
"storageReads": ["signal.t_vessel_tracks_5min"],
"storageWrites": [],
"outputs": ["DataFrame of points"],
"impacts": ["VesselStore 초기화와 증분 merge 입력"],
"status": "implemented"
},
{
"id": "vessel_store",
"label": "VesselStore 캐시",
"stage": "캐시",
"kind": "module",
"position": { "x": 800, "y": 20 },
"file": "prediction/cache/vessel_store.py",
"symbol": "load_initial / merge_incremental / evict_stale",
"role": "24시간 sliding in-memory cache 유지",
"params": ["_tracks", "_last_bucket"],
"rules": ["timestamp dedupe", "safe bucket 기준 24h eviction"],
"storageReads": [],
"storageWrites": [],
"outputs": ["latest positions", "tracks by MMSI"],
"impacts": ["identity, grouping, correlation, inference 공통 입력"],
"status": "implemented"
},
{
"id": "gear_identity",
"label": "어구 identity",
"stage": "정규화",
"kind": "module",
"position": { "x": 1080, "y": 20 },
"file": "prediction/fleet_tracker.py",
"symbol": "track_gear_identity",
"role": "어구 이름 패턴 파싱과 gear_identity_log 유지",
"params": ["parent_name", "gear_index_1", "gear_index_2"],
"rules": ["정규화 길이 4 미만 제외", "같은 이름 다른 MMSI면 identity migration"],
"storageReads": ["fleet_vessels"],
"storageWrites": ["gear_identity_log", "gear_correlation_scores(target_mmsi transfer)"],
"outputs": ["active gear identity rows"],
"impacts": ["grouping과 parent_mmsi 보조 입력"],
"status": "implemented"
},
{
"id": "detect_groups",
"label": "어구 그룹 검출",
"stage": "그룹핑",
"kind": "function",
"position": { "x": 260, "y": 220 },
"file": "prediction/algorithms/polygon_builder.py",
"symbol": "detect_gear_groups",
"role": "이름 기반 raw group과 거리 기반 sub-cluster 생성",
"params": ["MAX_DIST_DEG=0.15", "STALE_SEC", "is_trackable_parent_name"],
"rules": ["440/441 제외", "single cluster면 sc#0", "multi cluster면 sc#1..N", "재병합 시 sc#0"],
"storageReads": [],
"storageWrites": [],
"outputs": ["gear_groups[]"],
"impacts": ["sub_cluster_id는 순간 라벨일 뿐 영구 ID가 아님"],
"status": "implemented"
},
{
"id": "group_snapshots",
"label": "그룹 스냅샷 생성",
"stage": "그룹핑",
"kind": "function",
"position": { "x": 520, "y": 220 },
"file": "prediction/algorithms/polygon_builder.py",
"symbol": "build_all_group_snapshots",
"role": "1h/1h-fb/6h polygon snapshot 생성",
"params": ["parent_active_1h", "MIN_GEAR_GROUP_SIZE"],
"rules": ["1h 활성<2이면 1h-fb", "수역 외 소수 멤버 제외", "parent nearby면 isParent=true"],
"storageReads": [],
"storageWrites": ["group_polygon_snapshots"],
"outputs": ["group snapshots"],
"impacts": ["backend live 현황과 parent inference center track 입력"],
"status": "implemented"
},
{
"id": "gear_correlation",
"label": "correlation 모델",
"stage": "후보 추적",
"kind": "module",
"position": { "x": 800, "y": 220 },
"file": "prediction/algorithms/gear_correlation.py",
"symbol": "run_gear_correlation",
"role": "후보 선박/어구 raw metric과 EMA score 계산",
"params": ["active models", "track_threshold", "decay_fast", "candidate max=30"],
"rules": ["선박은 track 기반", "어구 후보는 GEAR_BUOY", "후보 이탈 시 fast decay"],
"storageReads": ["group snapshots", "vessel_store", "correlation_param_models"],
"storageWrites": ["gear_correlation_raw_metrics", "gear_correlation_scores"],
"outputs": ["raw metrics", "EMA score rows"],
"impacts": ["parent inference 후보 seed"],
"status": "implemented"
},
{
"id": "workflow_exclusions",
"label": "후보 제외 / 라벨",
"stage": "검토 워크플로우",
"kind": "table",
"position": { "x": 1080, "y": 220 },
"file": "database/migration/014_gear_parent_workflow_v2_phase1.sql",
"symbol": "gear_parent_candidate_exclusions / gear_parent_label_sessions",
"role": "사람 판단 데이터를 자동 추론과 분리 저장",
"params": ["scope=GROUP|GLOBAL", "duration=1|3|5d"],
"rules": ["GLOBAL은 모든 그룹에서 제거", "ACTIVE label session만 tracking"],
"storageReads": [],
"storageWrites": ["gear_parent_candidate_exclusions", "gear_parent_label_sessions"],
"outputs": ["active exclusions", "active label sessions"],
"impacts": ["parent inference candidate pruning", "label tracking"],
"status": "implemented"
},
{
"id": "parent_inference",
"label": "모선 추론",
"stage": "최종 추론",
"kind": "module",
"position": { "x": 260, "y": 420 },
"file": "prediction/algorithms/gear_parent_inference.py",
"symbol": "run_gear_parent_inference",
"role": "후보 생성, coverage-aware scoring, 상태 전이, resolution 저장",
"params": ["auto score 0.72/0.15/3", "review threshold 0.60", "412/413 bonus +15%"],
"rules": ["DIRECT_PARENT_MATCH", "SKIPPED_SHORT_NAME", "NO_CANDIDATE", "AUTO_PROMOTED", "REVIEW_REQUIRED", "UNRESOLVED"],
"storageReads": ["gear_correlation_scores", "gear_correlation_raw_metrics", "group_polygon_snapshots", "active exclusions", "active label sessions"],
"storageWrites": ["gear_group_parent_candidate_snapshots", "gear_group_parent_resolution", "gear_parent_label_tracking_cycles"],
"outputs": ["candidate snapshots", "resolution current state", "label tracking rows"],
"impacts": ["review queue", "group detail", "future prior feature source"],
"status": "implemented"
},
{
"id": "score_breakdown",
"label": "점수 보정",
"stage": "최종 추론",
"kind": "function",
"position": { "x": 520, "y": 420 },
"file": "prediction/algorithms/gear_parent_inference.py",
"symbol": "_build_candidate_scores / _build_track_coverage_metrics",
"role": "이름, 궤적, 방문, 근접, 활동, 안정성, bonus를 합산",
"params": ["name 1.0/0.8/0.5/0.3", "coverage factors", "registry +0.05", "china prefix +0.15"],
"rules": ["raw->effective 보정", "preBonusScore>=0.30일 때만 412/413 bonus"],
"storageReads": [],
"storageWrites": ["candidate evidence JSON"],
"outputs": ["final_score", "coverage metrics", "evidenceConfidence"],
"impacts": ["review UI 설명력", "future signal/prior 분리 설계"],
"status": "implemented"
},
{
"id": "backend_read_model",
"label": "backend read model",
"stage": "조회 계층",
"kind": "module",
"position": { "x": 800, "y": 420 },
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java",
"symbol": "group list / review queue / detail SQL",
"role": "최신 전역 1h live snapshot과 fresh inference만 노출",
"params": ["snapshot_time max where resolution=1h"],
"rules": ["last_evaluated_at >= snapshot_time", "사라진 과거 sub-cluster 숨김"],
"storageReads": ["group_polygon_snapshots", "gear_group_parent_resolution", "gear_group_parent_candidate_snapshots"],
"storageWrites": [],
"outputs": ["GroupPolygonDto", "GroupParentInferenceDto", "review queue rows"],
"impacts": ["stale inference 차단", "프론트 live 상세 일관성"],
"status": "implemented"
},
{
"id": "workflow_api",
"label": "workflow API",
"stage": "조회 계층",
"kind": "module",
"position": { "x": 1080, "y": 420 },
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java",
"symbol": "candidate-exclusions / label-sessions endpoints",
"role": "그룹 제외, 전역 제외, 라벨 세션, tracking 조회 API",
"params": ["POST/GET workflow actions"],
"rules": ["activeOnly query", "release/cancel action"],
"storageReads": ["workflow tables"],
"storageWrites": ["workflow tables", "review log"],
"outputs": ["workflow DTO responses"],
"impacts": ["human-in-the-loop 데이터 축적"],
"status": "implemented"
},
{
"id": "review_ui",
"label": "모선 검토 UI",
"stage": "프론트",
"kind": "component",
"position": { "x": 520, "y": 620 },
"file": "frontend/src/components/korea/ParentReviewPanel.tsx",
"symbol": "ParentReviewPanel",
"role": "후보 비교, 필터, 라벨/제외 액션, coverage evidence 표시",
"params": ["min score", "min gear count", "search", "spatial filter"],
"rules": ["30% 미만 후보 비표시", "검색/범위/어구수 필터 AND", "hover 기반 overlay 강조"],
"storageReads": ["review/detail API", "localStorage filters"],
"storageWrites": ["workflow API actions", "localStorage filters"],
"outputs": ["review decisions", "candidate interpretation"],
"impacts": ["사람 판단 백데이터 생성"],
"status": "implemented"
},
{
"id": "mermaid_docs",
"label": "Mermaid 산출물",
"stage": "문서",
"kind": "artifact",
"position": { "x": 800, "y": 620 },
"file": "docs/generated/gear-parent-flow-overview.md",
"symbol": "generated Mermaid docs",
"role": "정적 흐름도와 노드 인덱스 문서",
"params": ["manifest JSON"],
"rules": ["generator 재실행 시 갱신"],
"storageReads": ["flow manifest"],
"storageWrites": ["docs/generated/*.md", "docs/generated/*.mmd"],
"outputs": ["overview flowchart", "node index"],
"impacts": ["정적 문서 기반 리뷰/공유"],
"status": "implemented"
},
{
"id": "react_flow_viewer",
"label": "React Flow viewer",
"stage": "문서",
"kind": "component",
"position": { "x": 1080, "y": 620 },
"file": "frontend/src/flow/GearParentFlowViewer.tsx",
"symbol": "GearParentFlowViewer",
"role": "노드 클릭/검색/필터/상세 패널이 있는 인터랙티브 흐름 뷰어",
"params": ["stage filter", "search", "node detail"],
"rules": ["별도 HTML entry", "manifest를 단일 source로 사용"],
"storageReads": ["flow manifest"],
"storageWrites": [],
"outputs": ["interactive HTML graph"],
"impacts": ["개발/검토/설명 자료"],
"status": "implemented"
},
{
"id": "future_episode",
"label": "episode continuity",
"stage": "후보 추적",
"kind": "module",
"position": { "x": 260, "y": 620 },
"file": "prediction/algorithms/gear_parent_episode.py",
"symbol": "build_episode_plan / compute_prior_bonus_components",
"role": "sub_cluster continuity와 episode/lineage/label prior bonus를 계산하는 계층",
"params": ["split", "merge", "expire", "24h/7d/30d prior windows"],
"rules": ["small member change는 same episode", "true merge는 new episode", "prior bonus는 weak carry-over + cap 0.20"],
"storageReads": ["gear_group_episodes", "gear_group_episode_snapshots", "candidate snapshots", "label history"],
"storageWrites": ["gear_group_episodes", "gear_group_episode_snapshots"],
"outputs": ["episode assignment", "continuity source/score", "prior bonus components"],
"impacts": ["장기 기억 기반 추론", "split/merge 이후 후보 관성 완화"],
"status": "implemented"
}
],
"edges": [
{ "id": "e1", "source": "source_tracks", "target": "safe_window", "label": "bucket window", "detail": "원천 5분 bucket에 safe delay와 overlap backfill 적용" },
{ "id": "e2", "source": "safe_window", "target": "snpdb_fetch", "label": "fetch bounds", "detail": "safe_bucket, from_bucket, window_start 전달" },
{ "id": "e3", "source": "snpdb_fetch", "target": "vessel_store", "label": "points", "detail": "초기/증분 point DataFrame 적재" },
{ "id": "e4", "source": "vessel_store", "target": "gear_identity", "label": "latest positions", "detail": "어구 이름 패턴과 parent_name 파싱" },
{ "id": "e5", "source": "vessel_store", "target": "detect_groups", "label": "latest positions", "detail": "어구 raw group과 서브클러스터 생성" },
{ "id": "e6", "source": "detect_groups", "target": "group_snapshots", "label": "gear_groups", "detail": "1h/1h-fb/6h polygon snapshot 생성" },
{ "id": "e7", "source": "vessel_store", "target": "gear_correlation", "label": "tracks", "detail": "후보 선박 6h track과 latest positions 입력" },
{ "id": "e8", "source": "detect_groups", "target": "gear_correlation", "label": "groups", "detail": "그룹 중심, 반경, active ratio 계산 입력" },
{ "id": "e9", "source": "group_snapshots", "target": "backend_read_model", "label": "snapshots", "detail": "최신 1h live group read model 구성" },
{ "id": "e10", "source": "group_snapshots", "target": "parent_inference", "label": "center tracks", "detail": "최근 6h 그룹 중심 이동과 live parent membership 입력" },
{ "id": "e11", "source": "gear_correlation", "target": "parent_inference", "label": "scores + raw", "detail": "correlation score와 raw metrics 사용" },
{ "id": "e11a", "source": "detect_groups", "target": "future_episode", "label": "current clusters", "detail": "현재 gear group 멤버/중심점으로 episode continuity 계산" },
{ "id": "e11b", "source": "workflow_exclusions", "target": "future_episode", "label": "label history", "detail": "label session lineage를 label prior 입력으로 사용" },
{ "id": "e11c", "source": "future_episode", "target": "parent_inference", "label": "episode assignment", "detail": "episode_id, continuity source, prior aggregate를 candidate build에 반영" },
{ "id": "e12", "source": "workflow_exclusions", "target": "parent_inference", "label": "active gates", "detail": "group/global exclusion과 label session을 candidate build에 반영" },
{ "id": "e13", "source": "parent_inference", "target": "score_breakdown", "label": "candidate scoring", "detail": "이름/track/visit/proximity/activity/stability와 bonus 계산" },
{ "id": "e13a", "source": "future_episode", "target": "score_breakdown", "label": "prior bonus", "detail": "episode/lineage/label prior bonus를 final score 마지막 단계에 가산" },
{ "id": "e14", "source": "score_breakdown", "target": "backend_read_model", "label": "fresh candidate/resolution", "detail": "fresh inference만 group detail과 review queue에 노출" },
{ "id": "e15", "source": "workflow_api", "target": "workflow_exclusions", "label": "CRUD", "detail": "exclusion/label 생성, 취소, 조회" },
{ "id": "e16", "source": "backend_read_model", "target": "review_ui", "label": "review/detail API", "detail": "모선 검토 UI의 기본 데이터 소스" },
{ "id": "e17", "source": "workflow_api", "target": "review_ui", "label": "actions", "detail": "라벨/그룹 제외/전체 제외/해제 액션 처리" },
{ "id": "e18", "source": "review_ui", "target": "mermaid_docs", "label": "human-readable spec", "detail": "정적 문서와 UI 해석 흐름 연결" },
{ "id": "e19", "source": "review_ui", "target": "react_flow_viewer", "label": "same manifest", "detail": "문서와 viewer가 같은 구조 설명을 공유" },
{ "id": "e20", "source": "parent_inference", "target": "future_episode", "label": "episode snapshots", "detail": "current resolution과 top candidate를 episode snapshot/history에 기록" }
]
}

파일 보기

@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@fontsource-variable/inter';
import '@fontsource-variable/noto-sans-kr';
import '@fontsource-variable/fira-code';
import './styles/tailwind.css';
import './index.css';
import GearParentFlowViewer from './flow/GearParentFlowViewer';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<GearParentFlowViewer />
</StrictMode>,
);

파일 보기

@ -12,7 +12,7 @@ import { clusterLabels } from '../utils/labelCluster';
export interface FleetClusterDeckConfig { export interface FleetClusterDeckConfig {
selectedGearGroup: string | null; selectedGearGroup: string | null;
hoveredMmsi: string | null; hoveredMmsi: string | null;
hoveredGearGroup: string | null; // gear polygon hover highlight hoveredGearCompositeKey: string | null;
enabledModels: Set<string>; enabledModels: Set<string>;
historyActive: boolean; historyActive: boolean;
hasCorrelationTracks: boolean; hasCorrelationTracks: boolean;
@ -21,13 +21,24 @@ export interface FleetClusterDeckConfig {
fontScale?: number; // fontScale.analysis (default 1) fontScale?: number; // fontScale.analysis (default 1)
focusMode?: boolean; // 집중 모드 — 라이브 폴리곤/마커 숨김 focusMode?: boolean; // 집중 모드 — 라이브 폴리곤/마커 숨김
onPolygonClick?: (features: PickedPolygonFeature[], coordinate: [number, number]) => void; onPolygonClick?: (features: PickedPolygonFeature[], coordinate: [number, number]) => void;
onPolygonHover?: (info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => void; onPolygonHover?: (info: {
lng: number;
lat: number;
type: 'fleet' | 'gear';
id: string | number;
groupKey?: string;
subClusterId?: number;
compositeKey?: string;
} | null) => void;
} }
export interface PickedPolygonFeature { export interface PickedPolygonFeature {
type: 'fleet' | 'gear'; type: 'fleet' | 'gear';
clusterId?: number; clusterId?: number;
name?: string; name?: string;
groupKey?: string;
subClusterId?: number;
compositeKey?: string;
gearCount?: number; gearCount?: number;
inZone?: boolean; inZone?: boolean;
} }
@ -112,6 +123,9 @@ function findPolygonsAtPoint(
results.push({ results.push({
type: 'gear', type: 'gear',
name: f.properties?.name, name: f.properties?.name,
groupKey: f.properties?.groupKey,
subClusterId: f.properties?.subClusterId,
compositeKey: f.properties?.compositeKey,
gearCount: f.properties?.gearCount, gearCount: f.properties?.gearCount,
inZone: f.properties?.inZone === 1, inZone: f.properties?.inZone === 1,
}); });
@ -136,7 +150,7 @@ export function useFleetClusterDeckLayers(
const { const {
selectedGearGroup, selectedGearGroup,
hoveredMmsi, hoveredMmsi,
hoveredGearGroup, hoveredGearCompositeKey,
enabledModels, enabledModels,
historyActive, historyActive,
zoomScale, zoomScale,
@ -243,7 +257,15 @@ export function useFleetClusterDeckLayers(
const f = info.object as GeoJSON.Feature; const f = info.object as GeoJSON.Feature;
const name = f.properties?.name; const name = f.properties?.name;
if (name) { if (name) {
onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'gear', id: name }); onPolygonHover?.({
lng: info.coordinate![0],
lat: info.coordinate![1],
type: 'gear',
id: f.properties?.groupKey ?? name,
groupKey: f.properties?.groupKey ?? name,
subClusterId: f.properties?.subClusterId,
compositeKey: f.properties?.compositeKey,
});
} }
} else { } else {
onPolygonHover?.(null); onPolygonHover?.(null);
@ -258,16 +280,12 @@ export function useFleetClusterDeckLayers(
} }
// ── 4b. Gear hover highlight ────────────────────────────────────────── // ── 4b. Gear hover highlight ──────────────────────────────────────────
if (hoveredGearGroup && gearFc.features.length > 0) { if (hoveredGearCompositeKey && geo.hoveredGearHighlightGeoJson && geo.hoveredGearHighlightGeoJson.features.length > 0) {
const hoveredGearFeatures = gearFc.features.filter(
f => f.properties?.name === hoveredGearGroup,
);
if (hoveredGearFeatures.length > 0) {
layers.push(new GeoJsonLayer({ layers.push(new GeoJsonLayer({
id: 'gear-hover-highlight', id: 'gear-hover-highlight',
data: { type: 'FeatureCollection' as const, features: hoveredGearFeatures }, data: geo.hoveredGearHighlightGeoJson,
getFillColor: (f: GeoJSON.Feature) => getFillColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? [220, 38, 38, 64] : [249, 115, 22, 64], f.properties?.inZone === 1 ? [220, 38, 38, 72] : [249, 115, 22, 72],
getLineColor: (f: GeoJSON.Feature) => getLineColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255], f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255],
getLineWidth: 2.5, getLineWidth: 2.5,
@ -277,7 +295,6 @@ export function useFleetClusterDeckLayers(
pickable: false, pickable: false,
})); }));
} }
}
// ── 5. Selected gear highlight (selectedGearHighlightGeoJson) ──────────── // ── 5. Selected gear highlight (selectedGearHighlightGeoJson) ────────────
if (geo.selectedGearHighlightGeoJson && geo.selectedGearHighlightGeoJson.features.length > 0) { if (geo.selectedGearHighlightGeoJson && geo.selectedGearHighlightGeoJson.features.length > 0) {
@ -539,7 +556,7 @@ export function useFleetClusterDeckLayers(
geo, geo,
selectedGearGroup, selectedGearGroup,
hoveredMmsi, hoveredMmsi,
hoveredGearGroup, hoveredGearCompositeKey,
enabledModels, enabledModels,
historyActive, historyActive,
zoomScale, zoomScale,

파일 보기

@ -6,6 +6,7 @@ import { useGearReplayStore } from '../stores/gearReplayStore';
import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess'; import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess';
import type { MemberPosition } from '../stores/gearReplayPreprocess'; import type { MemberPosition } from '../stores/gearReplayPreprocess';
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants'; import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
import { getParentReviewCandidateColor } from '../components/korea/parentReviewCandidateColors';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
import type { GearCorrelationItem } from '../services/vesselAnalysis'; import type { GearCorrelationItem } from '../services/vesselAnalysis';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg'; import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
@ -41,6 +42,80 @@ interface CorrPosition {
isVessel: boolean; isVessel: boolean;
} }
interface TripDatumLike {
id: string;
path: [number, number][];
timestamps: number[];
color: [number, number, number, number];
}
function interpolateTripPosition(
trip: TripDatumLike,
relTime: number,
): { lon: number; lat: number; cog: number } | null {
const ts = trip.timestamps;
const path = trip.path;
if (path.length === 0 || ts.length === 0) return null;
if (relTime < ts[0] || relTime > ts[ts.length - 1]) return null;
if (path.length === 1 || ts.length === 1) {
return { lon: path[0][0], lat: path[0][1], cog: 0 };
}
if (relTime <= ts[0]) {
const dx = path[1][0] - path[0][0];
const dy = path[1][1] - path[0][1];
return {
lon: path[0][0],
lat: path[0][1],
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
};
}
if (relTime >= ts[ts.length - 1]) {
const last = path.length - 1;
const dx = path[last][0] - path[last - 1][0];
const dy = path[last][1] - path[last - 1][1];
return {
lon: path[last][0],
lat: path[last][1],
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
};
}
let lo = 0;
let hi = ts.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (ts[mid] <= relTime) lo = mid;
else hi = mid;
}
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
const dx = path[hi][0] - path[lo][0];
const dy = path[hi][1] - path[lo][1];
return {
lon: path[lo][0] + dx * ratio,
lat: path[lo][1] + (path[hi][1] - path[lo][1]) * ratio,
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
};
}
function clipTripPathToTime(trip: TripDatumLike, relTime: number): [number, number][] {
const ts = trip.timestamps;
if (trip.path.length < 2 || ts.length < 2) return [];
if (relTime < ts[0]) return [];
if (relTime >= ts[ts.length - 1]) return trip.path;
let hi = ts.findIndex(value => value > relTime);
if (hi <= 0) hi = 1;
const clipped = trip.path.slice(0, hi);
const interpolated = interpolateTripPosition(trip, relTime);
if (interpolated) {
clipped.push([interpolated.lon, interpolated.lat]);
}
return clipped;
}
// ── Hook ────────────────────────────────────────────────────────────────────── // ── Hook ──────────────────────────────────────────────────────────────────────
/** /**
@ -67,8 +142,8 @@ export function useGearReplayLayers(
const enabledModels = useGearReplayStore(s => s.enabledModels); const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels); const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
const reviewCandidates = useGearReplayStore(s => s.reviewCandidates);
const correlationByModel = useGearReplayStore(s => s.correlationByModel); const correlationByModel = useGearReplayStore(s => s.correlationByModel);
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
const showTrails = useGearReplayStore(s => s.showTrails); const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon); const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
@ -217,6 +292,11 @@ export function useGearReplayLayers(
// Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용) // Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용)
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct); const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
const relTime = ct - st;
const visibleMemberMmsis = new Set(members.map(m => m.mmsi));
const reviewCandidateMap = new Map(reviewCandidates.map(candidate => [candidate.mmsi, candidate]));
const reviewCandidateSet = new Set(reviewCandidateMap.keys());
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d]));
// 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유) // 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유)
const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }]; const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }];
@ -226,7 +306,7 @@ export function useGearReplayLayers(
// 멤버 전체 항적 (identity — 항상 ON) // 멤버 전체 항적 (identity — 항상 ON)
if (memberTripsData.length > 0) { if (memberTripsData.length > 0) {
for (const trip of memberTripsData) { for (const trip of memberTripsData) {
if (trip.path.length < 2) continue; if (!visibleMemberMmsis.has(trip.id) || trip.path.length < 2) continue;
layers.push(new PathLayer({ layers.push(new PathLayer({
id: `replay-member-path-${trip.id}`, id: `replay-member-path-${trip.id}`,
data: [{ path: trip.path }], data: [{ path: trip.path }],
@ -236,6 +316,7 @@ export function useGearReplayLayers(
})); }));
} }
} }
// 연관 선박 전체 항적 (correlation) // 연관 선박 전체 항적 (correlation)
if (correlationTripsData.length > 0) { if (correlationTripsData.length > 0) {
const activeMmsis = new Set<string>(); const activeMmsis = new Set<string>();
@ -246,7 +327,7 @@ export function useGearReplayLayers(
} }
} }
for (const trip of correlationTripsData) { for (const trip of correlationTripsData) {
if (!activeMmsis.has(trip.id) || trip.path.length < 2) continue; if (!activeMmsis.has(trip.id) || reviewCandidateSet.has(trip.id) || trip.path.length < 2) continue;
layers.push(new PathLayer({ layers.push(new PathLayer({
id: `replay-corr-path-${trip.id}`, id: `replay-corr-path-${trip.id}`,
data: [{ path: trip.path }], data: [{ path: trip.path }],
@ -256,30 +337,28 @@ export function useGearReplayLayers(
})); }));
} }
} }
}
// 1. Correlation TripsLayer (GPU animated, 항상 ON, 고채도) if (reviewCandidates.length > 0) {
if (correlationTripsData.length > 0) { for (const candidate of reviewCandidates) {
const activeMmsis = new Set<string>(); const trip = corrTrackMap.get(candidate.mmsi);
for (const [mn, items] of correlationByModel) { if (!trip || trip.path.length < 2) continue;
if (!enabledModels.has(mn)) continue; const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
for (const c of items as GearCorrelationItem[]) { const hovered = hoveredMmsi === candidate.mmsi;
if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi); layers.push(new PathLayer({
} id: `replay-review-path-glow-${candidate.mmsi}`,
} data: [{ path: trip.path }],
const enabledTrips = correlationTripsData.filter(d => activeMmsis.has(d.id)); getPath: (d: { path: [number, number][] }) => d.path,
if (enabledTrips.length > 0) { getColor: hovered ? [255, 255, 255, 110] : [255, 255, 255, 45],
layers.push(new TripsLayer({ widthMinPixels: hovered ? 7 : 4,
id: 'replay-corr-trails',
data: enabledTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [100, 180, 255, 220], // 고채도 파랑 (항적보다 밝게)
widthMinPixels: 2.5,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
})); }));
layers.push(new PathLayer({
id: `replay-review-path-${candidate.mmsi}`,
data: [{ path: trip.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [r, g, b, hovered ? 230 : 160],
widthMinPixels: hovered ? 4.5 : 2.5,
}));
}
} }
} }
@ -329,11 +408,10 @@ export function useGearReplayLayers(
} }
} }
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback) // 6. Correlation vessel positions (현재 리플레이 시점에 실제로 보이는 대상만)
const corrPositions: CorrPosition[] = []; const corrPositions: CorrPosition[] = [];
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d])); const reviewPositions: CorrPosition[] = [];
const liveShips = shipsRef.current; void shipsRef;
const relTime = ct - st;
for (const [mn, items] of correlationByModel) { for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue; if (!enabledModels.has(mn)) continue;
@ -342,80 +420,44 @@ export function useGearReplayLayers(
for (const c of items as GearCorrelationItem[]) { for (const c of items as GearCorrelationItem[]) {
if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외 if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외
if (reviewCandidateSet.has(c.targetMmsi)) continue;
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue; if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
let lon: number | undefined;
let lat: number | undefined;
let cog = 0;
// 방법 1: 트랙 데이터 (보간 + 범위 밖은 끝점 clamp)
const tripData = corrTrackMap.get(c.targetMmsi); const tripData = corrTrackMap.get(c.targetMmsi);
if (tripData && tripData.path.length > 0) { const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
const ts = tripData.timestamps; if (!position) continue;
const path = tripData.path;
if (relTime <= ts[0]) {
// 트랙 시작 전 → 첫 점 사용
lon = path[0][0]; lat = path[0][1];
if (path.length > 1) {
const dx = path[1][0] - path[0][0];
const dy = path[1][1] - path[0][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
} else if (relTime >= ts[ts.length - 1]) {
// 트랙 종료 후 → 마지막 점 사용
const last = path.length - 1;
lon = path[last][0]; lat = path[last][1];
if (last > 0) {
const dx = path[last][0] - path[last - 1][0];
const dy = path[last][1] - path[last - 1][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
} else {
// 범위 내 → 보간
let lo = 0;
let hi = ts.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (ts[mid] <= relTime) lo = mid; else hi = mid;
}
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio;
lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio;
const dx = path[hi][0] - path[lo][0];
const dy = path[hi][1] - path[lo][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
}
// 방법 2: live 선박 위치 fallback
if (lon === undefined) {
const ship = liveShips.get(c.targetMmsi);
if (ship) {
lon = ship.lng;
lat = ship.lat;
cog = ship.course ?? 0;
}
}
if (lon === undefined || lat === undefined) continue;
corrPositions.push({ corrPositions.push({
mmsi: c.targetMmsi, mmsi: c.targetMmsi,
name: c.targetName || c.targetMmsi, name: c.targetName || c.targetMmsi,
lon, lon: position.lon,
lat, lat: position.lat,
cog, cog: position.cog,
color: [r, g, b, 230], color: [r, g, b, 230],
isVessel: c.targetType === 'VESSEL', isVessel: c.targetType === 'VESSEL',
}); });
} }
} }
for (const candidate of reviewCandidates) {
const tripData = corrTrackMap.get(candidate.mmsi);
const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
if (!position) continue;
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
reviewPositions.push({
mmsi: candidate.mmsi,
name: candidate.name,
lon: position.lon,
lat: position.lat,
cog: position.cog,
color: [r, g, b, hoveredMmsi === candidate.mmsi ? 255 : 235],
isVessel: true,
});
}
// 디버그: 첫 프레임에서 전체 상태 출력 // 디버그: 첫 프레임에서 전체 상태 출력
if (shouldLog) { if (shouldLog) {
const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length; const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length;
const liveHit = corrPositions.length - trackHit;
const sampleTrip = memberTripsData[0]; const sampleTrip = memberTripsData[0];
console.log('[GearReplay] renderFrame:', { console.log('[GearReplay] renderFrame:', {
historyFrames: state.historyFrames.length, historyFrames: state.historyFrames.length,
@ -427,7 +469,8 @@ export function useGearReplayLayers(
currentTime: Math.round((ct - st) / 60000) + 'min (rel)', currentTime: Math.round((ct - st) / 60000) + 'min (rel)',
members: members.length, members: members.length,
corrPositions: corrPositions.length, corrPositions: corrPositions.length,
posSource: `track:${trackHit} live:${liveHit}`, reviewPositions: reviewPositions.length,
posSource: `track:${trackHit}`,
memberTrip0: sampleTrip ? { id: sampleTrip.id, pts: sampleTrip.path.length, tsRange: `${Math.round(sampleTrip.timestamps[0]/60000)}~${Math.round(sampleTrip.timestamps[sampleTrip.timestamps.length-1]/60000)}min` } : 'none', memberTrip0: sampleTrip ? { id: sampleTrip.id, pts: sampleTrip.path.length, tsRange: `${Math.round(sampleTrip.timestamps[0]/60000)}~${Math.round(sampleTrip.timestamps[sampleTrip.timestamps.length-1]/60000)}min` } : 'none',
}); });
// 모델별 상세 // 모델별 상세
@ -440,6 +483,73 @@ export function useGearReplayLayers(
} }
} }
const visibleCorrMmsis = new Set(corrPositions.map(position => position.mmsi));
const visibleReviewMmsis = new Set(reviewPositions.map(position => position.mmsi));
const visibleMemberTrips = memberTripsData.filter(d => visibleMemberMmsis.has(d.id));
const enabledCorrTrips = correlationTripsData.filter(d => visibleCorrMmsis.has(d.id) && !reviewCandidateSet.has(d.id));
const reviewVisibleTrips = correlationTripsData
.filter(d => visibleReviewMmsis.has(d.id))
.map(d => {
const candidate = reviewCandidateMap.get(d.id);
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate?.rank ?? 1));
return { ...d, color: [r, g, b, hoveredMmsi === d.id ? 255 : 230] as [number, number, number, number] };
});
const hoveredReviewTrips = reviewVisibleTrips.filter(d => d.id === hoveredMmsi);
const defaultReviewTrips = reviewVisibleTrips.filter(d => d.id !== hoveredMmsi);
if (enabledCorrTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-corr-trails',
data: enabledCorrTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [100, 180, 255, 220],
widthMinPixels: 2.5,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
if (defaultReviewTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-review-trails',
data: defaultReviewTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => d.color,
widthMinPixels: 4,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
if (hoveredReviewTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-review-hover-trails-glow',
data: hoveredReviewTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [255, 255, 255, 190],
widthMinPixels: 7,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
layers.push(new TripsLayer({
id: 'replay-review-hover-trails',
data: hoveredReviewTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => d.color,
widthMinPixels: 5,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
if (corrPositions.length > 0) { if (corrPositions.length > 0) {
layers.push(new IconLayer<CorrPosition>({ layers.push(new IconLayer<CorrPosition>({
id: 'replay-corr-vessels', id: 'replay-corr-vessels',
@ -470,10 +580,57 @@ export function useGearReplayLayers(
} }
} }
if (reviewPositions.length > 0) {
layers.push(new ScatterplotLayer({
id: 'replay-review-vessel-glow',
data: reviewPositions,
getPosition: d => [d.lon, d.lat],
getFillColor: d => {
const alpha = hoveredMmsi === d.mmsi ? 90 : 40;
return [255, 255, 255, alpha] as [number, number, number, number];
},
getRadius: d => hoveredMmsi === d.mmsi ? 420 : 260,
radiusUnits: 'meters',
radiusMinPixels: 10,
}));
layers.push(new IconLayer<CorrPosition>({
id: 'replay-review-vessels',
data: reviewPositions,
getPosition: d => [d.lon, d.lat],
getIcon: () => SHIP_ICON_MAPPING['ship-triangle'],
getSize: d => hoveredMmsi === d.mmsi ? 24 : 20,
getAngle: d => -(d.cog || 0),
getColor: d => d.color,
sizeUnits: 'pixels',
billboard: false,
}));
if (showLabels) {
const clusteredReview = clusterLabels(reviewPositions, d => [d.lon, d.lat], zoomLevel);
layers.push(new TextLayer<CorrPosition>({
id: 'replay-review-labels',
data: clusteredReview,
getPosition: d => [d.lon, d.lat],
getText: d => {
const candidate = reviewCandidateMap.get(d.mmsi);
return candidate ? `#${candidate.rank} ${d.name}` : d.name;
},
getColor: d => d.color,
getSize: d => hoveredMmsi === d.mmsi ? 10 * fs : 9 * fs,
getPixelOffset: [0, 17],
background: true,
getBackgroundColor: [0, 0, 0, 215],
backgroundPadding: [3, 2],
}));
}
}
// 7. Hover highlight // 7. Hover highlight
if (hoveredMmsi) { if (hoveredMmsi) {
const hoveredMember = members.find(m => m.mmsi === hoveredMmsi); const hoveredMember = members.find(m => m.mmsi === hoveredMmsi);
const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi); const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi)
?? reviewPositions.find(c => c.mmsi === hoveredMmsi);
const hoveredPos: [number, number] | null = hoveredMember const hoveredPos: [number, number] | null = hoveredMember
? [hoveredMember.lon, hoveredMember.lat] ? [hoveredMember.lon, hoveredMember.lat]
: hoveredCorr : hoveredCorr
@ -506,16 +663,8 @@ export function useGearReplayLayers(
// Hover trail (from correlation track) // Hover trail (from correlation track)
const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi); const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi);
if (hoveredTrack) { if (hoveredTrack && !reviewCandidateSet.has(hoveredMmsi) && (visibleCorrMmsis.has(hoveredMmsi) || visibleMemberMmsis.has(hoveredMmsi))) {
const relTime = ct - st; const clippedPath = clipTripPathToTime(hoveredTrack, relTime);
let clipIdx = hoveredTrack.timestamps.length;
for (let i = 0; i < hoveredTrack.timestamps.length; i++) {
if (hoveredTrack.timestamps[i] > relTime) {
clipIdx = i;
break;
}
}
const clippedPath = hoveredTrack.path.slice(0, clipIdx);
if (clippedPath.length >= 2) { if (clippedPath.length >= 2) {
layers.push(new PathLayer({ layers.push(new PathLayer({
id: 'replay-hover-trail', id: 'replay-hover-trail',
@ -537,6 +686,9 @@ export function useGearReplayLayers(
for (const c of corrPositions) { for (const c of corrPositions) {
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] }); if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
} }
for (const c of reviewPositions) {
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
}
if (pinnedPositions.length > 0) { if (pinnedPositions.length > 0) {
// glow // glow
layers.push(new ScatterplotLayer({ layers.push(new ScatterplotLayer({
@ -566,12 +718,8 @@ export function useGearReplayLayers(
// pinned trails (correlation tracks) // pinned trails (correlation tracks)
const relTime = ct - st; const relTime = ct - st;
for (const trip of correlationTripsData) { for (const trip of correlationTripsData) {
if (!state.pinnedMmsis.has(trip.id)) continue; if (!state.pinnedMmsis.has(trip.id) || !visibleCorrMmsis.has(trip.id)) continue;
let clipIdx = trip.timestamps.length; const clippedPath = clipTripPathToTime(trip, relTime);
for (let i = 0; i < trip.timestamps.length; i++) {
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
}
const clippedPath = trip.path.slice(0, clipIdx);
if (clippedPath.length >= 2) { if (clippedPath.length >= 2) {
layers.push(new PathLayer({ layers.push(new PathLayer({
id: `replay-pinned-trail-${trip.id}`, id: `replay-pinned-trail-${trip.id}`,
@ -583,14 +731,24 @@ export function useGearReplayLayers(
} }
} }
for (const trip of reviewVisibleTrips) {
if (!state.pinnedMmsis.has(trip.id)) continue;
const clippedPath = clipTripPathToTime(trip, relTime);
if (clippedPath.length >= 2) {
layers.push(new PathLayer({
id: `replay-pinned-review-trail-${trip.id}`,
data: [{ path: clippedPath }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: trip.color,
widthMinPixels: 3.5,
}));
}
}
// pinned member trails (identity tracks) // pinned member trails (identity tracks)
for (const trip of memberTripsData) { for (const trip of memberTripsData) {
if (!state.pinnedMmsis.has(trip.id)) continue; if (!state.pinnedMmsis.has(trip.id) || !visibleMemberMmsis.has(trip.id)) continue;
let clipIdx = trip.timestamps.length; const clippedPath = clipTripPathToTime(trip, relTime);
for (let i = 0; i < trip.timestamps.length; i++) {
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
}
const clippedPath = trip.path.slice(0, clipIdx);
if (clippedPath.length >= 2) { if (clippedPath.length >= 2) {
layers.push(new PathLayer({ layers.push(new PathLayer({
id: `replay-pinned-mtrail-${trip.id}`, id: `replay-pinned-mtrail-${trip.id}`,
@ -642,62 +800,6 @@ export function useGearReplayLayers(
} }
} }
// 8.5. Model center trails + current center point (모델×서브클러스터별 중심 경로)
for (const trail of modelCenterTrails) {
if (!enabledModels.has(trail.modelName)) continue;
if (trail.path.length < 2) continue;
const color = MODEL_COLORS[trail.modelName] ?? '#94a3b8';
const [r, g, b] = hexToRgb(color);
// 중심 경로 (PathLayer, 연한 모델 색상)
layers.push(new PathLayer({
id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`,
data: [{ path: trail.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [r, g, b, 100],
widthMinPixels: 1.5,
}));
// 현재 중심점 (보간)
const ts = trail.timestamps;
if (ts.length > 0 && relTime >= ts[0] && relTime <= ts[ts.length - 1]) {
let lo = 0, hi = ts.length - 1;
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (ts[mid] <= relTime) lo = mid; else hi = mid; }
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
const cx = trail.path[lo][0] + (trail.path[hi][0] - trail.path[lo][0]) * ratio;
const cy = trail.path[lo][1] + (trail.path[hi][1] - trail.path[lo][1]) * ratio;
const centerData = [{ position: [cx, cy] as [number, number] }];
layers.push(new ScatterplotLayer({
id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`,
data: centerData,
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [r, g, b, 255],
getRadius: 150,
radiusUnits: 'meters',
radiusMinPixels: 5,
stroked: true,
getLineColor: [255, 255, 255, 200],
lineWidthMinPixels: 1.5,
}));
if (showLabels) {
layers.push(new TextLayer({
id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`,
data: centerData,
getPosition: (d: { position: [number, number] }) => d.position,
getText: () => trail.modelName,
getColor: [r, g, b, 255],
getSize: 9 * fs,
getPixelOffset: [0, -12],
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [3, 1],
fontFamily: '"Fira Code Variable", monospace',
}));
}
}
}
// 9. Model badges (small colored dots next to each vessel/gear per model) // 9. Model badges (small colored dots next to each vessel/gear per model)
{ {
const badgeTargets = new Map<string, { lon: number; lat: number; models: Set<string> }>(); const badgeTargets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
@ -797,10 +899,10 @@ export function useGearReplayLayers(
} }
// TripsLayer (멤버 트레일) // TripsLayer (멤버 트레일)
if (memberTripsData.length > 0) { if (visibleMemberTrips.length > 0) {
layers.push(new TripsLayer({ layers.push(new TripsLayer({
id: 'replay-identity-trails', id: 'replay-identity-trails',
data: memberTripsData, data: visibleMemberTrips,
getPath: d => d.path, getPath: d => d.path,
getTimestamps: d => d.timestamps, getTimestamps: d => d.timestamps,
getColor: [255, 200, 60, 220], getColor: [255, 200, 60, 220],
@ -848,6 +950,8 @@ export function useGearReplayLayers(
const frame6h = state.historyFrames6h[frameIdx6h]; const frame6h = state.historyFrames6h[frameIdx6h];
const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }]; const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }];
const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct); const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct);
const visibleMemberMmsis6h = new Set(members6h.map(member => member.mmsi));
const visibleMemberTrips6h = memberTripsData6h.filter(trip => visibleMemberMmsis6h.has(trip.id));
// 6h 폴리곤 // 6h 폴리곤
for (const sf of subFrames6h) { for (const sf of subFrames6h) {
@ -908,10 +1012,10 @@ export function useGearReplayLayers(
} }
// 6h TripsLayer (항적 애니메이션) // 6h TripsLayer (항적 애니메이션)
if (memberTripsData6h.length > 0) { if (visibleMemberTrips6h.length > 0) {
layers.push(new TripsLayer({ layers.push(new TripsLayer({
id: 'replay-6h-identity-trails', id: 'replay-6h-identity-trails',
data: memberTripsData6h, data: visibleMemberTrips6h,
getPath: d => d.path, getPath: d => d.path,
getTimestamps: d => d.timestamps, getTimestamps: d => d.timestamps,
getColor: [147, 197, 253, 180] as [number, number, number, number], getColor: [147, 197, 253, 180] as [number, number, number, number],
@ -957,7 +1061,7 @@ export function useGearReplayLayers(
centerTrailSegments, centerDotsPositions, centerTrailSegments, centerDotsPositions,
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h, centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel, enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
modelCenterTrails, subClusterCenters, showTrails, showLabels, reviewCandidates, subClusterCenters, showTrails, showLabels,
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel, show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
replayLayerRef, requestRender, replayLayerRef, requestRender,
]); ]);

파일 보기

@ -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,13 +50,14 @@ 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 {
const [allGroups, setAllGroups] = useState<GroupPolygonDto[]>([]); const [allGroups, setAllGroups] = useState<GroupPolygonDto[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [lastUpdated, setLastUpdated] = useState(0); const [lastUpdated, setLastUpdated] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval>>(); const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
const load = useCallback(async () => { const load = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@ -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 };
} }

파일 보기

@ -195,6 +195,202 @@
"operator": "Operator", "operator": "Operator",
"yearSuffix": "" "yearSuffix": ""
}, },
"fleetGear": {
"fleetSection": "Fleet Status ({{count}})",
"fleetFallback": "Fleet #{{id}}",
"inZoneSection": "Gear In Zone ({{count}})",
"outZoneSection": "Unauthorized Gear ({{count}})",
"toggleFleetSection": "Collapse or expand fleet status",
"emptyFleet": "No fleet data",
"vesselCountCompact": "({{count}} vessels)",
"zoom": "Zoom",
"moveToFleet": "Move map to this fleet",
"moveToGroup": "Move map to this gear group",
"moveToShip": "Move to ship",
"moveToShipItem": "Move to ship {{name}}",
"moveToGear": "Move to gear position",
"moveToGearItem": "Move to {{name}} position",
"shipList": "Ships",
"gearList": "Gear List",
"roleMain": "Main",
"roleSub": "Sub"
},
"parentInference": {
"title": "Parent Review",
"actorLabel": "Review Actor",
"actorPlaceholder": "lab-ui",
"reviewQueue": "Review Queue ({{count}})",
"reviewQueueFiltered": "Review Queue ({{filtered}} / {{total}})",
"queueMeta": "sc#{{subClusterId}} · {{count}} candidates",
"emptyQueue": "No items waiting for review.",
"loading": "Loading...",
"emptyState": "No parent inference data yet.",
"filters": {
"minScore": "Minimum score",
"minScoreValue": "{{value}}%+",
"minScoreAll": "All",
"minMemberCount": "Minimum gear",
"search": "Search",
"searchPlaceholder": "Search name, zone, suggested parent",
"clearSearch": "Clear",
"resetFilters": "Reset filters",
"sort": "Sort",
"startSpatial": "Draw map range",
"finishSpatial": "Apply range",
"clearSpatial": "Clear range",
"spatialIdle": "No map range filter is applied.",
"spatialDrawing": "{{count}} points added on the map. Move the mouse for a live preview, and click near the start point after 3 or more points to close the polygon.",
"spatialApplied": "Only gear groups inside the drawn map range are shown.",
"queueFilterFallback": "Saved filters currently hide every item, so the full review queue is shown temporarily.",
"queueTopScore": "Top Score {{score}}",
"queueMemberCount": "{{count}} gear",
"sortOptions": {
"backend": "Default order",
"topScore": "Highest score",
"memberCount": "Most gear",
"candidateCount": "Most candidates",
"zoneDistance": "Closest to fishing zone",
"name": "Name"
}
},
"summary": {
"label": "Inference",
"recommendedParent": "Suggested Parent",
"confidence": "Confidence",
"topMargin": "Top Score/Margin",
"stableCycles": "Stable Cycles",
"statusReason": "Reason",
"marginOnly": "Margin",
"activeLabel": "Active Label",
"activeUntil": "until {{value}}",
"groupExclusions": "Group Exclusions"
},
"metrics": {
"corr": "Corr",
"name": "Name",
"track": "Track",
"visit": "Visit",
"prox": "Prox",
"activity": "Activity"
},
"actions": {
"refresh": "Refresh",
"duration": "Duration",
"durationOption": "{{days}}d",
"label": "Label",
"jumpSubCluster": "Locate",
"cancelLabel": "Clear Label",
"groupExclude": "Group Excl.",
"releaseGroupExclude": "Group Clear",
"globalExclude": "Global Excl.",
"releaseGlobalExclude": "Global Clear",
"otherLabelActive": "Another candidate is already active as the labeled parent."
},
"badges": {
"AUTO_PROMOTED": "AUTO",
"MANUAL_CONFIRMED": "MANUAL",
"DIRECT_PARENT_MATCH": "DIRECT",
"REVIEW_REQUIRED": "REVIEW",
"SKIPPED_SHORT_NAME": "SHORT",
"NO_CANDIDATE": "NO CAND",
"UNRESOLVED": "OPEN",
"NONE": "NONE"
},
"status": {
"AUTO_PROMOTED": "Auto Promoted",
"MANUAL_CONFIRMED": "Manual Confirmed",
"DIRECT_PARENT_MATCH": "Direct Parent Match",
"REVIEW_REQUIRED": "Review Required",
"SKIPPED_SHORT_NAME": "Skipped: Short Name",
"NO_CANDIDATE": "No Candidate",
"UNRESOLVED": "Unresolved"
},
"reasons": {
"shortName": "Normalized name is shorter than 4 characters",
"directParentMatch": "A direct parent vessel is already included in the group",
"noCandidate": "No candidate could be generated"
},
"reference": {
"shipOnly": "Only ship candidates are used for confirm and 24-hour exclusion. Gear remains reference-only for replay comparison.",
"reviewDriven": "When parent review is active, this panel becomes reference-only. Actual overlay visibility follows the state of the right-side parent review panel.",
"referenceGear": "Reference Gear"
},
"candidate": {
"hoverHint": "Hover a candidate card to compare that vessel's full track and current replay movement more clearly.",
"trackReady": "Track Ready",
"trackMissing": "No Track",
"totalScore": "Total",
"nationalityBonusApplied": "Nationality +{{value}}%",
"nationalityBonusNone": "No nationality bonus",
"evidenceConfidence": "Evidence {{value}}%",
"emptyThreshold": "No candidates at or above {{score}}%.",
"labelActive": "Label",
"groupExcludedUntil": "Group Excluded · {{value}}",
"globalExcluded": "Global Excl.",
"trackWindow": "Observed",
"overlapWindow": "Overlap",
"inZoneWindow": "In zone",
"scoreWindow": "Score win.",
"trackCoverage": "Track adj.",
"visitCoverage": "Visit adj.",
"activityCoverage": "Activity adj.",
"proxCoverage": "Prox adj."
},
"help": {
"title": "Parent Review Guide",
"intro": "All scores are shown as 0-100%. The final candidate score is built from the components below, and reviewers should use both the candidate cards and replay comparison together.",
"close": "Close",
"scoreTitle": "Scoring",
"scoreScaleLabel": "Display scale",
"scoreScaleDesc": "Each candidate metric is stored as 0.0-1.0 internally and displayed as 0-100%.",
"formulaLabel": "Final score formula",
"formulaDesc": "Corr 40% + Name 15% + Track 15% + Visit 10% + Proximity 5% + Activity 5% + Stability 10% + Registry bonus 5%. After that, if the pre-bonus score is at least 30% and MMSI starts with 412/413, a +15% nationality bonus is added at the very end.",
"nameScoreLabel": "Name score",
"nameScoreDesc": "100% for raw uppercase exact match, 80% for normalized exact match after removing spaces/`_`/`-`/`%`, 50% for prefix or contains match, 30% when only the pure alphabetic portion matches after removing digits, and 0% otherwise. Normalized comparison uses the gear-group name against candidate AIS/registry names.",
"corrLabel": "Corr",
"corrDesc": "Uses the current_score from the default correlation model directly. This is the base linkage score between the group and the vessel candidate.",
"trackLabel": "Track",
"trackDesc": "Compares the last 6 hours of gear-polygon center movement and vessel track with DTW. Near 0m average distance approaches 100%; 10km or more approaches 0%. Short observations are reduced afterward by a coverage adjustment based on observed points and span.",
"coverageLabel": "Coverage adjustment",
"coverageDesc": "Track, visit, proximity, and activity are discounted when the observed track, overlap window, or in-zone stay is too short. The candidate card shows this as Observed/Overlap/In zone plus the adjustment rows.",
"visitLabel": "Visit",
"visitDesc": "Average visit_score from raw metrics over the last 6 hours. It rises when the vessel repeatedly visits the group area, but short in-zone coverage lowers the effective value.",
"proxLabel": "Proximity",
"proxDesc": "Average proximity_ratio from raw metrics over the last 6 hours. It rises when the vessel stays physically close over aligned observations, but very short tracks are reduced by the track coverage adjustment.",
"activityLabel": "Activity",
"activityDesc": "Average activity_sync from raw metrics over the last 6 hours. It reflects how similarly movement and working patterns evolve together, and is reduced when in-zone coverage is too short.",
"stabilityLabel": "Stability",
"stabilityDesc": "Computed as default correlation streak_count divided by 6, then clamped to 100%. It rises when the same top candidate persists across cycles.",
"bonusLabel": "Bonuses",
"bonusDesc": "A registry-matched vessel gets a fixed +5%. A 412/413 MMSI gets +15% only when the pre-bonus score is already at least 30%.",
"summaryLabel": "Top / Margin / Stable cycles",
"summaryDesc": "Top score is the final score of the #1 candidate for the group, margin is the gap between #1 and #2, and stable cycles counts how many consecutive cycles the same top MMSI remained on top.",
"filterTitle": "Filters",
"filterSortLabel": "Sort",
"filterSortDesc": "Reorders the queue by default order, score, gear count, candidate count, fishing-zone distance, or name.",
"filterMemberLabel": "Min gear count",
"filterMemberDesc": "Only groups with at least this many gear members remain in the review queue. Default is 2.",
"filterScoreLabel": "Min score",
"filterScoreDesc": "Only groups whose top score is at or above this threshold remain in the list and on the map. Candidate cards are also limited to 30%+ scores in the current UI.",
"filterSearchLabel": "Search",
"filterSearchDesc": "Matches input text while ignoring spaces and case. Groups remain visible when the query is contained in the group name, zone name, or suggested parent name.",
"filterSpatialLabel": "Map area",
"filterSpatialDesc": "Start drawing, click the map to create a polygon, and finish to keep only groups inside that area. It combines with score/gear-count filters using AND logic.",
"actionTitle": "Buttons and interactions",
"actionRefreshLabel": "Refresh",
"actionRefreshDesc": "Reloads the selected group's inference, active labels/exclusions, and the review queue.",
"actionLocateLabel": "Locate",
"actionLocateDesc": "Moves the map to the actual member bounds of that `sc#` subcluster, which helps when the same name is split into far-apart clusters.",
"actionLabelLabel": "Label",
"actionLabelDesc": "Stores the selected candidate as the answer label for this group. During the chosen duration (1/3/5 days), shadow tracking rows are accumulated for model evaluation.",
"actionGroupExcludeLabel": "Group Excl.",
"actionGroupExcludeDesc": "Excludes the selected candidate only from this gear group for the chosen duration. Other groups are unaffected.",
"actionGlobalExcludeLabel": "Global Excl.",
"actionGlobalExcludeDesc": "Excludes the selected MMSI from every gear group's candidate pool for the chosen duration. Use this for AIS targets that were misclassified as vessel candidates.",
"actionHoverLabel": "Hover compare",
"actionHoverDesc": "Hovering a candidate card strongly highlights that vessel's full track and current movement in replay so you can visually compare it against the gear polygon movement."
}
},
"auth": { "auth": {
"title": "KCG Monitoring Dashboard", "title": "KCG Monitoring Dashboard",
"subtitle": "Maritime Situational Awareness", "subtitle": "Maritime Situational Awareness",

파일 보기

@ -195,6 +195,202 @@
"operator": "운영", "operator": "운영",
"yearSuffix": "년" "yearSuffix": "년"
}, },
"fleetGear": {
"fleetSection": "선단 현황 ({{count}}개)",
"fleetFallback": "선단 #{{id}}",
"inZoneSection": "조업구역내 어구 ({{count}}개)",
"outZoneSection": "비허가 어구 ({{count}}개)",
"toggleFleetSection": "선단 현황 접기/펴기",
"emptyFleet": "선단 데이터 없음",
"vesselCountCompact": "({{count}}척)",
"zoom": "이동",
"moveToFleet": "이 선단으로 지도 이동",
"moveToGroup": "이 어구 그룹으로 지도 이동",
"moveToShip": "선박으로 이동",
"moveToShipItem": "{{name}} 선박으로 이동",
"moveToGear": "어구 위치로 이동",
"moveToGearItem": "{{name}} 위치로 이동",
"shipList": "선박",
"gearList": "어구 목록",
"roleMain": "주선",
"roleSub": "구성"
},
"parentInference": {
"title": "모선 검토",
"actorLabel": "검토자",
"actorPlaceholder": "lab-ui",
"reviewQueue": "검토 대기 ({{count}}건)",
"reviewQueueFiltered": "검토 대기 ({{filtered}} / {{total}}건)",
"queueMeta": "sc#{{subClusterId}} · 후보 {{count}}건",
"emptyQueue": "대기 중인 검토가 없습니다.",
"loading": "불러오는 중...",
"emptyState": "모선 추론 데이터가 아직 없습니다.",
"filters": {
"minScore": "최소 일치율",
"minScoreValue": "{{value}}%+",
"minScoreAll": "전체",
"minMemberCount": "최소 어구 수",
"search": "검색",
"searchPlaceholder": "이름, 수역, 추천 모선 검색",
"clearSearch": "초기화",
"resetFilters": "필터 초기화",
"sort": "정렬",
"startSpatial": "지도 범위 그리기",
"finishSpatial": "범위 확정",
"clearSpatial": "범위 해제",
"spatialIdle": "지도 범위 필터가 적용되지 않았습니다.",
"spatialDrawing": "지도에서 점 {{count}}개를 찍었습니다. 마우스를 움직이면 미리보기가 보이고, 3개 이상이면 시작점 근처 클릭으로 바로 닫을 수 있습니다.",
"spatialApplied": "사용자가 그린 지도 범위 안의 어구 그룹만 표시합니다.",
"queueFilterFallback": "저장된 필터로 0건이 되어 전체 검토 대기 목록을 임시 표시 중입니다.",
"queueTopScore": "Top 점수 {{score}}",
"queueMemberCount": "{{count}}개",
"sortOptions": {
"backend": "기본 순서",
"topScore": "일치율 높은순",
"memberCount": "어구 수 많은순",
"candidateCount": "후보 수 많은순",
"zoneDistance": "조업구역 가까운순",
"name": "이름순"
}
},
"summary": {
"label": "추론",
"recommendedParent": "추천 모선",
"confidence": "신뢰도",
"topMargin": "Top/격차",
"stableCycles": "연속 안정 주기",
"statusReason": "사유",
"marginOnly": "격차",
"activeLabel": "활성 정답 라벨",
"activeUntil": "{{value}}까지",
"groupExclusions": "그룹 제외 후보"
},
"metrics": {
"corr": "상관",
"name": "이름",
"track": "궤적",
"visit": "방문",
"prox": "근접",
"activity": "활동"
},
"actions": {
"refresh": "새로고침",
"duration": "적용 기간",
"durationOption": "{{days}}일",
"label": "라벨",
"jumpSubCluster": "이동",
"cancelLabel": "라벨 해제",
"groupExclude": "그룹 제외",
"releaseGroupExclude": "그룹 해제",
"globalExclude": "전체 제외",
"releaseGlobalExclude": "전체 해제",
"otherLabelActive": "다른 후보가 이미 정답 라벨로 활성화되어 있습니다."
},
"badges": {
"AUTO_PROMOTED": "자동",
"MANUAL_CONFIRMED": "수동",
"DIRECT_PARENT_MATCH": "직접일치",
"REVIEW_REQUIRED": "검토",
"SKIPPED_SHORT_NAME": "짧음",
"NO_CANDIDATE": "후보없음",
"UNRESOLVED": "미해결",
"NONE": "없음"
},
"status": {
"AUTO_PROMOTED": "자동 승격",
"MANUAL_CONFIRMED": "수동 확정",
"DIRECT_PARENT_MATCH": "직접 모선 일치",
"REVIEW_REQUIRED": "검토 필요",
"SKIPPED_SHORT_NAME": "짧은 이름 제외",
"NO_CANDIDATE": "후보 없음",
"UNRESOLVED": "미해결"
},
"reasons": {
"shortName": "정규화 이름 길이 4 미만",
"directParentMatch": "그룹 멤버에 직접 모선이 포함됨",
"noCandidate": "후보를 생성하지 못함"
},
"reference": {
"shipOnly": "모선 확정과 24시간 제외 판단은 선박 후보만 사용합니다. 어구는 재생 비교용 참고 정보입니다.",
"reviewDriven": "모선 검토가 선택되면 이 패널은 참고 정보만 보여주고, 실제 오버레이 표시는 우측 모선 검토 패널 상태를 그대로 따릅니다.",
"referenceGear": "참고 어구"
},
"candidate": {
"hoverHint": "후보 카드에 마우스를 올리면 리플레이에서 해당 선박 항적과 현재 움직임을 강하게 비교할 수 있습니다.",
"trackReady": "항적 비교 가능",
"trackMissing": "항적 없음",
"totalScore": "전체",
"nationalityBonusApplied": "국적 가산 +{{value}}%",
"nationalityBonusNone": "국적 가산 없음",
"evidenceConfidence": "증거 {{value}}%",
"emptyThreshold": "{{score}}% 이상 후보가 없습니다.",
"labelActive": "라벨 활성",
"groupExcludedUntil": "그룹 제외 · {{value}}",
"globalExcluded": "전체 제외",
"trackWindow": "관측",
"overlapWindow": "겹침",
"inZoneWindow": "영역내",
"scoreWindow": "점수창",
"trackCoverage": "궤적 보정",
"visitCoverage": "방문 보정",
"activityCoverage": "활동 보정",
"proxCoverage": "근접 보정"
},
"help": {
"title": "모선 검토 가이드",
"intro": "각 점수는 0~100%로 표시됩니다. 최종 후보 점수는 아래 항목을 합산해 계산하고, 검토자는 우측 후보 카드와 리플레이 비교를 함께 사용합니다.",
"close": "닫기",
"scoreTitle": "점수 기준",
"scoreScaleLabel": "표시 단위",
"scoreScaleDesc": "후보 카드의 각 수치는 0.0~1.0 내부 점수를 0~100%로 변환해 보여줍니다.",
"formulaLabel": "전체 점수 산식",
"formulaDesc": "상관 40% + 이름 15% + 궤적 15% + 방문 10% + 근접 5% + 활동 5% + 안정성 10% + 등록보너스 5%를 합산합니다. 그 뒤 pre-bonus 점수가 30% 이상이고 MMSI가 412/413으로 시작하면 국적 가산 +15%를 마지막에 후가산합니다.",
"nameScoreLabel": "이름 점수",
"nameScoreDesc": "원문을 대문자로 본 완전일치면 100%, 공백/`_`/`-`/`%` 제거 후 정규화 일치면 80%, prefix 또는 contains 일치면 50%, 숫자를 제거한 순수 문자 기준으로만 같으면 30%, 그 외는 0%입니다. 정규화 비교는 어구 그룹 이름과 후보 AIS/registry 이름을 기준으로 합니다.",
"corrLabel": "상관",
"corrDesc": "기본 correlation model의 current_score를 그대로 사용합니다. 해당 어구 그룹과 후보 선박이 기존 상관 모델에서 얼마나 강하게 연결됐는지의 기본 점수입니다.",
"trackLabel": "궤적",
"trackDesc": "최근 6시간의 어구 폴리곤 중심 이동과 선박 항적을 DTW 기반으로 비교합니다. 평균 거리 0m에 가까울수록 100%, 평균 거리 10km 이상이면 0%에 수렴합니다. 다만 관측 포인트 수와 관측 시간폭이 짧으면 coverage 보정으로 실제 반영치는 더 낮아집니다.",
"coverageLabel": "Coverage 보정",
"coverageDesc": "짧은 항적, 짧은 겹침, 짧은 영역내 체류가 과대평가되지 않도록 궤적/방문/근접/활동에 별도 보정 계수를 곱합니다. 후보 카드의 `관측/겹침/영역내`와 `XX 보정` 항목이 이 근거입니다.",
"visitLabel": "방문",
"visitDesc": "최근 6시간 raw metrics의 visit_score 평균입니다. 선박이 해당 어구 그룹 주변을 반복 방문할수록 높아집니다. 단, 영역내 포인트 수와 체류 시간이 짧으면 coverage 보정으로 낮아집니다.",
"proxLabel": "근접",
"proxDesc": "최근 6시간 raw metrics의 proximity_ratio 평균입니다. 같은 시계열 기준으로 가까이 붙어 있던 비율이 높을수록 올라갑니다. 단, 짧은 관측은 궤적 coverage 보정으로 그대로 100%를 유지하지 못합니다.",
"activityLabel": "활동",
"activityDesc": "최근 6시간 raw metrics의 activity_sync 평균입니다. 이동/조업 패턴이 함께 움직인 정도를 반영합니다. 영역내 관측이 짧으면 activity coverage 보정으로 반영치를 낮춥니다.",
"stabilityLabel": "안정성",
"stabilityDesc": "기본 correlation model의 streak_count를 6으로 나눈 뒤 100%로 clamp 합니다. 같은 후보가 여러 cycle 연속 유지될수록 올라갑니다.",
"bonusLabel": "보너스",
"bonusDesc": "registry 선박으로 식별되면 +5% 고정 가산, MMSI 412/413 후보는 pre-bonus 점수 30% 이상일 때만 +15%를 마지막에 추가합니다.",
"summaryLabel": "Top/격차/안정 주기",
"summaryDesc": "Top 점수는 현재 그룹의 1위 후보 최종 점수, 격차는 1위와 2위의 차이, 연속 안정 주기는 같은 1위 MMSI가 연속 유지된 cycle 수입니다.",
"filterTitle": "필터 사용법",
"filterSortLabel": "정렬",
"filterSortDesc": "기본 순서, 일치율, 어구 수, 후보 수, 조업구역 거리, 이름 기준으로 검토 대기 목록을 재배열합니다.",
"filterMemberLabel": "최소 어구 수",
"filterMemberDesc": "해당 수 이상 멤버를 가진 어구 그룹만 검토 대기에 남깁니다. 기본값은 2입니다.",
"filterScoreLabel": "최소 일치율",
"filterScoreDesc": "Top 점수가 지정한 값 이상인 그룹만 목록과 지도에 남깁니다. 현재 UI 후보 카드도 30% 이상 후보만 보여줍니다.",
"filterSearchLabel": "검색",
"filterSearchDesc": "입력한 텍스트를 공백 무관, 대소문자 무관으로 비교합니다. 그룹 이름, 수역명, 추천 모선 이름에 포함되면 목록과 지도에 남깁니다.",
"filterSpatialLabel": "지도 범위",
"filterSpatialDesc": "범위 시작 후 지도를 클릭해 다각형을 그리고, 완료를 누르면 그 범위 안의 어구 그룹만 검토 대기에 남깁니다. 최소 일치율/어구 수와 AND 조건으로 함께 적용됩니다.",
"actionTitle": "버튼과 동작",
"actionRefreshLabel": "새로고침",
"actionRefreshDesc": "현재 선택 그룹의 추론 결과, 활성 라벨/제외 상태, 검토 대기 목록을 다시 불러옵니다.",
"actionLocateLabel": "이동",
"actionLocateDesc": "해당 `sc#` 서브클러스터의 실제 멤버 bounds로 지도를 이동시켜, 멀리 떨어진 클러스터를 바로 찾을 수 있게 합니다.",
"actionLabelLabel": "라벨",
"actionLabelDesc": "선택한 후보를 이 어구 그룹의 정답 라벨로 기록합니다. 기간(1/3/5일) 동안 별도 tracking row가 쌓여 모델 평가용 백데이터로 사용됩니다.",
"actionGroupExcludeLabel": "그룹 제외",
"actionGroupExcludeDesc": "선택한 후보를 현재 어구 그룹에서만 기간 동안 제외합니다. 다른 어구 그룹의 후보군에는 영향을 주지 않습니다.",
"actionGlobalExcludeLabel": "전체 제외",
"actionGlobalExcludeDesc": "선택한 MMSI를 모든 어구 그룹의 후보군에서 기간 동안 제외합니다. 패턴 기반 이름이 아니어서 선박으로 오분류된 AIS를 제거할 때 사용합니다.",
"actionHoverLabel": "호버 비교",
"actionHoverDesc": "후보 카드에 마우스를 올리면 리플레이에서 해당 후보 선박의 전체 항적과 현재 움직임이 강하게 강조되어, 어구 폴리곤 중심 이동과 시각적으로 비교할 수 있습니다."
}
},
"auth": { "auth": {
"title": "KCG 모니터링 대시보드", "title": "KCG 모니터링 대시보드",
"subtitle": "해양 상황 인식 시스템", "subtitle": "해양 상황 인식 시스템",

파일 보기

@ -58,6 +58,20 @@ export interface MemberInfo {
isParent: boolean; isParent: boolean;
} }
export interface ParentInferenceSummary {
status: string;
normalizedParentName: string | null;
selectedParentMmsi: string | null;
selectedParentName: string | null;
confidence: number | null;
decisionSource: string | null;
topScore: number | null;
scoreMargin: number | null;
stableCycles: number | null;
skipReason: string | null;
statusReason: string | null;
}
export interface GroupPolygonDto { export interface GroupPolygonDto {
groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE'; groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE';
groupKey: string; groupKey: string;
@ -73,7 +87,9 @@ export interface GroupPolygonDto {
zoneName: string | null; zoneName: string | null;
members: MemberInfo[]; members: MemberInfo[];
color: string; color: string;
resolution?: '1h' | '6h'; resolution?: '1h' | '1h-fb' | '6h';
candidateCount?: number | null;
parentInference?: ParentInferenceSummary | null;
} }
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> { export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {
@ -134,6 +150,376 @@ export async function fetchGroupCorrelations(
return res.json(); return res.json();
} }
/* ── Parent Inference Review Types ───────────────────────────── */
export interface ParentInferenceCandidate {
candidateMmsi: string;
candidateName: string;
candidateVesselId: number | null;
rank: number;
candidateSource: string;
finalScore: number | null;
baseCorrScore: number | null;
nameMatchScore: number | null;
trackSimilarityScore: number | null;
visitScore6h: number | null;
proximityScore6h: number | null;
activitySyncScore6h: number | null;
stabilityScore: number | null;
registryBonus: number | null;
marginFromTop: number | null;
trackAvailable: boolean | null;
evidence: Record<string, unknown>;
}
export interface GroupParentInferenceItem {
groupType: GroupPolygonDto['groupType'];
groupKey: string;
groupLabel: string;
subClusterId: number;
snapshotTime: string;
zoneName: string | null;
memberCount: number | null;
resolution: GroupPolygonDto['resolution'];
candidateCount: number | null;
parentInference: ParentInferenceSummary | null;
candidates?: ParentInferenceCandidate[];
evidenceSummary?: Record<string, unknown>;
}
export interface ParentInferenceReviewResponse {
count: number;
items: GroupParentInferenceItem[];
}
export interface GroupParentInferenceResponse {
groupKey: string;
count: number;
items: GroupParentInferenceItem[];
}
export interface ParentInferenceReviewRequest {
action: 'CONFIRM' | 'REJECT' | 'RESET';
selectedParentMmsi?: string;
actor: string;
comment?: string;
}
export async function fetchParentInferenceReview(
status = 'REVIEW_REQUIRED',
limit = 100,
): Promise<ParentInferenceReviewResponse> {
const res = await fetch(
`${API_BASE}/vessel-analysis/groups/parent-inference/review?status=${encodeURIComponent(status)}&limit=${limit}`,
{ headers: { accept: 'application/json' } },
);
if (!res.ok) return { count: 0, items: [] };
return res.json();
}
export async function fetchGroupParentInference(groupKey: string): Promise<GroupParentInferenceResponse> {
const res = await fetch(
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/parent-inference`,
{ headers: { accept: 'application/json' } },
);
if (!res.ok) return { groupKey, count: 0, items: [] };
return res.json();
}
export async function reviewGroupParentInference(
groupKey: string,
subClusterId: number,
payload: ParentInferenceReviewRequest,
): Promise<{ groupKey: string; subClusterId: number; action: string; item: GroupParentInferenceItem | null }> {
const res = await fetch(
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/parent-inference/${subClusterId}/review`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(payload),
},
);
if (!res.ok) {
let message = `parent inference review failed: ${res.status}`;
try {
const data = await res.json() as { error?: string };
if (data.error) message = data.error;
} catch {
// ignore JSON parse failure
}
throw new Error(message);
}
return res.json();
}
export interface ParentCandidateExclusion {
id: number;
scopeType: 'GROUP' | 'GLOBAL';
groupKey: string | null;
subClusterId: number | null;
candidateMmsi: string;
reasonType: 'GROUP_WRONG_PARENT' | 'GLOBAL_NOT_PARENT_TARGET';
durationDays: number | null;
activeFrom: string;
activeUntil: string | null;
releasedAt: string | null;
releasedBy: string | null;
actor: string;
comment: string | null;
active: boolean;
metadata: Record<string, unknown>;
}
export interface ParentLabelSession {
id: number;
groupKey: string;
subClusterId: number;
labelParentMmsi: string;
labelParentName: string | null;
labelParentVesselId: number | null;
durationDays: number;
status: 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
activeFrom: string;
activeUntil: string;
actor: string;
comment: string | null;
anchorSnapshotTime: string | null;
anchorCenterLat: number | null;
anchorCenterLon: number | null;
anchorMemberCount: number | null;
active: boolean;
metadata: Record<string, unknown>;
}
export interface ParentLabelTrackingCycle {
id: number;
labelSessionId: number;
observedAt: string;
candidateSnapshotObservedAt: string | null;
autoStatus: string | null;
topCandidateMmsi: string | null;
topCandidateName: string | null;
topCandidateScore: number | null;
topCandidateMargin: number | null;
candidateCount: number | null;
labeledCandidatePresent: boolean;
labeledCandidateRank: number | null;
labeledCandidateScore: number | null;
labeledCandidatePreBonusScore: number | null;
labeledCandidateMarginFromTop: number | null;
matchedTop1: boolean;
matchedTop3: boolean;
evidenceSummary: Record<string, unknown>;
}
export interface GroupParentLabelSessionRequest {
selectedParentMmsi: string;
durationDays: 1 | 3 | 5;
actor: string;
comment?: string;
}
export interface GroupParentCandidateExclusionRequest {
candidateMmsi: string;
durationDays: 1 | 3 | 5;
actor: string;
comment?: string;
}
export interface GlobalParentCandidateExclusionRequest {
candidateMmsi: string;
actor: string;
comment?: string;
}
export interface ParentWorkflowActionRequest {
actor: string;
comment?: string;
}
export interface ParentCandidateExclusionListResponse {
count: number;
items: ParentCandidateExclusion[];
}
export interface ParentLabelSessionListResponse {
count: number;
items: ParentLabelSession[];
}
export interface ParentLabelTrackingResponse {
labelSessionId: number;
count: number;
items: ParentLabelTrackingCycle[];
}
async function parseWorkflowError(res: Response, fallback: string): Promise<never> {
let message = fallback;
try {
const data = await res.json() as { error?: string };
if (data.error) {
message = data.error;
}
} catch {
// ignore JSON parse failure
}
throw new Error(message);
}
export async function createGroupParentLabelSession(
groupKey: string,
subClusterId: number,
payload: GroupParentLabelSessionRequest,
): Promise<{ groupKey: string; subClusterId: number; action: string; item: ParentLabelSession | null }> {
const res = await fetch(
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/parent-inference/${subClusterId}/label-sessions`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(payload),
},
);
if (!res.ok) {
return parseWorkflowError(res, `parent label session failed: ${res.status}`);
}
return res.json();
}
export async function createGroupCandidateExclusion(
groupKey: string,
subClusterId: number,
payload: GroupParentCandidateExclusionRequest,
): Promise<{ groupKey: string; subClusterId: number; action: string; item: ParentCandidateExclusion | null }> {
const res = await fetch(
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/parent-inference/${subClusterId}/candidate-exclusions`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(payload),
},
);
if (!res.ok) {
return parseWorkflowError(res, `group candidate exclusion failed: ${res.status}`);
}
return res.json();
}
export async function createGlobalCandidateExclusion(
payload: GlobalParentCandidateExclusionRequest,
): Promise<{ action: string; item: ParentCandidateExclusion | null }> {
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/candidate-exclusions/global`, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!res.ok) {
return parseWorkflowError(res, `global candidate exclusion failed: ${res.status}`);
}
return res.json();
}
export async function releaseCandidateExclusion(
exclusionId: number,
payload: ParentWorkflowActionRequest,
): Promise<{ action: string; item: ParentCandidateExclusion | null }> {
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/candidate-exclusions/${exclusionId}/release`, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!res.ok) {
return parseWorkflowError(res, `candidate exclusion release failed: ${res.status}`);
}
return res.json();
}
export async function fetchParentCandidateExclusions(params: {
scopeType?: 'GROUP' | 'GLOBAL';
groupKey?: string;
subClusterId?: number;
candidateMmsi?: string;
activeOnly?: boolean;
limit?: number;
} = {}): Promise<ParentCandidateExclusionListResponse> {
const search = new URLSearchParams();
if (params.scopeType) search.set('scopeType', params.scopeType);
if (params.groupKey) search.set('groupKey', params.groupKey);
if (params.subClusterId != null) search.set('subClusterId', String(params.subClusterId));
if (params.candidateMmsi) search.set('candidateMmsi', params.candidateMmsi);
if (params.activeOnly != null) search.set('activeOnly', String(params.activeOnly));
if (params.limit != null) search.set('limit', String(params.limit));
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/candidate-exclusions?${search.toString()}`, {
headers: { accept: 'application/json' },
});
if (!res.ok) return { count: 0, items: [] };
return res.json();
}
export async function fetchParentLabelSessions(params: {
groupKey?: string;
subClusterId?: number;
status?: 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
activeOnly?: boolean;
limit?: number;
} = {}): Promise<ParentLabelSessionListResponse> {
const search = new URLSearchParams();
if (params.groupKey) search.set('groupKey', params.groupKey);
if (params.subClusterId != null) search.set('subClusterId', String(params.subClusterId));
if (params.status) search.set('status', params.status);
if (params.activeOnly != null) search.set('activeOnly', String(params.activeOnly));
if (params.limit != null) search.set('limit', String(params.limit));
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/label-sessions?${search.toString()}`, {
headers: { accept: 'application/json' },
});
if (!res.ok) return { count: 0, items: [] };
return res.json();
}
export async function cancelParentLabelSession(
labelSessionId: number,
payload: ParentWorkflowActionRequest,
): Promise<{ action: string; item: ParentLabelSession | null }> {
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/label-sessions/${labelSessionId}/cancel`, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!res.ok) {
return parseWorkflowError(res, `label session cancel failed: ${res.status}`);
}
return res.json();
}
export async function fetchParentLabelTracking(
labelSessionId: number,
limit = 200,
): Promise<ParentLabelTrackingResponse> {
const res = await fetch(
`${API_BASE}/vessel-analysis/parent-inference/label-sessions/${labelSessionId}/tracking?limit=${limit}`,
{ headers: { accept: 'application/json' } },
);
if (!res.ok) return { labelSessionId, count: 0, items: [] };
return res.json();
}
/* ── Correlation Tracks (Prediction API) ──────────────────────── */ /* ── Correlation Tracks (Prediction API) ──────────────────────── */
export interface CorrelationTrackPoint { export interface CorrelationTrackPoint {

파일 보기

@ -37,8 +37,18 @@ export interface CenterTrailSegment {
isInterpolated: boolean; isInterpolated: boolean;
} }
export interface ReplayReviewCandidate {
mmsi: string;
name: string;
rank: number;
score: number | null;
trackAvailable: boolean;
subClusterId: number;
}
// ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ── // ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ──
const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440 const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440
const DEFAULT_AB_RANGE_MS = 2 * 60 * 60 * 1000;
// ── Module-level rAF state (outside React) ─────────────────────── // ── Module-level rAF state (outside React) ───────────────────────
let animationFrameId: number | null = null; let animationFrameId: number | null = null;
@ -52,6 +62,8 @@ interface GearReplayState {
currentTime: number; currentTime: number;
startTime: number; startTime: number;
endTime: number; endTime: number;
dataStartTime: number;
dataEndTime: number;
playbackSpeed: number; playbackSpeed: number;
// Source data (1h = primary identity polygon) // Source data (1h = primary identity polygon)
@ -84,6 +96,7 @@ interface GearReplayState {
enabledModels: Set<string>; enabledModels: Set<string>;
enabledVessels: Set<string>; enabledVessels: Set<string>;
hoveredMmsi: string | null; hoveredMmsi: string | null;
reviewCandidates: ReplayReviewCandidate[];
correlationByModel: Map<string, GearCorrelationItem[]>; correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean; showTrails: boolean;
showLabels: boolean; showLabels: boolean;
@ -111,6 +124,7 @@ interface GearReplayState {
setEnabledModels: (models: Set<string>) => void; setEnabledModels: (models: Set<string>) => void;
setEnabledVessels: (vessels: Set<string>) => void; setEnabledVessels: (vessels: Set<string>) => void;
setHoveredMmsi: (mmsi: string | null) => void; setHoveredMmsi: (mmsi: string | null) => void;
setReviewCandidates: (candidates: ReplayReviewCandidate[]) => void;
setShowTrails: (show: boolean) => void; setShowTrails: (show: boolean) => void;
setShowLabels: (show: boolean) => void; setShowLabels: (show: boolean) => void;
setFocusMode: (focus: boolean) => void; setFocusMode: (focus: boolean) => void;
@ -169,6 +183,8 @@ export const useGearReplayStore = create<GearReplayState>()(
currentTime: 0, currentTime: 0,
startTime: 0, startTime: 0,
endTime: 0, endTime: 0,
dataStartTime: 0,
dataEndTime: 0,
playbackSpeed: 1, playbackSpeed: 1,
// Source data // Source data
@ -198,6 +214,7 @@ export const useGearReplayStore = create<GearReplayState>()(
enabledModels: new Set<string>(), enabledModels: new Set<string>(),
enabledVessels: new Set<string>(), enabledVessels: new Set<string>(),
hoveredMmsi: null, hoveredMmsi: null,
reviewCandidates: [],
showTrails: true, showTrails: true,
showLabels: true, showLabels: true,
focusMode: false, focusMode: false,
@ -216,6 +233,52 @@ export const useGearReplayStore = create<GearReplayState>()(
const endTime = Date.now(); const endTime = Date.now();
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime()); const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime());
let primaryDataStartTime = Number.POSITIVE_INFINITY;
let primaryDataEndTime = 0;
let fallbackDataStartTime = Number.POSITIVE_INFINITY;
let fallbackDataEndTime = 0;
const pushPrimaryTime = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return;
primaryDataStartTime = Math.min(primaryDataStartTime, value);
primaryDataEndTime = Math.max(primaryDataEndTime, value);
};
const pushFallbackTime = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return;
fallbackDataStartTime = Math.min(fallbackDataStartTime, value);
fallbackDataEndTime = Math.max(fallbackDataEndTime, value);
};
frameTimes.forEach(pushPrimaryTime);
frameTimes6h.forEach(pushPrimaryTime);
for (const track of corrTracks) {
for (const point of track.track) {
pushFallbackTime(point.ts);
}
}
let dataStartTime = Number.isFinite(primaryDataStartTime)
? primaryDataStartTime
: fallbackDataStartTime;
let dataEndTime = Number.isFinite(primaryDataStartTime)
? primaryDataEndTime
: fallbackDataEndTime;
if (!Number.isFinite(dataStartTime) || dataStartTime <= 0) {
dataStartTime = startTime;
dataEndTime = endTime;
} else if (dataEndTime <= dataStartTime) {
const paddedStart = Math.max(startTime, dataStartTime - DEFAULT_AB_RANGE_MS / 2);
const paddedEnd = Math.min(endTime, dataStartTime + DEFAULT_AB_RANGE_MS / 2);
if (paddedEnd > paddedStart) {
dataStartTime = paddedStart;
dataEndTime = paddedEnd;
} else {
dataStartTime = startTime;
dataEndTime = endTime;
}
}
const memberTrips = buildMemberTripsData(frames, startTime); const memberTrips = buildMemberTripsData(frames, startTime);
const corrTrips = buildCorrelationTripsData(corrTracks, startTime); const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
@ -248,6 +311,8 @@ export const useGearReplayStore = create<GearReplayState>()(
snapshotRanges6h: ranges6h, snapshotRanges6h: ranges6h,
startTime, startTime,
endTime, endTime,
dataStartTime,
dataEndTime,
currentTime: startTime, currentTime: startTime,
rawCorrelationTracks: corrTracks, rawCorrelationTracks: corrTracks,
memberTripsData: memberTrips, memberTripsData: memberTrips,
@ -305,17 +370,29 @@ export const useGearReplayStore = create<GearReplayState>()(
}, },
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
setReviewCandidates: (candidates) => set({ reviewCandidates: candidates }),
setShowTrails: (show) => set({ showTrails: show }), setShowTrails: (show) => set({ showTrails: show }),
setShowLabels: (show) => set({ showLabels: show }), setShowLabels: (show) => set({ showLabels: show }),
setFocusMode: (focus) => set({ focusMode: focus }), setFocusMode: (focus) => set({ focusMode: focus }),
setShow1hPolygon: (show) => set({ show1hPolygon: show }), setShow1hPolygon: (show) => set({ show1hPolygon: show }),
setShow6hPolygon: (show) => set({ show6hPolygon: show }), setShow6hPolygon: (show) => set({ show6hPolygon: show }),
setAbLoop: (on) => { setAbLoop: (on) => {
const { startTime, endTime } = get(); const { startTime, endTime, dataStartTime, dataEndTime } = get();
if (on && startTime > 0) { if (on && startTime > 0) {
// 기본 A-B: 전체 구간의 마지막 4시간 let rangeStart = dataStartTime > 0 ? Math.max(startTime, dataStartTime) : startTime;
const dur = endTime - startTime; let rangeEnd = dataEndTime > rangeStart ? Math.min(endTime, dataEndTime) : endTime;
set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime }); if (rangeEnd <= rangeStart) {
const fallbackStart = Math.max(startTime, rangeStart - DEFAULT_AB_RANGE_MS / 2);
const fallbackEnd = Math.min(endTime, rangeStart + DEFAULT_AB_RANGE_MS / 2);
if (fallbackEnd > fallbackStart) {
rangeStart = fallbackStart;
rangeEnd = fallbackEnd;
} else {
rangeStart = startTime;
rangeEnd = endTime;
}
}
set({ abLoop: true, abA: rangeStart, abB: rangeEnd });
} else { } else {
set({ abLoop: false, abA: 0, abB: 0 }); set({ abLoop: false, abA: 0, abB: 0 });
} }
@ -358,6 +435,8 @@ export const useGearReplayStore = create<GearReplayState>()(
currentTime: 0, currentTime: 0,
startTime: 0, startTime: 0,
endTime: 0, endTime: 0,
dataStartTime: 0,
dataEndTime: 0,
playbackSpeed: 1, playbackSpeed: 1,
historyFrames: [], historyFrames: [],
historyFrames6h: [], historyFrames6h: [],
@ -381,6 +460,7 @@ export const useGearReplayStore = create<GearReplayState>()(
enabledModels: new Set<string>(), enabledModels: new Set<string>(),
enabledVessels: new Set<string>(), enabledVessels: new Set<string>(),
hoveredMmsi: null, hoveredMmsi: null,
reviewCandidates: [],
showTrails: true, showTrails: true,
showLabels: true, showLabels: true,
focusMode: false, focusMode: false,

파일 보기

@ -10,6 +10,7 @@
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",

파일 보기

@ -1,3 +1,4 @@
import { resolve } from 'node:path'
import { defineConfig, type UserConfig } from 'vite' import { defineConfig, type UserConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@ -6,6 +7,14 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig(({ mode }): UserConfig => ({ export default defineConfig(({ mode }): UserConfig => ({
plugins: [tailwindcss(), react()], plugins: [tailwindcss(), react()],
esbuild: mode === 'production' ? { drop: ['console', 'debugger'] } : {}, esbuild: mode === 'production' ? { drop: ['console', 'debugger'] } : {},
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
gearParentFlow: resolve(__dirname, 'gear-parent-flow.html'),
},
},
},
server: { server: {
proxy: { proxy: {
'/api/ais': { '/api/ais': {
@ -116,9 +125,9 @@ export default defineConfig(({ mode }): UserConfig => ({
secure: true, secure: true,
}, },
'/api/prediction/': { '/api/prediction/': {
target: 'http://192.168.1.18:8001', target: 'https://kcg.gc-si.dev',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/prediction/, '/api'), secure: true,
}, },
'/ollama': { '/ollama': {
target: 'http://localhost:11434', target: 'http://localhost:11434',

파일 보기

@ -19,6 +19,7 @@ from datetime import datetime, timezone
from typing import Optional from typing import Optional
from algorithms.polygon_builder import _get_time_bucket_age from algorithms.polygon_builder import _get_time_bucket_age
from config import qualified_table
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -26,6 +27,9 @@ logger = logging.getLogger(__name__)
# ── 상수 ────────────────────────────────────────────────────────── # ── 상수 ──────────────────────────────────────────────────────────
_EARTH_RADIUS_NM = 3440.065 _EARTH_RADIUS_NM = 3440.065
_NM_TO_M = 1852.0 _NM_TO_M = 1852.0
CORRELATION_PARAM_MODELS = qualified_table('correlation_param_models')
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
GEAR_CORRELATION_RAW_METRICS = qualified_table('gear_correlation_raw_metrics')
# ── 파라미터 모델 ───────────────────────────────────────────────── # ── 파라미터 모델 ─────────────────────────────────────────────────
@ -469,10 +473,11 @@ def _get_vessel_track(vessel_store, mmsi: str, hours: int = 6) -> list[dict]:
else recent.get('raw_sog', pd.Series(dtype=float))).fillna(0).values else recent.get('raw_sog', pd.Series(dtype=float))).fillna(0).values
cogs = (recent['cog'] if 'cog' in recent.columns cogs = (recent['cog'] if 'cog' in recent.columns
else pd.Series(0, index=recent.index)).fillna(0).values else pd.Series(0, index=recent.index)).fillna(0).values
timestamps = recent['timestamp'].tolist()
return [ return [
{'lat': float(lats[i]), 'lon': float(lons[i]), {'lat': float(lats[i]), 'lon': float(lons[i]),
'sog': float(sogs[i]), 'cog': float(cogs[i])} 'sog': float(sogs[i]), 'cog': float(cogs[i]), 'timestamp': timestamps[i]}
for i in range(len(lats)) for i in range(len(lats))
] ]
@ -724,7 +729,7 @@ def _load_active_models(conn) -> list[ModelParams]:
cur = conn.cursor() cur = conn.cursor()
try: try:
cur.execute( cur.execute(
"SELECT id, name, params FROM kcg.correlation_param_models " f"SELECT id, name, params FROM {CORRELATION_PARAM_MODELS} "
"WHERE is_active = TRUE ORDER BY is_default DESC, id ASC" "WHERE is_active = TRUE ORDER BY is_default DESC, id ASC"
) )
rows = cur.fetchall() rows = cur.fetchall()
@ -751,7 +756,7 @@ def _load_all_scores(conn) -> dict[tuple, dict]:
"SELECT model_id, group_key, sub_cluster_id, target_mmsi, " "SELECT model_id, group_key, sub_cluster_id, target_mmsi, "
"current_score, streak_count, last_observed_at, " "current_score, streak_count, last_observed_at, "
"target_type, target_name " "target_type, target_name "
"FROM kcg.gear_correlation_scores" f"FROM {GEAR_CORRELATION_SCORES}"
) )
result = {} result = {}
for row in cur.fetchall(): for row in cur.fetchall():
@ -780,7 +785,7 @@ def _batch_insert_raw(conn, batch: list[tuple]):
from psycopg2.extras import execute_values from psycopg2.extras import execute_values
execute_values( execute_values(
cur, cur,
"""INSERT INTO kcg.gear_correlation_raw_metrics f"""INSERT INTO {GEAR_CORRELATION_RAW_METRICS}
(observed_at, group_key, sub_cluster_id, target_mmsi, target_type, target_name, (observed_at, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
proximity_ratio, visit_score, activity_sync, proximity_ratio, visit_score, activity_sync,
dtw_similarity, speed_correlation, heading_coherence, dtw_similarity, speed_correlation, heading_coherence,
@ -805,7 +810,7 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
from psycopg2.extras import execute_values from psycopg2.extras import execute_values
execute_values( execute_values(
cur, cur,
"""INSERT INTO kcg.gear_correlation_scores f"""INSERT INTO {GEAR_CORRELATION_SCORES}
(model_id, group_key, sub_cluster_id, target_mmsi, target_type, target_name, (model_id, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
current_score, streak_count, freeze_state, current_score, streak_count, freeze_state,
first_observed_at, last_observed_at, updated_at) first_observed_at, last_observed_at, updated_at)
@ -817,7 +822,7 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
current_score = EXCLUDED.current_score, current_score = EXCLUDED.current_score,
streak_count = EXCLUDED.streak_count, streak_count = EXCLUDED.streak_count,
freeze_state = EXCLUDED.freeze_state, freeze_state = EXCLUDED.freeze_state,
observation_count = kcg.gear_correlation_scores.observation_count + 1, observation_count = {GEAR_CORRELATION_SCORES}.observation_count + 1,
last_observed_at = EXCLUDED.last_observed_at, last_observed_at = EXCLUDED.last_observed_at,
updated_at = EXCLUDED.updated_at""", updated_at = EXCLUDED.updated_at""",
batch, batch,

파일 보기

@ -0,0 +1,19 @@
"""어구 parent name 정규화/필터 규칙."""
from __future__ import annotations
from typing import Optional
_TRACKABLE_PARENT_MIN_LENGTH = 4
_REMOVE_TOKENS = (' ', '_', '-', '%')
def normalize_parent_name(name: Optional[str]) -> str:
value = (name or '').upper().strip()
for token in _REMOVE_TOKENS:
value = value.replace(token, '')
return value
def is_trackable_parent_name(name: Optional[str]) -> bool:
return len(normalize_parent_name(name)) >= _TRACKABLE_PARENT_MIN_LENGTH

파일 보기

@ -0,0 +1,631 @@
"""어구 모선 추론 episode continuity + prior bonus helper."""
from __future__ import annotations
import json
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Optional
from uuid import uuid4
from config import qualified_table
GEAR_GROUP_EPISODES = qualified_table('gear_group_episodes')
GEAR_GROUP_EPISODE_SNAPSHOTS = qualified_table('gear_group_episode_snapshots')
GEAR_GROUP_PARENT_CANDIDATE_SNAPSHOTS = qualified_table('gear_group_parent_candidate_snapshots')
GEAR_PARENT_LABEL_SESSIONS = qualified_table('gear_parent_label_sessions')
_ACTIVE_EPISODE_WINDOW_HOURS = 6
_EPISODE_PRIOR_WINDOW_HOURS = 24
_LINEAGE_PRIOR_WINDOW_DAYS = 7
_LABEL_PRIOR_WINDOW_DAYS = 30
_CONTINUITY_SCORE_THRESHOLD = 0.45
_MERGE_SCORE_THRESHOLD = 0.35
_CENTER_DISTANCE_THRESHOLD_NM = 12.0
_EPISODE_PRIOR_MAX = 0.05
_LINEAGE_PRIOR_MAX = 0.03
_LABEL_PRIOR_MAX = 0.07
_TOTAL_PRIOR_CAP = 0.10
def _clamp(value: float, floor: float = 0.0, ceil: float = 1.0) -> float:
return max(floor, min(ceil, value))
def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
earth_radius_nm = 3440.065
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return earth_radius_nm * 2 * math.atan2(math.sqrt(a), math.sqrt(max(0.0, 1 - a)))
def _json_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item) for item in value if item]
try:
parsed = json.loads(value)
except Exception:
return []
if isinstance(parsed, list):
return [str(item) for item in parsed if item]
return []
@dataclass
class GroupEpisodeInput:
group_key: str
normalized_parent_name: str
sub_cluster_id: int
member_mmsis: list[str]
member_count: int
center_lat: float
center_lon: float
@property
def key(self) -> tuple[str, int]:
return (self.group_key, self.sub_cluster_id)
@dataclass
class EpisodeState:
episode_id: str
lineage_key: str
group_key: str
normalized_parent_name: str
current_sub_cluster_id: int
member_mmsis: list[str]
member_count: int
center_lat: float
center_lon: float
last_snapshot_time: datetime
status: str
@dataclass
class EpisodeAssignment:
group_key: str
sub_cluster_id: int
normalized_parent_name: str
episode_id: str
continuity_source: str
continuity_score: float
split_from_episode_id: Optional[str]
merged_from_episode_ids: list[str]
member_mmsis: list[str]
member_count: int
center_lat: float
center_lon: float
@property
def key(self) -> tuple[str, int]:
return (self.group_key, self.sub_cluster_id)
@dataclass
class EpisodePlan:
assignments: dict[tuple[str, int], EpisodeAssignment]
expired_episode_ids: set[str]
merged_episode_targets: dict[str, str]
def _member_jaccard(left: Iterable[str], right: Iterable[str]) -> tuple[float, int]:
left_set = {item for item in left if item}
right_set = {item for item in right if item}
if not left_set and not right_set:
return 0.0, 0
overlap = len(left_set & right_set)
union = len(left_set | right_set)
return (overlap / union if union else 0.0), overlap
def continuity_score(current: GroupEpisodeInput, previous: EpisodeState) -> tuple[float, int, float]:
jaccard, overlap_count = _member_jaccard(current.member_mmsis, previous.member_mmsis)
distance_nm = _haversine_nm(current.center_lat, current.center_lon, previous.center_lat, previous.center_lon)
center_support = _clamp(1.0 - (distance_nm / _CENTER_DISTANCE_THRESHOLD_NM))
score = _clamp((0.75 * jaccard) + (0.25 * center_support))
return round(score, 6), overlap_count, round(distance_nm, 3)
def load_active_episode_states(conn, lineage_keys: list[str]) -> dict[str, list[EpisodeState]]:
if not lineage_keys:
return {}
cur = conn.cursor()
try:
cur.execute(
f"""
SELECT episode_id, lineage_key, group_key, normalized_parent_name,
current_sub_cluster_id, current_member_mmsis, current_member_count,
ST_Y(current_center_point) AS center_lat,
ST_X(current_center_point) AS center_lon,
last_snapshot_time, status
FROM {GEAR_GROUP_EPISODES}
WHERE lineage_key = ANY(%s)
AND status = 'ACTIVE'
AND last_snapshot_time >= NOW() - (%s * INTERVAL '1 hour')
ORDER BY lineage_key, last_snapshot_time DESC, episode_id ASC
""",
(lineage_keys, _ACTIVE_EPISODE_WINDOW_HOURS),
)
result: dict[str, list[EpisodeState]] = {}
for row in cur.fetchall():
state = EpisodeState(
episode_id=row[0],
lineage_key=row[1],
group_key=row[2],
normalized_parent_name=row[3],
current_sub_cluster_id=int(row[4] or 0),
member_mmsis=_json_list(row[5]),
member_count=int(row[6] or 0),
center_lat=float(row[7] or 0.0),
center_lon=float(row[8] or 0.0),
last_snapshot_time=row[9],
status=row[10],
)
result.setdefault(state.lineage_key, []).append(state)
return result
finally:
cur.close()
def group_to_episode_input(group: dict[str, Any], normalized_parent_name: str) -> GroupEpisodeInput:
members = group.get('members') or []
member_mmsis = sorted({str(member.get('mmsi')) for member in members if member.get('mmsi')})
member_count = len(member_mmsis)
if members:
center_lat = sum(float(member['lat']) for member in members) / len(members)
center_lon = sum(float(member['lon']) for member in members) / len(members)
else:
center_lat = 0.0
center_lon = 0.0
return GroupEpisodeInput(
group_key=group['parent_name'],
normalized_parent_name=normalized_parent_name,
sub_cluster_id=int(group.get('sub_cluster_id', 0)),
member_mmsis=member_mmsis,
member_count=member_count,
center_lat=center_lat,
center_lon=center_lon,
)
def build_episode_plan(
groups: list[GroupEpisodeInput],
previous_by_lineage: dict[str, list[EpisodeState]],
) -> EpisodePlan:
assignments: dict[tuple[str, int], EpisodeAssignment] = {}
expired_episode_ids: set[str] = set()
merged_episode_targets: dict[str, str] = {}
groups_by_lineage: dict[str, list[GroupEpisodeInput]] = {}
for group in groups:
groups_by_lineage.setdefault(group.normalized_parent_name, []).append(group)
for lineage_key, current_groups in groups_by_lineage.items():
previous_groups = previous_by_lineage.get(lineage_key, [])
qualified_matches: dict[tuple[str, int], list[tuple[EpisodeState, float, int, float]]] = {}
prior_to_currents: dict[str, list[tuple[GroupEpisodeInput, float, int, float]]] = {}
for current in current_groups:
for previous in previous_groups:
score, overlap_count, distance_nm = continuity_score(current, previous)
if score >= _CONTINUITY_SCORE_THRESHOLD or (
overlap_count > 0 and distance_nm <= _CENTER_DISTANCE_THRESHOLD_NM
):
qualified_matches.setdefault(current.key, []).append((previous, score, overlap_count, distance_nm))
prior_to_currents.setdefault(previous.episode_id, []).append((current, score, overlap_count, distance_nm))
consumed_previous_ids: set[str] = set()
assigned_current_keys: set[tuple[str, int]] = set()
for current in current_groups:
matches = sorted(
qualified_matches.get(current.key, []),
key=lambda item: (item[1], item[2], -item[3], item[0].last_snapshot_time),
reverse=True,
)
merge_candidates = [
item for item in matches
if item[1] >= _MERGE_SCORE_THRESHOLD
]
if len(merge_candidates) >= 2:
episode_id = f"ep-{uuid4().hex[:12]}"
merged_ids = [item[0].episode_id for item in merge_candidates]
assignments[current.key] = EpisodeAssignment(
group_key=current.group_key,
sub_cluster_id=current.sub_cluster_id,
normalized_parent_name=current.normalized_parent_name,
episode_id=episode_id,
continuity_source='MERGE_NEW',
continuity_score=round(max(item[1] for item in merge_candidates), 6),
split_from_episode_id=None,
merged_from_episode_ids=merged_ids,
member_mmsis=current.member_mmsis,
member_count=current.member_count,
center_lat=current.center_lat,
center_lon=current.center_lon,
)
assigned_current_keys.add(current.key)
for merged_id in merged_ids:
consumed_previous_ids.add(merged_id)
merged_episode_targets[merged_id] = episode_id
previous_ranked = sorted(
previous_groups,
key=lambda item: item.last_snapshot_time,
reverse=True,
)
for previous in previous_ranked:
if previous.episode_id in consumed_previous_ids:
continue
matches = [
item for item in prior_to_currents.get(previous.episode_id, [])
if item[0].key not in assigned_current_keys
]
if not matches:
continue
matches.sort(key=lambda item: (item[1], item[2], -item[3]), reverse=True)
current, score, _, _ = matches[0]
split_candidate_count = len(prior_to_currents.get(previous.episode_id, []))
assignments[current.key] = EpisodeAssignment(
group_key=current.group_key,
sub_cluster_id=current.sub_cluster_id,
normalized_parent_name=current.normalized_parent_name,
episode_id=previous.episode_id,
continuity_source='SPLIT_CONTINUE' if split_candidate_count > 1 else 'CONTINUED',
continuity_score=score,
split_from_episode_id=None,
merged_from_episode_ids=[],
member_mmsis=current.member_mmsis,
member_count=current.member_count,
center_lat=current.center_lat,
center_lon=current.center_lon,
)
assigned_current_keys.add(current.key)
consumed_previous_ids.add(previous.episode_id)
for current in current_groups:
if current.key in assigned_current_keys:
continue
matches = sorted(
qualified_matches.get(current.key, []),
key=lambda item: (item[1], item[2], -item[3], item[0].last_snapshot_time),
reverse=True,
)
split_from_episode_id = None
continuity_source = 'NEW'
continuity_score_value = 0.0
if matches:
best_previous, score, _, _ = matches[0]
split_from_episode_id = best_previous.episode_id
continuity_source = 'SPLIT_NEW'
continuity_score_value = score
assignments[current.key] = EpisodeAssignment(
group_key=current.group_key,
sub_cluster_id=current.sub_cluster_id,
normalized_parent_name=current.normalized_parent_name,
episode_id=f"ep-{uuid4().hex[:12]}",
continuity_source=continuity_source,
continuity_score=continuity_score_value,
split_from_episode_id=split_from_episode_id,
merged_from_episode_ids=[],
member_mmsis=current.member_mmsis,
member_count=current.member_count,
center_lat=current.center_lat,
center_lon=current.center_lon,
)
assigned_current_keys.add(current.key)
current_previous_ids = {assignment.episode_id for assignment in assignments.values() if assignment.normalized_parent_name == lineage_key}
for previous in previous_groups:
if previous.episode_id in merged_episode_targets:
continue
if previous.episode_id not in current_previous_ids:
expired_episode_ids.add(previous.episode_id)
return EpisodePlan(
assignments=assignments,
expired_episode_ids=expired_episode_ids,
merged_episode_targets=merged_episode_targets,
)
def load_episode_prior_stats(conn, episode_ids: list[str]) -> dict[tuple[str, str], dict[str, Any]]:
if not episode_ids:
return {}
cur = conn.cursor()
try:
cur.execute(
f"""
SELECT episode_id, candidate_mmsi,
COUNT(*) AS seen_count,
SUM(CASE WHEN rank = 1 THEN 1 ELSE 0 END) AS top1_count,
AVG(final_score) AS avg_score,
MAX(observed_at) AS last_seen_at
FROM {GEAR_GROUP_PARENT_CANDIDATE_SNAPSHOTS}
WHERE episode_id = ANY(%s)
AND observed_at >= NOW() - (%s * INTERVAL '1 hour')
GROUP BY episode_id, candidate_mmsi
""",
(episode_ids, _EPISODE_PRIOR_WINDOW_HOURS),
)
result: dict[tuple[str, str], dict[str, Any]] = {}
for episode_id, candidate_mmsi, seen_count, top1_count, avg_score, last_seen_at in cur.fetchall():
result[(episode_id, candidate_mmsi)] = {
'seen_count': int(seen_count or 0),
'top1_count': int(top1_count or 0),
'avg_score': float(avg_score or 0.0),
'last_seen_at': last_seen_at,
}
return result
finally:
cur.close()
def load_lineage_prior_stats(conn, lineage_keys: list[str]) -> dict[tuple[str, str], dict[str, Any]]:
if not lineage_keys:
return {}
cur = conn.cursor()
try:
cur.execute(
f"""
SELECT normalized_parent_name, candidate_mmsi,
COUNT(*) AS seen_count,
SUM(CASE WHEN rank = 1 THEN 1 ELSE 0 END) AS top1_count,
SUM(CASE WHEN rank <= 3 THEN 1 ELSE 0 END) AS top3_count,
AVG(final_score) AS avg_score,
MAX(observed_at) AS last_seen_at
FROM {GEAR_GROUP_PARENT_CANDIDATE_SNAPSHOTS}
WHERE normalized_parent_name = ANY(%s)
AND observed_at >= NOW() - (%s * INTERVAL '1 day')
GROUP BY normalized_parent_name, candidate_mmsi
""",
(lineage_keys, _LINEAGE_PRIOR_WINDOW_DAYS),
)
result: dict[tuple[str, str], dict[str, Any]] = {}
for lineage_key, candidate_mmsi, seen_count, top1_count, top3_count, avg_score, last_seen_at in cur.fetchall():
result[(lineage_key, candidate_mmsi)] = {
'seen_count': int(seen_count or 0),
'top1_count': int(top1_count or 0),
'top3_count': int(top3_count or 0),
'avg_score': float(avg_score or 0.0),
'last_seen_at': last_seen_at,
}
return result
finally:
cur.close()
def load_label_prior_stats(conn, lineage_keys: list[str]) -> dict[tuple[str, str], dict[str, Any]]:
if not lineage_keys:
return {}
cur = conn.cursor()
try:
cur.execute(
f"""
SELECT normalized_parent_name, label_parent_mmsi,
COUNT(*) AS session_count,
MAX(active_from) AS last_labeled_at
FROM {GEAR_PARENT_LABEL_SESSIONS}
WHERE normalized_parent_name = ANY(%s)
AND active_from >= NOW() - (%s * INTERVAL '1 day')
GROUP BY normalized_parent_name, label_parent_mmsi
""",
(lineage_keys, _LABEL_PRIOR_WINDOW_DAYS),
)
result: dict[tuple[str, str], dict[str, Any]] = {}
for lineage_key, candidate_mmsi, session_count, last_labeled_at in cur.fetchall():
result[(lineage_key, candidate_mmsi)] = {
'session_count': int(session_count or 0),
'last_labeled_at': last_labeled_at,
}
return result
finally:
cur.close()
def _recency_support(observed_at: Optional[datetime], now: datetime, hours: float) -> float:
if observed_at is None:
return 0.0
if observed_at.tzinfo is None:
observed_at = observed_at.replace(tzinfo=timezone.utc)
delta_hours = max(0.0, (now - observed_at.astimezone(timezone.utc)).total_seconds() / 3600.0)
return _clamp(1.0 - (delta_hours / hours))
def compute_prior_bonus_components(
observed_at: datetime,
normalized_parent_name: str,
episode_id: str,
candidate_mmsi: str,
episode_prior_stats: dict[tuple[str, str], dict[str, Any]],
lineage_prior_stats: dict[tuple[str, str], dict[str, Any]],
label_prior_stats: dict[tuple[str, str], dict[str, Any]],
) -> dict[str, float]:
episode_stats = episode_prior_stats.get((episode_id, candidate_mmsi), {})
lineage_stats = lineage_prior_stats.get((normalized_parent_name, candidate_mmsi), {})
label_stats = label_prior_stats.get((normalized_parent_name, candidate_mmsi), {})
episode_bonus = 0.0
if episode_stats:
episode_bonus = _EPISODE_PRIOR_MAX * (
0.35 * min(1.0, episode_stats.get('seen_count', 0) / 6.0)
+ 0.35 * min(1.0, episode_stats.get('top1_count', 0) / 3.0)
+ 0.15 * _clamp(float(episode_stats.get('avg_score', 0.0)))
+ 0.15 * _recency_support(episode_stats.get('last_seen_at'), observed_at, _EPISODE_PRIOR_WINDOW_HOURS)
)
lineage_bonus = 0.0
if lineage_stats:
lineage_bonus = _LINEAGE_PRIOR_MAX * (
0.30 * min(1.0, lineage_stats.get('seen_count', 0) / 12.0)
+ 0.25 * min(1.0, lineage_stats.get('top3_count', 0) / 6.0)
+ 0.20 * min(1.0, lineage_stats.get('top1_count', 0) / 3.0)
+ 0.15 * _clamp(float(lineage_stats.get('avg_score', 0.0)))
+ 0.10 * _recency_support(lineage_stats.get('last_seen_at'), observed_at, _LINEAGE_PRIOR_WINDOW_DAYS * 24.0)
)
label_bonus = 0.0
if label_stats:
label_bonus = _LABEL_PRIOR_MAX * (
0.70 * min(1.0, label_stats.get('session_count', 0) / 3.0)
+ 0.30 * _recency_support(label_stats.get('last_labeled_at'), observed_at, _LABEL_PRIOR_WINDOW_DAYS * 24.0)
)
total = min(_TOTAL_PRIOR_CAP, episode_bonus + lineage_bonus + label_bonus)
return {
'episodePriorBonus': round(episode_bonus, 6),
'lineagePriorBonus': round(lineage_bonus, 6),
'labelPriorBonus': round(label_bonus, 6),
'priorBonusTotal': round(total, 6),
}
def sync_episode_states(conn, observed_at: datetime, plan: EpisodePlan) -> None:
cur = conn.cursor()
try:
if plan.expired_episode_ids:
cur.execute(
f"""
UPDATE {GEAR_GROUP_EPISODES}
SET status = 'EXPIRED',
updated_at = %s
WHERE episode_id = ANY(%s)
""",
(observed_at, list(plan.expired_episode_ids)),
)
for previous_episode_id, merged_into_episode_id in plan.merged_episode_targets.items():
cur.execute(
f"""
UPDATE {GEAR_GROUP_EPISODES}
SET status = 'MERGED',
merged_into_episode_id = %s,
updated_at = %s
WHERE episode_id = %s
""",
(merged_into_episode_id, observed_at, previous_episode_id),
)
for assignment in plan.assignments.values():
cur.execute(
f"""
INSERT INTO {GEAR_GROUP_EPISODES} (
episode_id, lineage_key, group_key, normalized_parent_name,
current_sub_cluster_id, status, continuity_source, continuity_score,
first_seen_at, last_seen_at, last_snapshot_time,
current_member_count, current_member_mmsis, current_center_point,
split_from_episode_id, merged_from_episode_ids, metadata, updated_at
) VALUES (
%s, %s, %s, %s,
%s, 'ACTIVE', %s, %s,
%s, %s, %s,
%s, %s::jsonb, ST_SetSRID(ST_MakePoint(%s, %s), 4326),
%s, %s::jsonb, '{{}}'::jsonb, %s
)
ON CONFLICT (episode_id)
DO UPDATE SET
group_key = EXCLUDED.group_key,
normalized_parent_name = EXCLUDED.normalized_parent_name,
current_sub_cluster_id = EXCLUDED.current_sub_cluster_id,
status = 'ACTIVE',
continuity_source = EXCLUDED.continuity_source,
continuity_score = EXCLUDED.continuity_score,
last_seen_at = EXCLUDED.last_seen_at,
last_snapshot_time = EXCLUDED.last_snapshot_time,
current_member_count = EXCLUDED.current_member_count,
current_member_mmsis = EXCLUDED.current_member_mmsis,
current_center_point = EXCLUDED.current_center_point,
split_from_episode_id = COALESCE(EXCLUDED.split_from_episode_id, {GEAR_GROUP_EPISODES}.split_from_episode_id),
merged_from_episode_ids = EXCLUDED.merged_from_episode_ids,
updated_at = EXCLUDED.updated_at
""",
(
assignment.episode_id,
assignment.normalized_parent_name,
assignment.group_key,
assignment.normalized_parent_name,
assignment.sub_cluster_id,
assignment.continuity_source,
assignment.continuity_score,
observed_at,
observed_at,
observed_at,
assignment.member_count,
json.dumps(assignment.member_mmsis, ensure_ascii=False),
assignment.center_lon,
assignment.center_lat,
assignment.split_from_episode_id,
json.dumps(assignment.merged_from_episode_ids, ensure_ascii=False),
observed_at,
),
)
finally:
cur.close()
def insert_episode_snapshots(
conn,
observed_at: datetime,
plan: EpisodePlan,
snapshot_payloads: dict[tuple[str, int], dict[str, Any]],
) -> int:
if not snapshot_payloads:
return 0
rows: list[tuple[Any, ...]] = []
for key, payload in snapshot_payloads.items():
assignment = plan.assignments.get(key)
if assignment is None:
continue
rows.append((
assignment.episode_id,
assignment.normalized_parent_name,
assignment.group_key,
assignment.normalized_parent_name,
assignment.sub_cluster_id,
observed_at,
assignment.member_count,
json.dumps(assignment.member_mmsis, ensure_ascii=False),
assignment.center_lon,
assignment.center_lat,
assignment.continuity_source,
assignment.continuity_score,
json.dumps(payload.get('parentEpisodeIds') or assignment.merged_from_episode_ids, ensure_ascii=False),
payload.get('topCandidateMmsi'),
payload.get('topCandidateScore'),
payload.get('resolutionStatus'),
json.dumps(payload.get('metadata') or {}, ensure_ascii=False),
))
if not rows:
return 0
cur = conn.cursor()
try:
from psycopg2.extras import execute_values
execute_values(
cur,
f"""
INSERT INTO {GEAR_GROUP_EPISODE_SNAPSHOTS} (
episode_id, lineage_key, group_key, normalized_parent_name, sub_cluster_id,
observed_at, member_count, member_mmsis, center_point,
continuity_source, continuity_score, parent_episode_ids,
top_candidate_mmsi, top_candidate_score, resolution_status, metadata
) VALUES %s
ON CONFLICT (episode_id, observed_at) DO NOTHING
""",
rows,
template="(%s, %s, %s, %s, %s, %s, %s, %s::jsonb, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s, %s::jsonb, %s, %s, %s, %s::jsonb)",
page_size=200,
)
return len(rows)
finally:
cur.close()

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -15,6 +15,8 @@ from zoneinfo import ZoneInfo
import pandas as pd import pandas as pd
from algorithms.gear_name_rules import is_trackable_parent_name
try: try:
from shapely.geometry import MultiPoint, Point from shapely.geometry import MultiPoint, Point
from shapely import wkt as shapely_wkt from shapely import wkt as shapely_wkt
@ -197,6 +199,8 @@ def detect_gear_groups(
continue continue
parent_raw = (m.group(1) or name).strip() parent_raw = (m.group(1) or name).strip()
if not is_trackable_parent_name(parent_raw):
continue
parent_key = _normalize_parent(parent_raw) parent_key = _normalize_parent(parent_raw)
# 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태) # 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태)
if parent_key not in parent_display or ' ' not in parent_raw: if parent_key not in parent_display or ' ' not in parent_raw:
@ -414,6 +418,16 @@ def build_all_group_snapshots(
# ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ──── # ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ────
gear_groups = detect_gear_groups(vessel_store, now=now) gear_groups = detect_gear_groups(vessel_store, now=now)
# parent_name 기준 전체 1h 활성 멤버 합산 (서브클러스터 분리 전)
parent_active_1h: dict[str, int] = {}
for group in gear_groups:
pn = group['parent_name']
cnt = sum(
1 for gm in group['members']
if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC
)
parent_active_1h[pn] = parent_active_1h.get(pn, 0) + cnt
for group in gear_groups: for group in gear_groups:
parent_name: str = group['parent_name'] parent_name: str = group['parent_name']
parent_mmsi: Optional[str] = group['parent_mmsi'] parent_mmsi: Optional[str] = group['parent_mmsi']
@ -422,17 +436,15 @@ def build_all_group_snapshots(
if not gear_members: if not gear_members:
continue continue
# ── 1h 활성 멤버 필터 ── # ── 1h 활성 멤버 필터 (이 서브클러스터 내) ──
active_members_1h = [ active_members_1h = [
gm for gm in gear_members gm for gm in gear_members
if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC
] ]
active_count_1h = len(active_members_1h)
# fallback: 1h < 2이면 time_bucket 최신 2개 유지 (리플레이/일치율 추적용) # fallback: 서브클러스터 내 1h < 2이면 time_bucket 최신 2개 유지
# 라이브 현황에서는 active_count_1h로 필터 (fallback 그룹 제외)
display_members_1h = active_members_1h display_members_1h = active_members_1h
if active_count_1h < 2 and len(gear_members) >= 2: if len(active_members_1h) < 2 and len(gear_members) >= 2:
sorted_by_age = sorted( sorted_by_age = sorted(
gear_members, gear_members,
key=lambda gm: _get_time_bucket_age(gm.get('mmsi'), all_positions, now), key=lambda gm: _get_time_bucket_age(gm.get('mmsi'), all_positions, now),
@ -447,8 +459,9 @@ def build_all_group_snapshots(
display_members_6h = gear_members display_members_6h = gear_members
# ── resolution별 스냅샷 생성 ── # ── resolution별 스냅샷 생성 ──
# 1h-fb: fallback (실제 1h 활성 < 2) — 리플레이/일치율 추적용, 라이브 현황에서 제외 # 1h-fb: parent_name 전체 1h 활성 < 2 → 리플레이/일치율 추적용, 라이브 현황에서 제외
res_1h = '1h' if active_count_1h >= 2 else '1h-fb' # parent_name 전체 기준으로 판단 (서브클러스터 분리로 개별 멤버가 적어져도 그룹 전체가 활성이면 1h)
res_1h = '1h' if parent_active_1h.get(parent_name, 0) >= 2 else '1h-fb'
for resolution, members_for_snap in [(res_1h, display_members_1h), ('6h', display_members_6h)]: for resolution, members_for_snap in [(res_1h, display_members_1h), ('6h', display_members_6h)]:
if len(members_for_snap) < 2: if len(members_for_snap) < 2:
continue continue

파일 보기

@ -1,9 +1,13 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo
import numpy as np import numpy as np
_KST = ZoneInfo('Asia/Seoul')
import pandas as pd import pandas as pd
from time_bucket import compute_initial_window_start, compute_safe_bucket
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -114,19 +118,21 @@ class VesselStore:
self._tracks[str(mmsi)] = group.reset_index(drop=True) self._tracks[str(mmsi)] = group.reset_index(drop=True)
# last_bucket 설정 — incremental fetch 시작점 # last_bucket 설정 — incremental fetch 시작점
# snpdb time_bucket은 tz-naive KST이므로 UTC 변환하지 않고 그대로 유지
if 'time_bucket' in df_all.columns and not df_all['time_bucket'].dropna().empty: if 'time_bucket' in df_all.columns and not df_all['time_bucket'].dropna().empty:
max_bucket = pd.to_datetime(df_all['time_bucket'].dropna()).max() max_bucket = pd.to_datetime(df_all['time_bucket'].dropna()).max()
if hasattr(max_bucket, 'to_pydatetime'): if hasattr(max_bucket, 'to_pydatetime'):
max_bucket = max_bucket.to_pydatetime() max_bucket = max_bucket.to_pydatetime()
if isinstance(max_bucket, datetime) and max_bucket.tzinfo is None: if isinstance(max_bucket, datetime) and max_bucket.tzinfo is not None:
max_bucket = max_bucket.replace(tzinfo=timezone.utc) max_bucket = max_bucket.replace(tzinfo=None)
self._last_bucket = max_bucket self._last_bucket = max_bucket
elif 'timestamp' in df_all.columns and not df_all['timestamp'].dropna().empty: elif 'timestamp' in df_all.columns and not df_all['timestamp'].dropna().empty:
max_ts = pd.to_datetime(df_all['timestamp'].dropna()).max() max_ts = pd.to_datetime(df_all['timestamp'].dropna()).max()
if hasattr(max_ts, 'to_pydatetime'): if hasattr(max_ts, 'to_pydatetime'):
max_ts = max_ts.to_pydatetime() max_ts = max_ts.to_pydatetime()
if isinstance(max_ts, datetime) and max_ts.tzinfo is None: # timestamp는 UTC aware → KST wall-clock naive로 변환
max_ts = max_ts.replace(tzinfo=timezone.utc) if isinstance(max_ts, datetime) and max_ts.tzinfo is not None:
max_ts = max_ts.astimezone(_KST).replace(tzinfo=None)
self._last_bucket = max_ts self._last_bucket = max_ts
vessel_count = len(self._tracks) vessel_count = len(self._tracks)
@ -159,10 +165,11 @@ class VesselStore:
mmsi_str = str(mmsi) mmsi_str = str(mmsi)
if mmsi_str in self._tracks: if mmsi_str in self._tracks:
combined = pd.concat([self._tracks[mmsi_str], group], ignore_index=True) combined = pd.concat([self._tracks[mmsi_str], group], ignore_index=True)
combined = combined.drop_duplicates(subset=['timestamp']) combined = combined.sort_values(['timestamp', 'time_bucket'])
combined = combined.drop_duplicates(subset=['timestamp'], keep='last')
self._tracks[mmsi_str] = combined.reset_index(drop=True) self._tracks[mmsi_str] = combined.reset_index(drop=True)
else: else:
self._tracks[mmsi_str] = group.reset_index(drop=True) self._tracks[mmsi_str] = group.sort_values(['timestamp', 'time_bucket']).reset_index(drop=True)
if 'time_bucket' in group.columns and not group['time_bucket'].empty: if 'time_bucket' in group.columns and not group['time_bucket'].empty:
bucket_vals = pd.to_datetime(group['time_bucket'].dropna()) bucket_vals = pd.to_datetime(group['time_bucket'].dropna())
@ -171,8 +178,8 @@ class VesselStore:
if new_buckets: if new_buckets:
latest = max(new_buckets) latest = max(new_buckets)
if isinstance(latest, datetime) and latest.tzinfo is None: if isinstance(latest, datetime) and latest.tzinfo is not None:
latest = latest.replace(tzinfo=timezone.utc) latest = latest.replace(tzinfo=None)
if self._last_bucket is None or latest > self._last_bucket: if self._last_bucket is None or latest > self._last_bucket:
self._last_bucket = latest self._last_bucket = latest
@ -186,6 +193,8 @@ class VesselStore:
"""Remove track points older than N hours and evict empty MMSI entries.""" """Remove track points older than N hours and evict empty MMSI entries."""
import datetime as _dt import datetime as _dt
safe_bucket = compute_safe_bucket()
cutoff_bucket = compute_initial_window_start(hours, safe_bucket)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
cutoff_aware = now - _dt.timedelta(hours=hours) cutoff_aware = now - _dt.timedelta(hours=hours)
cutoff_naive = cutoff_aware.replace(tzinfo=None) cutoff_naive = cutoff_aware.replace(tzinfo=None)
@ -195,6 +204,10 @@ class VesselStore:
for mmsi in list(self._tracks.keys()): for mmsi in list(self._tracks.keys()):
df = self._tracks[mmsi] df = self._tracks[mmsi]
if 'time_bucket' in df.columns and not df['time_bucket'].dropna().empty:
bucket_col = pd.to_datetime(df['time_bucket'], errors='coerce')
mask = bucket_col >= pd.Timestamp(cutoff_bucket)
else:
ts_col = df['timestamp'] ts_col = df['timestamp']
# Handle tz-aware and tz-naive timestamps uniformly # Handle tz-aware and tz-naive timestamps uniformly
if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None: if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None:
@ -210,10 +223,11 @@ class VesselStore:
after_total = sum(len(v) for v in self._tracks.values()) after_total = sum(len(v) for v in self._tracks.values())
logger.info( logger.info(
'eviction complete: removed %d points, evicted %d mmsis (threshold=%dh)', 'eviction complete: removed %d points, evicted %d mmsis (threshold=%dh, cutoff_bucket=%s)',
before_total - after_total, before_total - after_total,
len(evicted_mmsis), len(evicted_mmsis),
hours, hours,
cutoff_bucket,
) )
def refresh_static_info(self) -> None: def refresh_static_info(self) -> None:

파일 보기

@ -9,6 +9,8 @@
- MarineTraffic AIS/GNSS 스푸핑 가이드 - MarineTraffic AIS/GNSS 스푸핑 가이드
""" """
from config import settings
# ── 역할 정의 ── # ── 역할 정의 ──
ROLE_DEFINITION = """당신은 대한민국 해양경찰청의 **해양상황 분석 AI 어시스턴트**입니다. ROLE_DEFINITION = """당신은 대한민국 해양경찰청의 **해양상황 분석 AI 어시스턴트**입니다.
Python AI 분석 파이프라인(7단계 + 8 알고리즘) 실시간 결과를 기반으로, Python AI 분석 파이프라인(7단계 + 8 알고리즘) 실시간 결과를 기반으로,
@ -406,6 +408,8 @@ snpdb (AIS 원본 항적) → vessel_store (인메모리 24h) → 7단계 파이
- 집계 데이터( 척인지) 이미 시스템 프롬프트에 있으므로 도구 불필요 - 집계 데이터( 척인지) 이미 시스템 프롬프트에 있으므로 도구 불필요
- 대부분의 질문은 kcgdb로 충분 snpdb 직접 조회는 특수한 항적 분석에만 사용""" - 대부분의 질문은 kcgdb로 충분 snpdb 직접 조회는 특수한 항적 분석에만 사용"""
DB_SCHEMA_AND_TOOLS = DB_SCHEMA_AND_TOOLS.replace('kcg.', f'{settings.KCGDB_SCHEMA}.')
# ── 지식 섹션 레지스트리 (키워드 → 상세 텍스트) ── # ── 지식 섹션 레지스트리 (키워드 → 상세 텍스트) ──
KNOWLEDGE_SECTIONS: dict[str, str] = { KNOWLEDGE_SECTIONS: dict[str, str] = {

파일 보기

@ -5,7 +5,14 @@ import logging
import re import re
from typing import Optional from typing import Optional
from config import qualified_table
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
VESSEL_ANALYSIS_RESULTS = qualified_table('vessel_analysis_results')
FLEET_VESSELS = qualified_table('fleet_vessels')
GROUP_POLYGON_SNAPSHOTS = qualified_table('group_polygon_snapshots')
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
CORRELATION_PARAM_MODELS = qualified_table('correlation_param_models')
# ── 사전 쿼리 패턴 (키워드 기반, 1회 왕복으로 해결) ── # ── 사전 쿼리 패턴 (키워드 기반, 1회 왕복으로 해결) ──
@ -117,8 +124,8 @@ def execute_prequery(params: dict) -> str:
v.cluster_id, v.cluster_size, v.dist_to_baseline_nm, v.cluster_id, v.cluster_size, v.dist_to_baseline_nm,
v.is_transship_suspect, v.transship_pair_mmsi, v.is_transship_suspect, v.transship_pair_mmsi,
fv.permit_no, fv.name_cn, fv.gear_code fv.permit_no, fv.name_cn, fv.gear_code
FROM kcg.vessel_analysis_results v FROM {VESSEL_ANALYSIS_RESULTS} v
LEFT JOIN kcg.fleet_vessels fv ON v.mmsi = fv.mmsi LEFT JOIN {FLEET_VESSELS} fv ON v.mmsi = fv.mmsi
WHERE {where} WHERE {where}
ORDER BY v.risk_score DESC ORDER BY v.risk_score DESC
LIMIT 30 LIMIT 30
@ -217,7 +224,7 @@ def _query_fleet_group(params: dict) -> str:
try: try:
from db import kcgdb from db import kcgdb
conditions = ["snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots)"] conditions = [f"snapshot_time = (SELECT MAX(snapshot_time) FROM {GROUP_POLYGON_SNAPSHOTS})"]
bind_params: list = [] bind_params: list = []
if 'group_type' in params: if 'group_type' in params:
@ -230,7 +237,7 @@ def _query_fleet_group(params: dict) -> str:
where = ' AND '.join(conditions) where = ' AND '.join(conditions)
query = f""" query = f"""
SELECT group_type, group_key, group_label, member_count, zone_name, members SELECT group_type, group_key, group_label, member_count, zone_name, members
FROM kcg.group_polygon_snapshots FROM {GROUP_POLYGON_SNAPSHOTS}
WHERE {where} WHERE {where}
ORDER BY member_count DESC ORDER BY member_count DESC
LIMIT 20 LIMIT 20
@ -376,8 +383,8 @@ def _query_gear_correlation(params: dict) -> str:
'SELECT target_name, target_mmsi, target_type, current_score, ' 'SELECT target_name, target_mmsi, target_type, current_score, '
'streak_count, observation_count, proximity_ratio, visit_score, ' 'streak_count, observation_count, proximity_ratio, visit_score, '
'heading_coherence, freeze_state ' 'heading_coherence, freeze_state '
'FROM kcg.gear_correlation_scores s ' f'FROM {GEAR_CORRELATION_SCORES} s '
'JOIN kcg.correlation_param_models m ON s.model_id = m.id ' f'JOIN {CORRELATION_PARAM_MODELS} m ON s.model_id = m.id '
'WHERE s.group_key = %s AND m.is_default = TRUE AND s.current_score >= 0.3 ' 'WHERE s.group_key = %s AND m.is_default = TRUE AND s.current_score >= 0.3 '
'ORDER BY s.current_score DESC LIMIT %s', 'ORDER BY s.current_score DESC LIMIT %s',
(group_key, limit), (group_key, limit),

파일 보기

@ -1,3 +1,6 @@
import re
from typing import Optional
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@ -25,6 +28,8 @@ class Settings(BaseSettings):
INITIAL_LOAD_HOURS: int = 24 INITIAL_LOAD_HOURS: int = 24
STATIC_INFO_REFRESH_MIN: int = 60 STATIC_INFO_REFRESH_MIN: int = 60
PERMIT_REFRESH_MIN: int = 30 PERMIT_REFRESH_MIN: int = 30
SNPDB_SAFE_DELAY_MIN: int = 12
SNPDB_BACKFILL_BUCKETS: int = 3
# 파이프라인 # 파이프라인
TRAJECTORY_HOURS: int = 6 TRAJECTORY_HOURS: int = 6
@ -48,3 +53,14 @@ class Settings(BaseSettings):
settings = Settings() settings = Settings()
_SQL_IDENTIFIER = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
def qualified_table(table_name: str, schema: Optional[str] = None) -> str:
resolved_schema = schema or settings.KCGDB_SCHEMA
if not _SQL_IDENTIFIER.fullmatch(resolved_schema):
raise ValueError(f'Invalid schema name: {resolved_schema!r}')
if not _SQL_IDENTIFIER.fullmatch(table_name):
raise ValueError(f'Invalid table name: {table_name!r}')
return f'{resolved_schema}.{table_name}'

파일 보기

@ -7,7 +7,7 @@ import psycopg2
from psycopg2 import pool from psycopg2 import pool
from psycopg2.extras import execute_values from psycopg2.extras import execute_values
from config import settings from config import qualified_table, settings
if TYPE_CHECKING: if TYPE_CHECKING:
from models.result import AnalysisResult from models.result import AnalysisResult
@ -15,6 +15,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_pool: Optional[pool.ThreadedConnectionPool] = None _pool: Optional[pool.ThreadedConnectionPool] = None
GROUP_POLYGON_SNAPSHOTS = qualified_table('group_polygon_snapshots')
def init_pool(): def init_pool():
@ -152,8 +153,8 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
if not snapshots: if not snapshots:
return 0 return 0
insert_sql = """ insert_sql = f"""
INSERT INTO kcg.group_polygon_snapshots ( INSERT INTO {GROUP_POLYGON_SNAPSHOTS} (
group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time, group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time,
polygon, center_point, area_sq_nm, member_count, polygon, center_point, area_sq_nm, member_count,
zone_id, zone_name, members, color zone_id, zone_name, members, color
@ -280,11 +281,11 @@ def fetch_polygon_summary() -> dict:
try: try:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute(f"""
SELECT group_type, COUNT(*), SUM(member_count) SELECT group_type, COUNT(*), SUM(member_count)
FROM kcg.group_polygon_snapshots FROM {GROUP_POLYGON_SNAPSHOTS}
WHERE snapshot_time = ( WHERE snapshot_time = (
SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots SELECT MAX(snapshot_time) FROM {GROUP_POLYGON_SNAPSHOTS}
) )
GROUP BY group_type GROUP BY group_type
""") """)
@ -315,7 +316,9 @@ def cleanup_group_snapshots(days: int = 7) -> int:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
f"DELETE FROM kcg.group_polygon_snapshots WHERE snapshot_time < NOW() - INTERVAL '{days} days'", f"DELETE FROM {GROUP_POLYGON_SNAPSHOTS} "
"WHERE snapshot_time < NOW() - (%s * INTERVAL '1 day')",
(days,),
) )
deleted = cur.rowcount deleted = cur.rowcount
conn.commit() conn.commit()

파일 보기

@ -10,15 +10,21 @@ APScheduler 일별 작업으로 실행:
import logging import logging
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from config import qualified_table, settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SYSTEM_CONFIG = qualified_table('system_config')
GEAR_CORRELATION_RAW_METRICS = qualified_table('gear_correlation_raw_metrics')
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
def _get_config_int(conn, key: str, default: int) -> int: def _get_config_int(conn, key: str, default: int) -> int:
"""system_config에서 설정값 조회. 없으면 default.""" """system_config에서 설정값 조회. 없으면 default."""
cur = conn.cursor() cur = conn.cursor()
try: try:
cur.execute( cur.execute(
"SELECT value::text FROM kcg.system_config WHERE key = %s", f"SELECT value::text FROM {SYSTEM_CONFIG} WHERE key = %s",
(key,), (key,),
) )
row = cur.fetchone() row = cur.fetchone()
@ -40,18 +46,18 @@ def _create_future_partitions(conn, days_ahead: int) -> int:
cur.execute( cur.execute(
"SELECT 1 FROM pg_class c " "SELECT 1 FROM pg_class c "
"JOIN pg_namespace n ON n.oid = c.relnamespace " "JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.relname = %s AND n.nspname = 'kcg'", "WHERE c.relname = %s AND n.nspname = %s",
(partition_name,), (partition_name, settings.KCGDB_SCHEMA),
) )
if cur.fetchone() is None: if cur.fetchone() is None:
next_d = d + timedelta(days=1) next_d = d + timedelta(days=1)
cur.execute( cur.execute(
f"CREATE TABLE IF NOT EXISTS kcg.{partition_name} " f"CREATE TABLE IF NOT EXISTS {qualified_table(partition_name)} "
f"PARTITION OF kcg.gear_correlation_raw_metrics " f"PARTITION OF {GEAR_CORRELATION_RAW_METRICS} "
f"FOR VALUES FROM ('{d.isoformat()}') TO ('{next_d.isoformat()}')" f"FOR VALUES FROM ('{d.isoformat()}') TO ('{next_d.isoformat()}')"
) )
created += 1 created += 1
logger.info('created partition: kcg.%s', partition_name) logger.info('created partition: %s.%s', settings.KCGDB_SCHEMA, partition_name)
conn.commit() conn.commit()
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
@ -71,7 +77,8 @@ def _drop_expired_partitions(conn, retention_days: int) -> int:
"SELECT c.relname FROM pg_class c " "SELECT c.relname FROM pg_class c "
"JOIN pg_namespace n ON n.oid = c.relnamespace " "JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.relname LIKE 'gear_correlation_raw_metrics_%%' " "WHERE c.relname LIKE 'gear_correlation_raw_metrics_%%' "
"AND n.nspname = 'kcg' AND c.relkind = 'r'" "AND n.nspname = %s AND c.relkind = 'r'",
(settings.KCGDB_SCHEMA,),
) )
for (name,) in cur.fetchall(): for (name,) in cur.fetchall():
date_str = name.rsplit('_', 1)[-1] date_str = name.rsplit('_', 1)[-1]
@ -80,9 +87,9 @@ def _drop_expired_partitions(conn, retention_days: int) -> int:
except ValueError: except ValueError:
continue continue
if partition_date < cutoff: if partition_date < cutoff:
cur.execute(f'DROP TABLE IF EXISTS kcg.{name}') cur.execute(f'DROP TABLE IF EXISTS {qualified_table(name)}')
dropped += 1 dropped += 1
logger.info('dropped expired partition: kcg.%s', name) logger.info('dropped expired partition: %s.%s', settings.KCGDB_SCHEMA, name)
conn.commit() conn.commit()
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
@ -97,7 +104,7 @@ def _cleanup_stale_scores(conn, cleanup_days: int) -> int:
cur = conn.cursor() cur = conn.cursor()
try: try:
cur.execute( cur.execute(
"DELETE FROM kcg.gear_correlation_scores " f"DELETE FROM {GEAR_CORRELATION_SCORES} "
"WHERE last_observed_at < NOW() - make_interval(days => %s)", "WHERE last_observed_at < NOW() - make_interval(days => %s)",
(cleanup_days,), (cleanup_days,),
) )

파일 보기

@ -8,6 +8,7 @@ import psycopg2
from psycopg2 import pool from psycopg2 import pool
from config import settings from config import settings
from time_bucket import compute_incremental_window_start, compute_initial_window_start, compute_safe_bucket
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -62,7 +63,10 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
LineStringM 지오메트리에서 개별 포인트를 추출하며, LineStringM 지오메트리에서 개별 포인트를 추출하며,
한국 해역(122-132E, 31-39N) 최근 N시간 데이터를 반환한다. 한국 해역(122-132E, 31-39N) 최근 N시간 데이터를 반환한다.
""" """
query = f""" safe_bucket = compute_safe_bucket()
window_start = compute_initial_window_start(hours, safe_bucket)
query = """
SELECT SELECT
t.mmsi, t.mmsi,
to_timestamp(ST_M((dp).geom)) as timestamp, to_timestamp(ST_M((dp).geom)) as timestamp,
@ -75,18 +79,21 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
END as raw_sog END as raw_sog
FROM signal.t_vessel_tracks_5min t, FROM signal.t_vessel_tracks_5min t,
LATERAL ST_DumpPoints(t.track_geom) dp LATERAL ST_DumpPoints(t.track_geom) dp
WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours' WHERE t.time_bucket >= %s
AND t.time_bucket <= %s
AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326) AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom)) ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
""" """
try: try:
with get_conn() as conn: with get_conn() as conn:
df = pd.read_sql_query(query, conn) df = pd.read_sql_query(query, conn, params=(window_start, safe_bucket))
logger.info( logger.info(
'fetch_all_tracks: %d rows, %d vessels (last %dh)', 'fetch_all_tracks: %d rows, %d vessels (window=%s..%s, last %dh safe)',
len(df), len(df),
df['mmsi'].nunique() if len(df) > 0 else 0, df['mmsi'].nunique() if len(df) > 0 else 0,
window_start,
safe_bucket,
hours, hours,
) )
return df return df
@ -101,6 +108,17 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
스케줄러 증분 업데이트에 사용되며, time_bucket > last_bucket 조건으로 스케줄러 증분 업데이트에 사용되며, time_bucket > last_bucket 조건으로
이미 처리한 버킷을 건너뛴다. 이미 처리한 버킷을 건너뛴다.
""" """
safe_bucket = compute_safe_bucket()
from_bucket = compute_incremental_window_start(last_bucket)
if safe_bucket <= from_bucket:
logger.info(
'fetch_incremental skipped: safe_bucket=%s, from_bucket=%s, last_bucket=%s',
safe_bucket,
from_bucket,
last_bucket,
)
return pd.DataFrame(columns=['mmsi', 'timestamp', 'lat', 'lon', 'raw_sog'])
query = """ query = """
SELECT SELECT
t.mmsi, t.mmsi,
@ -115,17 +133,20 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
FROM signal.t_vessel_tracks_5min t, FROM signal.t_vessel_tracks_5min t,
LATERAL ST_DumpPoints(t.track_geom) dp LATERAL ST_DumpPoints(t.track_geom) dp
WHERE t.time_bucket > %s WHERE t.time_bucket > %s
AND t.time_bucket <= %s
AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326) AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom)) ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
""" """
try: try:
with get_conn() as conn: with get_conn() as conn:
df = pd.read_sql_query(query, conn, params=(last_bucket,)) df = pd.read_sql_query(query, conn, params=(from_bucket, safe_bucket))
logger.info( logger.info(
'fetch_incremental: %d rows, %d vessels (since %s)', 'fetch_incremental: %d rows, %d vessels (from %s, safe %s, last %s)',
len(df), len(df),
df['mmsi'].nunique() if len(df) > 0 else 0, df['mmsi'].nunique() if len(df) > 0 else 0,
from_bucket.isoformat(),
safe_bucket.isoformat(),
last_bucket.isoformat(), last_bucket.isoformat(),
) )
return df return df

파일 보기

@ -7,6 +7,9 @@ from typing import Optional
import pandas as pd import pandas as pd
from algorithms.gear_name_rules import is_trackable_parent_name
from config import qualified_table
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 어구 이름 패턴 — 공백/영숫자 인덱스/끝_ 허용 # 어구 이름 패턴 — 공백/영숫자 인덱스/끝_ 허용
@ -14,6 +17,11 @@ GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$') GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$')
_REGISTRY_CACHE_SEC = 3600 _REGISTRY_CACHE_SEC = 3600
FLEET_COMPANIES = qualified_table('fleet_companies')
FLEET_VESSELS = qualified_table('fleet_vessels')
GEAR_IDENTITY_LOG = qualified_table('gear_identity_log')
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot')
class FleetTracker: class FleetTracker:
@ -32,13 +40,13 @@ class FleetTracker:
return return
cur = conn.cursor() cur = conn.cursor()
cur.execute('SELECT id, name_cn, name_en FROM kcg.fleet_companies') cur.execute(f'SELECT id, name_cn, name_en FROM {FLEET_COMPANIES}')
self._companies = {r[0]: {'name_cn': r[1], 'name_en': r[2]} for r in cur.fetchall()} self._companies = {r[0]: {'name_cn': r[1], 'name_en': r[2]} for r in cur.fetchall()}
cur.execute( cur.execute(
"""SELECT id, company_id, permit_no, name_cn, name_en, tonnage, f"""SELECT id, company_id, permit_no, name_cn, name_en, tonnage,
gear_code, fleet_role, pair_vessel_id, mmsi gear_code, fleet_role, pair_vessel_id, mmsi
FROM kcg.fleet_vessels""" FROM {FLEET_VESSELS}"""
) )
self._vessels = {} self._vessels = {}
self._name_cn_map = {} self._name_cn_map = {}
@ -92,7 +100,7 @@ class FleetTracker:
# 이미 매칭됨 → last_seen_at 업데이트 # 이미 매칭됨 → last_seen_at 업데이트
if mmsi in self._mmsi_to_vid: if mmsi in self._mmsi_to_vid:
cur.execute( cur.execute(
'UPDATE kcg.fleet_vessels SET last_seen_at = NOW() WHERE id = %s', f'UPDATE {FLEET_VESSELS} SET last_seen_at = NOW() WHERE id = %s',
(self._mmsi_to_vid[mmsi],), (self._mmsi_to_vid[mmsi],),
) )
continue continue
@ -104,7 +112,7 @@ class FleetTracker:
if vid: if vid:
cur.execute( cur.execute(
"""UPDATE kcg.fleet_vessels f"""UPDATE {FLEET_VESSELS}
SET mmsi = %s, match_confidence = 0.95, match_method = 'NAME_EXACT', SET mmsi = %s, match_confidence = 0.95, match_method = 'NAME_EXACT',
last_seen_at = NOW(), updated_at = NOW() last_seen_at = NOW(), updated_at = NOW()
WHERE id = %s AND (mmsi IS NULL OR mmsi = %s)""", WHERE id = %s AND (mmsi IS NULL OR mmsi = %s)""",
@ -154,6 +162,10 @@ class FleetTracker:
if m2: if m2:
parent_name = m2.group(1).strip() parent_name = m2.group(1).strip()
effective_parent_name = parent_name or name
if not is_trackable_parent_name(effective_parent_name):
continue
# 모선 매칭 # 모선 매칭
parent_mmsi: Optional[str] = None parent_mmsi: Optional[str] = None
parent_vid: Optional[int] = None parent_vid: Optional[int] = None
@ -170,7 +182,7 @@ class FleetTracker:
# 기존 활성 행 조회 # 기존 활성 행 조회
cur.execute( cur.execute(
"""SELECT id, name FROM kcg.gear_identity_log f"""SELECT id, name FROM {GEAR_IDENTITY_LOG}
WHERE mmsi = %s AND is_active = TRUE""", WHERE mmsi = %s AND is_active = TRUE""",
(mmsi,), (mmsi,),
) )
@ -180,7 +192,7 @@ class FleetTracker:
if existing[1] == name: if existing[1] == name:
# 같은 MMSI + 같은 이름 → 위치/시간 업데이트 # 같은 MMSI + 같은 이름 → 위치/시간 업데이트
cur.execute( cur.execute(
"""UPDATE kcg.gear_identity_log f"""UPDATE {GEAR_IDENTITY_LOG}
SET last_seen_at = %s, lat = %s, lon = %s SET last_seen_at = %s, lat = %s, lon = %s
WHERE id = %s""", WHERE id = %s""",
(now, lat, lon, existing[0]), (now, lat, lon, existing[0]),
@ -188,11 +200,11 @@ class FleetTracker:
else: else:
# 같은 MMSI + 다른 이름 → 이전 비활성화 + 새 행 # 같은 MMSI + 다른 이름 → 이전 비활성화 + 새 행
cur.execute( cur.execute(
'UPDATE kcg.gear_identity_log SET is_active = FALSE WHERE id = %s', f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
(existing[0],), (existing[0],),
) )
cur.execute( cur.execute(
"""INSERT INTO kcg.gear_identity_log f"""INSERT INTO {GEAR_IDENTITY_LOG}
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id, (mmsi, name, parent_name, parent_mmsi, parent_vessel_id,
gear_index_1, gear_index_2, lat, lon, gear_index_1, gear_index_2, lat, lon,
match_method, match_confidence, first_seen_at, last_seen_at) match_method, match_confidence, first_seen_at, last_seen_at)
@ -204,7 +216,7 @@ class FleetTracker:
else: else:
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인 # 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인
cur.execute( cur.execute(
"""SELECT id, mmsi FROM kcg.gear_identity_log f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
WHERE name = %s AND is_active = TRUE AND mmsi != %s""", WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
(name, mmsi), (name, mmsi),
) )
@ -212,7 +224,7 @@ class FleetTracker:
if old_mmsi_row: if old_mmsi_row:
# 같은 이름 + 다른 MMSI → MMSI 변경 # 같은 이름 + 다른 MMSI → MMSI 변경
cur.execute( cur.execute(
'UPDATE kcg.gear_identity_log SET is_active = FALSE WHERE id = %s', f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
(old_mmsi_row[0],), (old_mmsi_row[0],),
) )
logger.info('gear MMSI change: %s%s (name=%s)', old_mmsi_row[1], mmsi, name) logger.info('gear MMSI change: %s%s (name=%s)', old_mmsi_row[1], mmsi, name)
@ -220,7 +232,7 @@ class FleetTracker:
# 어피니티 점수 이전 (이전 MMSI → 새 MMSI) # 어피니티 점수 이전 (이전 MMSI → 새 MMSI)
try: try:
cur.execute( cur.execute(
"UPDATE kcg.gear_correlation_scores " f"UPDATE {GEAR_CORRELATION_SCORES} "
"SET target_mmsi = %s, updated_at = NOW() " "SET target_mmsi = %s, updated_at = NOW() "
"WHERE target_mmsi = %s", "WHERE target_mmsi = %s",
(mmsi, old_mmsi_row[1]), (mmsi, old_mmsi_row[1]),
@ -234,7 +246,7 @@ class FleetTracker:
logger.warning('affinity score transfer failed: %s', e) logger.warning('affinity score transfer failed: %s', e)
cur.execute( cur.execute(
"""INSERT INTO kcg.gear_identity_log f"""INSERT INTO {GEAR_IDENTITY_LOG}
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id, (mmsi, name, parent_name, parent_mmsi, parent_vessel_id,
gear_index_1, gear_index_2, lat, lon, gear_index_1, gear_index_2, lat, lon,
match_method, match_confidence, first_seen_at, last_seen_at) match_method, match_confidence, first_seen_at, last_seen_at)
@ -329,7 +341,7 @@ class FleetTracker:
center_lon = sum(lons) / len(lons) if lons else None center_lon = sum(lons) / len(lons) if lons else None
cur.execute( cur.execute(
"""INSERT INTO kcg.fleet_tracking_snapshot f"""INSERT INTO {FLEET_TRACKING_SNAPSHOT}
(company_id, snapshot_time, total_vessels, active_vessels, (company_id, snapshot_time, total_vessels, active_vessels,
center_lat, center_lon) center_lat, center_lon)
VALUES (%s, %s, %s, %s, %s, %s)""", VALUES (%s, %s, %s, %s, %s, %s)""",

파일 보기

@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, FastAPI from fastapi import BackgroundTasks, FastAPI
from config import settings from config import qualified_table, settings
from db import kcgdb, snpdb from db import kcgdb, snpdb
from scheduler import get_last_run, run_analysis_cycle, start_scheduler, stop_scheduler from scheduler import get_last_run, run_analysis_cycle, start_scheduler, stop_scheduler
@ -14,6 +14,8 @@ logging.basicConfig(
stream=sys.stdout, stream=sys.stdout,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
CORRELATION_PARAM_MODELS = qualified_table('correlation_param_models')
@asynccontextmanager @asynccontextmanager
@ -89,11 +91,11 @@ def get_correlation_tracks(
cur = conn.cursor() cur = conn.cursor()
# Get correlated vessels from ALL active models # Get correlated vessels from ALL active models
cur.execute(""" cur.execute(f"""
SELECT s.target_mmsi, s.target_type, s.target_name, SELECT s.target_mmsi, s.target_type, s.target_name,
s.current_score, m.name AS model_name s.current_score, m.name AS model_name
FROM kcg.gear_correlation_scores s FROM {GEAR_CORRELATION_SCORES} s
JOIN kcg.correlation_param_models m ON s.model_id = m.id JOIN {CORRELATION_PARAM_MODELS} m ON s.model_id = m.id
WHERE s.group_key = %s WHERE s.group_key = %s
AND s.current_score >= %s AND s.current_score >= %s
AND m.is_active = TRUE AND m.is_active = TRUE

파일 보기

@ -7,5 +7,6 @@ pandas>=2.2
scikit-learn>=1.5 scikit-learn>=1.5
apscheduler>=3.10 apscheduler>=3.10
shapely>=2.0 shapely>=2.0
tzdata
httpx>=0.27 httpx>=0.27
redis>=5.0 redis>=5.0

파일 보기

@ -121,6 +121,7 @@ def run_analysis_cycle():
# 4.7 어구 연관성 분석 (멀티모델 패턴 추적) # 4.7 어구 연관성 분석 (멀티모델 패턴 추적)
try: try:
from algorithms.gear_correlation import run_gear_correlation from algorithms.gear_correlation import run_gear_correlation
from algorithms.gear_parent_inference import run_gear_parent_inference
corr_result = run_gear_correlation( corr_result = run_gear_correlation(
vessel_store=vessel_store, vessel_store=vessel_store,
@ -132,6 +133,21 @@ def run_analysis_cycle():
corr_result['updated'], corr_result['raw_inserted'], corr_result['updated'], corr_result['raw_inserted'],
corr_result['models'], corr_result['models'],
) )
inference_result = run_gear_parent_inference(
vessel_store=vessel_store,
gear_groups=gear_groups,
conn=kcg_conn,
)
logger.info(
'gear parent inference: %d groups, %d direct-match, %d candidates, %d promoted, %d review, %d skipped',
inference_result['groups'],
inference_result.get('direct_matched', 0),
inference_result['candidates'],
inference_result['promoted'],
inference_result['review_required'],
inference_result['skipped'],
)
except Exception as e: except Exception as e:
logger.warning('gear correlation failed: %s', e) logger.warning('gear correlation failed: %s', e)

파일 보기

@ -0,0 +1,177 @@
import unittest
import sys
import types
from datetime import datetime, timedelta, timezone
stub = types.ModuleType('pydantic_settings')
class BaseSettings:
def __init__(self, **kwargs):
for name, value in self.__class__.__dict__.items():
if name.isupper():
setattr(self, name, kwargs.get(name, value))
stub.BaseSettings = BaseSettings
sys.modules.setdefault('pydantic_settings', stub)
from algorithms.gear_parent_episode import (
GroupEpisodeInput,
EpisodeState,
build_episode_plan,
compute_prior_bonus_components,
continuity_score,
)
class GearParentEpisodeTest(unittest.TestCase):
def test_continuity_score_prefers_member_overlap_and_near_center(self):
current = GroupEpisodeInput(
group_key='ZHEDAIYU02394',
normalized_parent_name='ZHEDAIYU02394',
sub_cluster_id=1,
member_mmsis=['100', '200', '300'],
member_count=3,
center_lat=35.0,
center_lon=129.0,
)
previous = EpisodeState(
episode_id='ep-prev',
lineage_key='ZHEDAIYU02394',
group_key='ZHEDAIYU02394',
normalized_parent_name='ZHEDAIYU02394',
current_sub_cluster_id=0,
member_mmsis=['100', '200', '400'],
member_count=3,
center_lat=35.02,
center_lon=129.01,
last_snapshot_time=datetime.now(timezone.utc),
status='ACTIVE',
)
score, overlap_count, distance_nm = continuity_score(current, previous)
self.assertGreaterEqual(overlap_count, 2)
self.assertGreater(score, 0.45)
self.assertLess(distance_nm, 12.0)
def test_build_episode_plan_creates_merge_episode(self):
now = datetime.now(timezone.utc)
current = GroupEpisodeInput(
group_key='JINSHI',
normalized_parent_name='JINSHI',
sub_cluster_id=0,
member_mmsis=['a', 'b', 'c', 'd'],
member_count=4,
center_lat=35.0,
center_lon=129.0,
)
previous_a = EpisodeState(
episode_id='ep-a',
lineage_key='JINSHI',
group_key='JINSHI',
normalized_parent_name='JINSHI',
current_sub_cluster_id=1,
member_mmsis=['a', 'b'],
member_count=2,
center_lat=35.0,
center_lon=129.0,
last_snapshot_time=now - timedelta(minutes=5),
status='ACTIVE',
)
previous_b = EpisodeState(
episode_id='ep-b',
lineage_key='JINSHI',
group_key='JINSHI',
normalized_parent_name='JINSHI',
current_sub_cluster_id=2,
member_mmsis=['c', 'd'],
member_count=2,
center_lat=35.01,
center_lon=129.01,
last_snapshot_time=now - timedelta(minutes=5),
status='ACTIVE',
)
plan = build_episode_plan([current], {'JINSHI': [previous_a, previous_b]})
assignment = plan.assignments[current.key]
self.assertEqual(assignment.continuity_source, 'MERGE_NEW')
self.assertEqual(set(assignment.merged_from_episode_ids), {'ep-a', 'ep-b'})
self.assertEqual(plan.merged_episode_targets['ep-a'], assignment.episode_id)
self.assertEqual(plan.merged_episode_targets['ep-b'], assignment.episode_id)
def test_build_episode_plan_marks_split_continue_and_split_new(self):
now = datetime.now(timezone.utc)
previous = EpisodeState(
episode_id='ep-prev',
lineage_key='A01859',
group_key='A01859',
normalized_parent_name='A01859',
current_sub_cluster_id=0,
member_mmsis=['a', 'b', 'c', 'd'],
member_count=4,
center_lat=35.0,
center_lon=129.0,
last_snapshot_time=now - timedelta(minutes=5),
status='ACTIVE',
)
current_a = GroupEpisodeInput(
group_key='A01859',
normalized_parent_name='A01859',
sub_cluster_id=1,
member_mmsis=['a', 'b', 'c'],
member_count=3,
center_lat=35.0,
center_lon=129.0,
)
current_b = GroupEpisodeInput(
group_key='A01859',
normalized_parent_name='A01859',
sub_cluster_id=2,
member_mmsis=['c', 'd'],
member_count=2,
center_lat=35.02,
center_lon=129.02,
)
plan = build_episode_plan([current_a, current_b], {'A01859': [previous]})
sources = {plan.assignments[current_a.key].continuity_source, plan.assignments[current_b.key].continuity_source}
self.assertIn('SPLIT_CONTINUE', sources)
self.assertIn('SPLIT_NEW', sources)
def test_compute_prior_bonus_components_caps_total_bonus(self):
observed_at = datetime.now(timezone.utc)
bonuses = compute_prior_bonus_components(
observed_at=observed_at,
normalized_parent_name='JINSHI',
episode_id='ep-1',
candidate_mmsi='412333326',
episode_prior_stats={
('ep-1', '412333326'): {
'seen_count': 12,
'top1_count': 5,
'avg_score': 0.88,
'last_seen_at': observed_at - timedelta(hours=1),
},
},
lineage_prior_stats={
('JINSHI', '412333326'): {
'seen_count': 24,
'top1_count': 6,
'top3_count': 10,
'avg_score': 0.82,
'last_seen_at': observed_at - timedelta(hours=3),
},
},
label_prior_stats={
('JINSHI', '412333326'): {
'session_count': 4,
'last_labeled_at': observed_at - timedelta(days=1),
},
},
)
self.assertGreater(bonuses['episodePriorBonus'], 0.0)
self.assertGreater(bonuses['lineagePriorBonus'], 0.0)
self.assertGreater(bonuses['labelPriorBonus'], 0.0)
self.assertLessEqual(bonuses['priorBonusTotal'], 0.20)
if __name__ == '__main__':
unittest.main()

파일 보기

@ -0,0 +1,279 @@
import unittest
import sys
import types
from datetime import datetime, timedelta, timezone
stub = types.ModuleType('pydantic_settings')
class BaseSettings:
def __init__(self, **kwargs):
for name, value in self.__class__.__dict__.items():
if name.isupper():
setattr(self, name, kwargs.get(name, value))
stub.BaseSettings = BaseSettings
sys.modules.setdefault('pydantic_settings', stub)
from algorithms.gear_parent_inference import (
RegistryVessel,
CandidateScore,
_AUTO_PROMOTED_STATUS,
_apply_final_score_bonus,
_build_track_coverage_metrics,
_build_candidate_scores,
_china_mmsi_prefix_bonus,
_direct_parent_member,
_direct_parent_stable_cycles,
_label_tracking_row,
_NO_CANDIDATE_STATUS,
_REVIEW_REQUIRED_STATUS,
_UNRESOLVED_STATUS,
_name_match_score,
_select_status,
_top_candidate_stable_cycles,
is_trackable_parent_name,
normalize_parent_name,
)
class GearParentInferenceRuleTest(unittest.TestCase):
def _candidate(self, *, mmsi='123456789', score=0.8, sources=None):
return CandidateScore(
mmsi=mmsi,
name='TEST',
vessel_id=1,
target_type='VESSEL',
candidate_source=','.join(sources or ['CORRELATION']),
base_corr_score=0.7,
name_match_score=0.1,
track_similarity_score=0.8,
visit_score_6h=0.4,
proximity_score_6h=0.3,
activity_sync_score_6h=0.2,
stability_score=0.9,
registry_bonus=0.05,
episode_prior_bonus=0.0,
lineage_prior_bonus=0.0,
label_prior_bonus=0.0,
final_score=score,
streak_count=6,
model_id=1,
model_name='default',
evidence={'sources': sources or ['CORRELATION']},
)
def test_normalize_parent_name_removes_space_symbols(self):
self.assertEqual(normalize_parent_name(' A_B-C% 12 '), 'ABC12')
def test_trackable_parent_name_requires_length_four_after_normalize(self):
self.assertFalse(is_trackable_parent_name('A-1%'))
self.assertFalse(is_trackable_parent_name('ZSY'))
self.assertFalse(is_trackable_parent_name('991'))
self.assertTrue(is_trackable_parent_name(' AB_12 '))
def test_name_match_score_prefers_raw_exact(self):
self.assertEqual(_name_match_score('LUWENYU 53265', 'LUWENYU 53265', None), 1.0)
def test_name_match_score_supports_compact_exact_and_prefix(self):
registry = RegistryVessel(
vessel_id=1,
mmsi='412327765',
name_cn='LUWENYU53265',
name_en='LUWENYU 53265',
)
self.assertEqual(_name_match_score('LUWENYU 53265', 'LUWENYU53265', None), 0.8)
self.assertEqual(_name_match_score('LUWENYU 532', 'LUWENYU53265', None), 0.5)
self.assertEqual(_name_match_score('LUWENYU 53265', 'DIFFERENT', registry), 1.0)
self.assertEqual(_name_match_score('ZHEDAIYU02433', 'ZHEDAIYU06178', None), 0.3)
def test_name_match_score_does_not_use_candidate_registry_self_match(self):
registry = RegistryVessel(
vessel_id=1,
mmsi='412413545',
name_cn='ZHEXIANGYU55005',
name_en='ZHEXIANGYU55005',
)
self.assertEqual(_name_match_score('JINSHI', 'ZHEXIANGYU55005', registry), 0.0)
def test_direct_parent_member_prefers_parent_member_then_parent_mmsi(self):
all_positions = {'412420673': {'name': 'ZHEDAIYU02433'}}
from_members = _direct_parent_member(
{
'parent_name': 'ZHEDAIYU02433',
'members': [
{'mmsi': '412420673', 'name': 'ZHEDAIYU02433', 'isParent': True},
{'mmsi': '24330082', 'name': 'ZHEDAIYU02433_82_99_', 'isParent': False},
],
},
all_positions,
)
self.assertEqual(from_members['mmsi'], '412420673')
from_parent_mmsi = _direct_parent_member(
{
'parent_name': 'ZHEDAIYU02433',
'parent_mmsi': '412420673',
'members': [],
},
all_positions,
)
self.assertEqual(from_parent_mmsi['mmsi'], '412420673')
self.assertEqual(from_parent_mmsi['name'], 'ZHEDAIYU02433')
def test_direct_parent_stable_cycles_reuses_same_parent(self):
existing = {
'selected_parent_mmsi': '412420673',
'stable_cycles': 4,
'evidence_summary': {'directParentMmsi': '412420673'},
}
self.assertEqual(_direct_parent_stable_cycles(existing, '412420673'), 5)
self.assertEqual(_direct_parent_stable_cycles(existing, '412000000'), 1)
def test_china_prefix_bonus_requires_threshold(self):
self.assertEqual(_china_mmsi_prefix_bonus('412327765', 0.30), 0.15)
self.assertEqual(_china_mmsi_prefix_bonus('413987654', 0.65), 0.15)
self.assertEqual(_china_mmsi_prefix_bonus('412327765', 0.29), 0.0)
self.assertEqual(_china_mmsi_prefix_bonus('440123456', 0.75), 0.0)
def test_apply_final_score_bonus_adds_bonus_after_weighted_score(self):
pre_bonus_score, china_bonus, final_score = _apply_final_score_bonus('412333326', 0.66)
self.assertIsInstance(pre_bonus_score, float)
self.assertIsInstance(china_bonus, float)
self.assertIsInstance(final_score, float)
self.assertEqual(pre_bonus_score, 0.66)
self.assertEqual(china_bonus, 0.15)
self.assertEqual(final_score, 0.81)
def test_top_candidate_stable_cycles_resets_on_candidate_change(self):
existing = {
'stable_cycles': 5,
'evidence_summary': {'topCandidateMmsi': '111111111'},
}
self.assertEqual(_top_candidate_stable_cycles(existing, self._candidate(mmsi='111111111')), 6)
self.assertEqual(_top_candidate_stable_cycles(existing, self._candidate(mmsi='222222222')), 1)
def test_select_status_requires_recent_stability_and_correlation_for_auto(self):
self.assertEqual(
_select_status(self._candidate(score=0.8, sources=['CORRELATION']), margin=0.2, stable_cycles=3),
(_AUTO_PROMOTED_STATUS, 'AUTO_PROMOTION'),
)
self.assertEqual(
_select_status(self._candidate(score=0.8, sources=['PREVIOUS_SELECTION']), margin=0.2, stable_cycles=3),
(_REVIEW_REQUIRED_STATUS, 'AUTO_REVIEW'),
)
self.assertEqual(
_select_status(self._candidate(score=0.8, sources=['CORRELATION']), margin=0.2, stable_cycles=2),
(_REVIEW_REQUIRED_STATUS, 'AUTO_REVIEW'),
)
def test_select_status_marks_candidate_gaps_explicitly(self):
self.assertEqual(_select_status(None, margin=0.0, stable_cycles=0), (_NO_CANDIDATE_STATUS, 'AUTO_NO_CANDIDATE'))
self.assertEqual(
_select_status(self._candidate(score=0.45, sources=['CORRELATION']), margin=0.1, stable_cycles=1),
(_UNRESOLVED_STATUS, 'AUTO_SCORE'),
)
def test_build_candidate_scores_applies_active_exclusions_before_scoring(self):
class FakeStore:
_tracks = {}
candidates = _build_candidate_scores(
vessel_store=FakeStore(),
observed_at=datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc),
group={'parent_name': 'AB1234', 'sub_cluster_id': 1},
episode_assignment=types.SimpleNamespace(
episode_id='ep-test',
continuity_source='NEW',
continuity_score=0.0,
),
default_model_id=1,
default_model_name='default',
score_rows=[
{
'target_mmsi': '412111111',
'target_type': 'VESSEL',
'target_name': 'AB1234',
'current_score': 0.8,
'streak_count': 4,
},
{
'target_mmsi': '440222222',
'target_type': 'VESSEL',
'target_name': 'AB1234',
'current_score': 0.7,
'streak_count': 3,
},
],
raw_metrics={},
center_track=[],
all_positions={},
registry_by_mmsi={},
registry_by_name={},
existing=None,
excluded_candidate_mmsis={'412111111'},
episode_prior_stats={},
lineage_prior_stats={},
label_prior_stats={},
)
self.assertEqual([candidate.mmsi for candidate in candidates], ['440222222'])
def test_track_coverage_metrics_penalize_short_track_support(self):
now = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
center_track = [
{'timestamp': now - timedelta(hours=5), 'lat': 35.0, 'lon': 129.0},
{'timestamp': now - timedelta(hours=1), 'lat': 35.1, 'lon': 129.1},
]
short_track = [
{'timestamp': now - timedelta(minutes=10), 'lat': 35.1, 'lon': 129.1, 'sog': 0.5},
]
long_track = [
{'timestamp': now - timedelta(minutes=90) + timedelta(minutes=10 * idx), 'lat': 35.0, 'lon': 129.0 + (0.01 * idx), 'sog': 0.5}
for idx in range(10)
]
short_metrics = _build_track_coverage_metrics(center_track, short_track, 35.05, 129.05)
long_metrics = _build_track_coverage_metrics(center_track, long_track, 35.05, 129.05)
self.assertEqual(short_metrics['trackPointCount'], 1)
self.assertEqual(short_metrics['trackCoverageFactor'], 0.0)
self.assertGreater(long_metrics['trackCoverageFactor'], 0.0)
self.assertGreater(long_metrics['coverageFactor'], short_metrics['coverageFactor'])
def test_label_tracking_row_tracks_rank_and_match_flags(self):
top_candidate = self._candidate(mmsi='412333326', score=0.81, sources=['CORRELATION'])
top_candidate.evidence = {
'sources': ['CORRELATION'],
'scoreBreakdown': {'preBonusScore': 0.66},
}
labeled_candidate = self._candidate(mmsi='440123456', score=0.62, sources=['CORRELATION'])
labeled_candidate.evidence = {
'sources': ['CORRELATION'],
'scoreBreakdown': {'preBonusScore': 0.62},
}
row = _label_tracking_row(
observed_at='2026-04-03T00:00:00Z',
label_session={
'id': 10,
'label_parent_mmsi': '440123456',
'label_parent_name': 'TARGET',
},
auto_status='REVIEW_REQUIRED',
top_candidate=top_candidate,
margin=0.19,
candidates=[top_candidate, labeled_candidate],
)
self.assertEqual(row[0], 10)
self.assertEqual(row[8], 2)
self.assertTrue(row[9])
self.assertEqual(row[10], 2)
self.assertEqual(row[11], 0.62)
self.assertEqual(row[12], 0.62)
self.assertFalse(row[14])
self.assertTrue(row[15])
if __name__ == '__main__':
unittest.main()

파일 보기

@ -0,0 +1,90 @@
import unittest
import sys
import types
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import pandas as pd
stub = types.ModuleType('pydantic_settings')
class BaseSettings:
def __init__(self, **kwargs):
for name, value in self.__class__.__dict__.items():
if name.isupper():
setattr(self, name, kwargs.get(name, value))
stub.BaseSettings = BaseSettings
sys.modules.setdefault('pydantic_settings', stub)
from cache.vessel_store import VesselStore
from time_bucket import compute_incremental_window_start, compute_initial_window_start, compute_safe_bucket
class TimeBucketRuleTest(unittest.TestCase):
def test_safe_bucket_uses_delay_then_floors_to_5m(self):
now = datetime(2026, 4, 2, 15, 14, 0, tzinfo=ZoneInfo('Asia/Seoul'))
self.assertEqual(compute_safe_bucket(now), datetime(2026, 4, 2, 15, 0, 0))
def test_incremental_window_includes_overlap_buckets(self):
last_bucket = datetime(2026, 4, 2, 15, 0, 0)
self.assertEqual(compute_incremental_window_start(last_bucket), datetime(2026, 4, 2, 14, 45, 0))
def test_initial_window_start_anchors_to_safe_bucket(self):
safe_bucket = datetime(2026, 4, 2, 15, 0, 0)
self.assertEqual(compute_initial_window_start(24, safe_bucket), datetime(2026, 4, 1, 15, 0, 0))
def test_merge_incremental_prefers_newer_overlap_rows(self):
store = VesselStore()
store._tracks = {
'412000001': pd.DataFrame([
{
'mmsi': '412000001',
'timestamp': pd.Timestamp('2026-04-02T00:01:00Z'),
'time_bucket': datetime(2026, 4, 2, 9, 0, 0),
'lat': 30.0,
'lon': 120.0,
'raw_sog': 1.0,
},
{
'mmsi': '412000001',
'timestamp': pd.Timestamp('2026-04-02T00:02:00Z'),
'time_bucket': datetime(2026, 4, 2, 9, 0, 0),
'lat': 30.1,
'lon': 120.1,
'raw_sog': 1.0,
},
])
}
df_new = pd.DataFrame([
{
'mmsi': '412000001',
'timestamp': pd.Timestamp('2026-04-02T00:02:00Z'),
'time_bucket': datetime(2026, 4, 2, 9, 0, 0),
'lat': 30.2,
'lon': 120.2,
'raw_sog': 2.0,
},
{
'mmsi': '412000001',
'timestamp': pd.Timestamp('2026-04-02T00:03:00Z'),
'time_bucket': datetime(2026, 4, 2, 9, 5, 0),
'lat': 30.3,
'lon': 120.3,
'raw_sog': 2.0,
},
])
store.merge_incremental(df_new)
merged = store._tracks['412000001']
self.assertEqual(len(merged), 3)
replacement = merged.loc[merged['timestamp'] == pd.Timestamp('2026-04-02T00:02:00Z')].iloc[0]
self.assertEqual(float(replacement['lat']), 30.2)
self.assertEqual(float(replacement['lon']), 120.2)
if __name__ == '__main__':
unittest.main()

42
prediction/time_bucket.py Normal file
파일 보기

@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from config import settings
_KST = ZoneInfo('Asia/Seoul')
_BUCKET_MINUTES = 5
def normalize_bucket_kst(bucket: datetime) -> datetime:
if bucket.tzinfo is None:
return bucket
return bucket.astimezone(_KST).replace(tzinfo=None)
def floor_bucket_kst(value: datetime, bucket_minutes: int = _BUCKET_MINUTES) -> datetime:
if value.tzinfo is None:
localized = value.replace(tzinfo=_KST)
else:
localized = value.astimezone(_KST)
floored_minute = (localized.minute // bucket_minutes) * bucket_minutes
return localized.replace(minute=floored_minute, second=0, microsecond=0)
def compute_safe_bucket(now: datetime | None = None) -> datetime:
current = now or datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
safe_point = current.astimezone(_KST) - timedelta(minutes=settings.SNPDB_SAFE_DELAY_MIN)
return floor_bucket_kst(safe_point).replace(tzinfo=None)
def compute_initial_window_start(hours: int, safe_bucket: datetime | None = None) -> datetime:
anchor = normalize_bucket_kst(safe_bucket or compute_safe_bucket())
return anchor - timedelta(hours=hours)
def compute_incremental_window_start(last_bucket: datetime) -> datetime:
normalized = normalize_bucket_kst(last_bucket)
return normalized - timedelta(minutes=settings.SNPDB_BACKFILL_BUCKETS * _BUCKET_MINUTES)