Files
Aislo/B03_FileInput/B03_FileInput_UI_Support.ts
T
eomsangdonandClaude Opus 5 1eb5ad4a60 feat(B03): 입력 화면을 계획노선/지형 두 컨테이너로 나누고 파일마다 카드를 준다
노선 파일이 다섯이면 카드도 다섯이어야 어느 것이 왔는지 보인다(사용자 지시).
세트를 슬롯 하나에 몰아 담던 companions 구조를 걷어내고, 슬롯 하나가 파일 하나를
갖는 기존 구조로 되돌렸다 - 업로드 루프도 원래대로다.

- 왼쪽 컨테이너 계획노선 자료: csv(.csv/.shp) shx dbf cpg route_prj
- 오른쪽 컨테이너 지형 자료(LAS): las_laz prj(지형 좌표계) tfw tif + LAS 없는 설계 토글
- .prj만 확장자로 안 갈린다 - 노선 도형과 basename이 같으면 노선 좌표계 카드,
  아니면 지형 카드(planSlotAssignments). 재접속 현황은 저장 경로(input/shp/)로 가른다.
- 재접속 현황 응답에 relative_path 추가.
- 필수 판정이 노선 PRJ를 route_prj로 따로 센다 - 지형 PRJ 없이 통과하던 구멍을 막았다.
- 형제 카드(shx/dbf/route_prj)는 노선 도형이 shapefile일 때만 필수 - 확장자 줄의
  "선택" 꼬리표가 실시간으로 붙고 떨어진다.
- 카드가 좁아져 한글 제목이 글자 단위로 접히던 것을 word-break: keep-all과 헤더
  flex-wrap으로 고쳤다.

화면 검증(공용 브라우저): 실물 7파일을 한 번에 떨어뜨려 배정 실측 - route.prj는
노선 좌표계 카드, terrain.prj는 지형 카드로 갈렸고 카드 제목 9개 모두 한 줄.
tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:50:39 +09:00

262 lines
7.5 KiB
TypeScript

import { ui_locales } from "@ui/ui_template_locale";
/**
* 카드 한 장 = 파일 한 개. 계획노선 shapefile은 파일이 다섯이므로 카드도 다섯이다
* (2026-08-31 사용자 지시) — 어느 파일이 왔고 어느 것이 비었는지 화면에서 바로 보인다.
* `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다.
*/
export type FileSlot =
| "csv"
| "shx"
| "dbf"
| "cpg"
| "route_prj"
| "las_laz"
| "prj"
| "tfw"
| "tif"
| "dxf";
/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */
export const ROUTE_SLOTS: readonly FileSlot[] = [
"csv",
"shx",
"dbf",
"cpg",
"route_prj",
];
/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */
export const TERRAIN_SLOTS: readonly FileSlot[] = [
"las_laz",
"prj",
"tfw",
"tif",
];
/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [
"shx",
"dbf",
"route_prj",
];
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;
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: "shx",
labelKey: "B03_File_Slot_RouteIndex",
icon: "⋮",
extensions: [".shx"],
isRequired: false,
},
{
slot: "dbf",
labelKey: "B03_File_Slot_RouteAttribute",
icon: "▤",
extensions: [".dbf"],
isRequired: false,
},
{
slot: "cpg",
labelKey: "B03_File_Slot_RouteEncoding",
icon: "⌨",
extensions: [".cpg"],
isRequired: false,
},
{
slot: "route_prj",
labelKey: "B03_File_Slot_RouteProjection",
icon: "◈",
extensions: [".prj"],
isRequired: false,
},
{
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;
}
/**
* 고른 파일을 카드(슬롯)에 배정한다.
*
* `.prj`만 확장자로 갈리지 않는다 — 노선 좌표계와 지형 좌표계가 같은 확장자다.
* **노선 도형(.shp)과 basename이 같은 것**만 노선 좌표계 카드로 보내고, 나머지는
* 지형 좌표계 카드로 보낸다. `routeStem`은 이미 골라 둔 노선 도형의 basename으로,
* 노선 PRJ를 나중에 따로 추가하는 경우를 받아 준다.
*/
export function planSlotAssignments(
files: readonly File[],
slotConfigs: readonly SlotConfig[],
routeStem?: string,
): { file: File; slot?: FileSlot }[] {
const batchStem = files
.filter((file) => getExtension(file.name) === ".shp")
.map((file) => getBaseName(file.name))[0];
const stem = batchStem ?? routeStem;
return files.map((file) => {
const extension = getExtension(file.name);
if (extension === ".prj") {
const isRoute = stem !== undefined && getBaseName(file.name) === stem;
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
}
const config = slotConfigs.find(
(candidate) =>
candidate.slot !== "route_prj" &&
candidate.extensions.includes(extension),
);
return { file, slot: config?.slot };
});
}
/** 슬롯 설정 목록 — 배정 규칙이 카드 정의와 같은 것을 쓰도록 밖으로 연다. */
export function slotConfigs(): readonly SlotConfig[] {
return SLOT_CONFIGS;
}
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;
}