원청 정식 계획노선이 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>
197 lines
6.1 KiB
TypeScript
197 lines
6.1 KiB
TypeScript
import { ui_locales } from "@ui/ui_template_locale";
|
|
|
|
export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
|
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
|
|
|
export interface SlotConfig {
|
|
slot: FileSlot;
|
|
labelKey: keyof typeof ui_locales;
|
|
icon: string;
|
|
extensions: readonly string[];
|
|
isRequired: boolean;
|
|
}
|
|
|
|
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;
|
|
speedMbs: number;
|
|
etaSeconds: number | null;
|
|
error?: string;
|
|
/**
|
|
* 서버(DB `input_files`)에 이미 업로드 완료된 파일 정보 — 재접속 현황의 정본.
|
|
* 로컬 파일을 새로 고르지 않아도 카드에 완료 상태로 표시하고, 이 슬롯에 새 파일을
|
|
* 올리면 교체 확인 모달을 띄우는 근거가 된다(2026-08-04 사용자 지시).
|
|
*/
|
|
serverUploaded?: { name: string; sizeMb: number };
|
|
}
|
|
|
|
export interface StoredUploadSession {
|
|
key: string;
|
|
projectId: string;
|
|
slot: FileSlot;
|
|
fileName: string;
|
|
fileSize: number;
|
|
uploadSessionId: string;
|
|
chunkSizeBytes: number;
|
|
totalChunks: number;
|
|
completedChunks: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
const SLOT_CONFIGS: readonly SlotConfig[] = [
|
|
{
|
|
slot: "csv",
|
|
labelKey: "B03_File_Slot_PlannedRoute",
|
|
icon: "⌁",
|
|
extensions: [".csv", ".shp"],
|
|
isRequired: true,
|
|
},
|
|
{
|
|
slot: "las_laz",
|
|
labelKey: "B03_File_Slot_PointCloud",
|
|
icon: "●",
|
|
extensions: [".las", ".laz"],
|
|
isRequired: true,
|
|
},
|
|
{
|
|
slot: "prj",
|
|
labelKey: "B03_File_Slot_Projection",
|
|
icon: "◇",
|
|
extensions: [".prj"],
|
|
isRequired: true,
|
|
},
|
|
{
|
|
slot: "tfw",
|
|
labelKey: "B03_File_Slot_RasterCoord",
|
|
icon: "□",
|
|
extensions: [".tfw"],
|
|
isRequired: true,
|
|
},
|
|
{
|
|
slot: "tif",
|
|
labelKey: "B03_File_Slot_TerrainDem",
|
|
icon: "▧",
|
|
extensions: [".tif"],
|
|
isRequired: false,
|
|
},
|
|
];
|
|
|
|
export function getExtension(fileName: string): string {
|
|
const index = fileName.lastIndexOf(".");
|
|
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`;
|
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
}
|
|
|
|
export function formatEta(seconds: number | null): string {
|
|
if (seconds === null || !Number.isFinite(seconds)) return "-";
|
|
if (seconds < 60) return `${Math.ceil(seconds)}s`;
|
|
return `${Math.ceil(seconds / 60)}m`;
|
|
}
|
|
|
|
export function makeSessionKey(projectId: string, file: File): string {
|
|
return `b03_upload_${projectId}_${file.name}_${file.size}`;
|
|
}
|
|
|
|
export function initializeSlots(): Map<FileSlot, FileSlotState> {
|
|
const map = new Map<FileSlot, FileSlotState>();
|
|
for (const config of SLOT_CONFIGS) {
|
|
map.set(config.slot, {
|
|
...config,
|
|
uploadStatus: "pending",
|
|
progressBytes: 0,
|
|
speedMbs: 0,
|
|
etaSeconds: null,
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
|
|
export function createFileCardTemplate(): HTMLTemplateElement {
|
|
const template = document.createElement("template");
|
|
template.id = "file-card-template";
|
|
template.innerHTML = `
|
|
<article class="b03-file__card b03-file__card--empty">
|
|
<div class="b03-file__card-header">
|
|
<span class="b03-file__card-icon" aria-hidden="true"></span>
|
|
<div class="b03-file__card-heading">
|
|
<strong class="b03-file__card-label"></strong>
|
|
<span class="b03-file__card-ext"></span>
|
|
</div>
|
|
<div class="b03-file__card-badge-container"></div>
|
|
<button class="b03-file__card-remove" type="button"></button>
|
|
</div>
|
|
<div class="b03-file__card-content">
|
|
<button class="b03-file__card-select" type="button"></button>
|
|
<input class="b03-file__slot-input" type="file" />
|
|
<div class="b03-file__file-info">
|
|
<span class="b03-file__file-name"></span>
|
|
<span class="b03-file__file-size"></span>
|
|
</div>
|
|
<div class="b03-file__progress-section">
|
|
<div class="b03-file__progress-bar-container">
|
|
<div class="b03-file__progress-bar"></div>
|
|
</div>
|
|
<div class="b03-file__progress-info">
|
|
<span class="b03-file__progress-bytes"></span>
|
|
<span class="b03-file__progress-speed"></span>
|
|
<span class="b03-file__progress-eta"></span>
|
|
</div>
|
|
</div>
|
|
<div class="b03-file__error-message" role="alert"></div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
return template;
|
|
}
|