feat(B03): 계획노선 shapefile 입력과 PRJ 2개 분리를 지원한다

원청 정식 계획노선이 shapefile(UTM-K)로, 지형이 별도 PRJ(동부원점 Bessel)로
들어오는데 입력 경로가 shapefile 확장자를 막고 PRJ를 프로젝트당 1개로 전제했다.

- 업로드 허용에 .shp/.shx/.dbf/.cpg 추가, 한 번에 보낼 파일 수 5 -> 10
- B03_FileInput_Engine_Shapefile: ESRI 규격 직접 파싱(GDAL 미사용). 형제 파일이
  아직 안 왔어도 .shp 하나로 기하를 읽는다. .cpg 내용이 949뿐인 실물을 CP949로
  정규화해 한글 속성을 살린다.
- 노선 판독을 read_planned_route로 일원화(CSV/shapefile), PlannedRoute에
  crs_input 추가 - 변환 입력은 EPSG 코드가 아니라 crs_input_from_prj가 주는
  값(EPSG:n 또는 원문 WKT)이다. 실물 PRJ 2종 모두 to_epsg가 None이다.
- shapefile 세트를 input/shp/ 한 폴더에 모은다(GDAL 요건). 노선 PRJ가 그 안에
  남으므로 지형 PRJ(input/prj/)와 파일명 정렬 운에 기대지 않고 갈린다.
  find_project_prj가 지형 PRJ를 프로젝트 좌표계로 고른다.
- 필수 세트를 노선 1종(csv 또는 shp) + prj + tfw로 완화, shp면 shx/dbf 동반 필수.
- UI: 확장자 단독 슬롯 매칭을 basename 그룹핑으로 바꿔 노선 PRJ와 지형 PRJ가
  같은 슬롯을 다투지 않게 하고, 노선 슬롯이 파일 한 벌을 담아 함께 전송한다.

자체검증: tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과,
ruff check/format 통과. 전체 스위트 잔여 실패 11건은 HEAD 사본(git archive)에서
동일하게 재현되는 기존 실패다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:28:34 +09:00
co-authored by Claude Opus 5
parent 9bc722dfd6
commit fe72ea041b
18 changed files with 582 additions and 48 deletions
+45 -1
View File
@@ -13,6 +13,12 @@ export interface SlotConfig {
export interface FileSlotState extends SlotConfig {
file?: File;
/**
* 계획노선 shapefile의 동반 파일(.shx/.dbf/.cpg/.prj). 슬롯 하나가 파일 한 벌을
* 받는 유일한 경우다 — GDAL이 열려면 형제 파일이 같이 있어야 한다(2026-08-31).
* 대표 파일(`file`)은 언제나 .shp이고, 동반 파일은 그보다 먼저 전송한다.
*/
companions?: File[];
uploadSessionId?: string;
uploadStatus: UploadStatus;
progressBytes: number;
@@ -45,7 +51,7 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
slot: "csv",
labelKey: "B03_File_Slot_PlannedRoute",
icon: "⌁",
extensions: [".csv"],
extensions: [".csv", ".shp"],
isRequired: true,
},
{
@@ -83,6 +89,44 @@ export function getExtension(fileName: string): string {
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
}
export function getBaseName(fileName: string): string {
const index = fileName.lastIndexOf(".");
return index >= 0 ? fileName.slice(0, index) : fileName;
}
/** shapefile 동반 파일. `.prj`가 여기 들어 있어 지형 PRJ와 basename으로 갈린다. */
const SHAPEFILE_COMPANION_EXT = [".shx", ".dbf", ".cpg", ".prj"];
/**
* 고른 파일을 「계획노선 shapefile 한 벌」과 나머지로 가른다.
*
* 확장자만 보고 슬롯을 찾으면 노선 PRJ와 지형 PRJ가 같은 슬롯을 다툰다. `.shp`와
* **basename이 같은** 것만 노선 세트로 묶고, 남은 `.prj`는 지형 슬롯으로 보낸다.
*/
export function splitShapefileSelection(files: readonly File[]): {
shapefile: { primary: File; companions: File[] } | null;
rest: File[];
} {
const primary = files.find((file) => getExtension(file.name) === ".shp");
if (!primary) return { shapefile: null, rest: [...files] };
const stem = getBaseName(primary.name);
const companions: File[] = [];
const rest: File[] = [];
for (const file of files) {
if (file === primary) continue;
const extension = getExtension(file.name);
if (
SHAPEFILE_COMPANION_EXT.includes(extension) &&
getBaseName(file.name) === stem
) {
companions.push(file);
} else {
rest.push(file);
}
}
return { shapefile: { primary, companions }, rest };
}
export function formatBytes(bytes: number): string {
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(2)} GB`;