GhiVideo 소스 복제 — v4 작업 시작 기준

기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제.
(node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:32:51 +09:00
co-authored by Claude Fable 5
commit d38b842e8d
362 changed files with 46358 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
import type { Annotation } from '@abcvideo/shared';
interface AnnotationStore {
annotations: Annotation[];
setAnnotations: (a: Annotation[]) => void;
addAnnotation: (a: Annotation) => void;
updateAnnotation: (id: string, a: Partial<Annotation>) => void;
removeAnnotation: (id: string) => void;
}
export const useAnnotationStore = create<AnnotationStore>((set) => ({
annotations: [],
setAnnotations: (annotations) => set({ annotations }),
addAnnotation: (annotation) =>
set((s) => ({
annotations: [...s.annotations, annotation].sort((a, b) => a.timeStart - b.timeStart),
})),
updateAnnotation: (id, update) =>
set((s) => ({
annotations: s.annotations.map((a) => (a.id === id ? { ...a, ...update } : a)),
})),
removeAnnotation: (id) =>
set((s) => ({ annotations: s.annotations.filter((a) => a.id !== id) })),
}));
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand';
export interface CaptureItem {
id: string;
dataUrl: string;
time: number;
filename: string;
createdAt: number;
}
interface CaptureStore {
captures: CaptureItem[];
addCapture: (item: CaptureItem) => void;
removeCapture: (id: string) => void;
clearCaptures: () => void;
}
export const useCaptureStore = create<CaptureStore>((set) => ({
captures: [],
addCapture: (item) => set((s) => ({ captures: [item, ...s.captures] })),
removeCapture: (id) => set((s) => ({ captures: s.captures.filter((c) => c.id !== id) })),
clearCaptures: () => set({ captures: [] }),
}));
+117
View File
@@ -0,0 +1,117 @@
/**
* 클라이언트 지리정보 스토어 (Zustand)
*
* 폴더 선택으로 파싱한 드론 프레임 / POI / 측점 / 중심선 / ENU 원점을 보관한다.
* 4개 소비 컴포넌트(GeoSearch / StationVerify / StationOverlay / RoutePanel)가
* 이 스토어를 단일 소스로 구독한다(서버 /api/geo/* 대체).
*
* playerStore.ts 패턴을 따른다.
*
* POI 위치 보정(poiOverrides): 마우스 드래그로 만든 title→{lat,lon,z} 맵.
* basePois(파싱 원본)에 applyPoiOverrides 를 적용한 결과가 pois 다.
* 내보내기/가져오기 파일(`<base>_poi_overrides.json`)로 영속화한다.
*/
import { create } from 'zustand';
import type {
DroneFrame,
GeoPoint,
CenterlinePoint,
GeoOrigin,
RouteMeta,
RouteStructure,
DirectionChange,
PoiOverride,
PoiOverrideMap,
} from '../types/geo';
import { loadFolderGeoData, applyPoiOverrides } from '../utils/geoData';
interface GeoStore {
loaded: boolean;
frames: DroneFrame[];
pois: GeoPoint[]; // type==='poi' (poiOverrides 적용 결과)
basePois: GeoPoint[]; // 파싱 원본 (보정 전) — 보정 재적용용
poiOverrides: PoiOverrideMap; // title → {lat,lon,z}
stations: GeoPoint[]; // type==='station' (stationOrder 정렬)
centerline: CenterlinePoint[];
origin: GeoOrigin | null;
baseName: string | null;
routeMeta: RouteMeta | null;
/** KMZ 원본 구조물(교량/터널/구교/역사) + route.json 보정. */
structures: RouteStructure[];
/** 측점 비고에서 추출한 방향전환점 목록. */
directionChanges: DirectionChange[];
/** 마지막 폴더 로드에서 KMZ(POI·구조물 원본)가 누락됐는지. true면 재구축 필요. */
kmzMissing: boolean;
/**
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
* 발견한 영상 File 을 반환(없으면 null) — 호출자가 loadLocalFile 로 재생.
*/
loadFromFolder: (files: FileList | File[]) => Promise<File | null>;
/** 단일 POI 보정 설정/갱신 (드래그 종료 시). title 키. */
setPoiOverride: (title: string, ov: PoiOverride) => void;
/** 단일 POI 보정 해제. */
clearPoiOverride: (title: string) => void;
/** 보정맵 전체 교체 (가져오기). */
setPoiOverrides: (map: PoiOverrideMap) => void;
clear: () => void;
}
const EMPTY = {
loaded: false,
frames: [] as DroneFrame[],
pois: [] as GeoPoint[],
basePois: [] as GeoPoint[],
poiOverrides: {} as PoiOverrideMap,
stations: [] as GeoPoint[],
centerline: [] as CenterlinePoint[],
origin: null as GeoOrigin | null,
baseName: null as string | null,
routeMeta: null as RouteMeta | null,
structures: [] as RouteStructure[],
directionChanges: [] as DirectionChange[],
kmzMissing: false,
};
export const useGeoStore = create<GeoStore>((set) => ({
...EMPTY,
loadFromFolder: async (files) => {
const data = await loadFolderGeoData(files);
set({
loaded: true,
frames: data.frames,
basePois: data.pois,
poiOverrides: data.poiOverrides,
pois: applyPoiOverrides(data.pois, data.poiOverrides),
stations: data.stations,
centerline: data.centerline,
origin: data.origin,
baseName: data.baseName,
routeMeta: data.routeMeta,
structures: data.structures,
directionChanges: data.directionChanges,
kmzMissing: data.kmzMissing,
});
return data.videoFile;
},
setPoiOverride: (title, ov) =>
set((s) => {
const poiOverrides = { ...s.poiOverrides, [title]: ov };
return { poiOverrides, pois: applyPoiOverrides(s.basePois, poiOverrides) };
}),
clearPoiOverride: (title) =>
set((s) => {
const poiOverrides = { ...s.poiOverrides };
delete poiOverrides[title];
return { poiOverrides, pois: applyPoiOverrides(s.basePois, poiOverrides) };
}),
setPoiOverrides: (map) =>
set((s) => ({ poiOverrides: map, pois: applyPoiOverrides(s.basePois, map) })),
clear: () => set({ ...EMPTY }),
}));
+56
View File
@@ -0,0 +1,56 @@
import { create } from 'zustand';
import type { VideoSource } from '../types/player';
interface PlayerStore {
source: VideoSource | null;
playing: boolean;
currentTime: number;
duration: number;
fps: number;
volume: number;
muted: boolean;
playbackRate: number;
hlsReady: boolean;
/** 영상 첫 프레임이 표시 가능(loadeddata)해진 후 true. 오버레이를 영상보다 먼저 그리지 않도록 게이트. */
videoReady: boolean;
/** 영상 원본(intrinsic) 해상도 (px). object-fit:cover 크롭 영역 계산 → 오버레이 정렬용. */
videoWidth: number;
videoHeight: number;
setSource: (source: VideoSource | null) => void;
setPlaying: (playing: boolean) => void;
setCurrentTime: (t: number) => void;
setDuration: (d: number) => void;
setFps: (fps: number) => void;
setVolume: (v: number) => void;
setMuted: (m: boolean) => void;
setPlaybackRate: (r: number) => void;
setHlsReady: (ready: boolean) => void;
setVideoReady: (ready: boolean) => void;
setVideoSize: (w: number, h: number) => void;
}
export const usePlayerStore = create<PlayerStore>((set) => ({
source: null,
playing: false,
currentTime: 0,
duration: 0,
fps: 30,
volume: 1,
muted: false,
playbackRate: 1,
hlsReady: false,
videoReady: false,
videoWidth: 0,
videoHeight: 0,
setSource: (source) => set({ source, hlsReady: false, videoReady: false }),
setPlaying: (playing) => set({ playing }),
setCurrentTime: (currentTime) => set({ currentTime }),
setDuration: (duration) => set({ duration }),
setFps: (fps) => set({ fps }),
setVolume: (volume) => set({ volume }),
setMuted: (muted) => set({ muted }),
setPlaybackRate: (playbackRate) => set({ playbackRate }),
setHlsReady: (hlsReady) => set({ hlsReady }),
setVideoReady: (videoReady) => set({ videoReady }),
setVideoSize: (videoWidth, videoHeight) => set({ videoWidth, videoHeight }),
}));
+110
View File
@@ -0,0 +1,110 @@
/**
* 사용자 설정 스토어 (Zustand + localStorage 지속)
*
* 현재는 구조물 시설종별(1종/2종/3종) 표시 필터만 보관한다.
* VideoPlayer 의 체크박스 UI 가 토글하고, StationBar(재생바)/RoutePanel(좌측 목록)
* 이 구독해 표시 여부를 결정한다.
*
* 지속: zustand persist 미들웨어. localStorage 키 'ghivideo.settings'.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/** 필터에서 관리하는 시설종별 집합. 이 외(예 '인상' 등)는 항상 표시. */
export const KNOWN_GRADES = ['1종', '2종', '3종', '기타'] as const;
interface SettingsStore {
/** 시설종별별 표시 여부. 기본 1종☑ 2종☑ 3종☐ 기타☐ (초기 구동 시 1·2종만 체크). */
gradeFilter: Record<string, boolean>;
/** 특정 시설종별 체크 토글. */
setGradeFilter: (grade: string, checked: boolean) => void;
/** 화면 POI 겹침제외(겹치면 1개만). true=겹친 것 숨김(기본), false=모든 POI 표시. */
poiOverlapExclude: boolean;
setPoiOverlapExclude: (v: boolean) => void;
/** 좌측 노선 패널(RoutePanel) 표시 여부. '화면표시 옵션' 토글로 제어. 기본 숨김(OFF). */
showRoutePanel: boolean;
setShowRoutePanel: (v: boolean) => void;
/** 측점 진단 HUD(드론 GPS·투영측점) 하단바 표시 여부. 기본 off. */
showStationDiag: boolean;
setShowStationDiag: (v: boolean) => void;
/** 선형(중심선) 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
showCenterline: boolean;
setShowCenterline: (v: boolean) => void;
/** 드론 궤적 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
showDronePath: boolean;
setShowDronePath: (v: boolean) => void;
/** 우측 상단 나침반 타입. 'analog'=눈금 나침반, 'map'=지도(노스업, 현재위치). 기본 map. */
compassType: 'analog' | 'map';
setCompassType: (v: 'analog' | 'map') => void;
/** 지도 나침반 배경. 'street'=OSM 일반, 'sat'=위성(Esri). 기본 street. */
compassMapStyle: 'street' | 'sat';
setCompassMapStyle: (v: 'street' | 'sat') => void;
}
const DEFAULT_GRADE_FILTER: Record<string, boolean> = {
'1종': true,
'2종': true,
'3종': false,
'기타': false,
};
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
gradeFilter: { ...DEFAULT_GRADE_FILTER },
setGradeFilter: (grade, checked) =>
set((s) => ({ gradeFilter: { ...s.gradeFilter, [grade]: checked } })),
poiOverlapExclude: true,
setPoiOverlapExclude: (v) => set({ poiOverlapExclude: v }),
showRoutePanel: false,
setShowRoutePanel: (v) => set({ showRoutePanel: v }),
showStationDiag: false,
setShowStationDiag: (v) => set({ showStationDiag: v }),
showCenterline: true,
setShowCenterline: (v) => set({ showCenterline: v }),
showDronePath: true,
setShowDronePath: (v) => set({ showDronePath: v }),
compassType: 'map',
setCompassType: (v) => set({ compassType: v }),
compassMapStyle: 'street',
setCompassMapStyle: (v) => set({ compassMapStyle: v }),
}),
{
name: 'ghivideo.settings',
// 시작 기본값 변경(좌측패널 OFF + 시설등급 1·2종만) 반영 —
// 과거 저장본(version 0)은 최초 1회 해당 두 값을 기본으로 강제 재설정한다.
version: 1,
migrate: (persisted, version) => {
const p = (persisted ?? {}) as Partial<SettingsStore>;
if (version < 1) {
return { ...p, showRoutePanel: false, gradeFilter: { ...DEFAULT_GRADE_FILTER } } as SettingsStore;
}
return p as SettingsStore;
},
// 과거 저장본에 누락된 종별 키가 있으면 기본값으로 보충(스키마 진화 대비).
merge: (persisted, current) => {
const p = (persisted ?? {}) as Partial<SettingsStore>;
return {
...current,
...p,
gradeFilter: { ...DEFAULT_GRADE_FILTER, ...(p.gradeFilter ?? {}) },
};
},
},
),
);
/**
* 구조물 표시 규칙(공유). 단일 소스로 StationBar/RoutePanel 이 동일 규칙을 쓴다.
* - grade 없음(구교 등) 또는 미관리 등급('기타' 등) → 항상 표시
* - 1/2/3종 → gradeFilter 가 true 일 때만 표시
*/
export function isGradeVisible(
grade: string | undefined,
gradeFilter: Record<string, boolean>,
): boolean {
if (!grade) return true;
if (!(KNOWN_GRADES as readonly string[]).includes(grade)) return true;
return gradeFilter[grade] === true;
}