Files
Aislo/B03_FileInput/B03_FileInput_UI_Support.ts
T
eomsangdonandClaude Sonnet 5.5 a826804277 refactor(B02·B03): 페이지 자체 변종을 부품 상자 부품으로 — 파일 카드 · 띠 알림 · 진행 막대 · 켜기 · 패널 머리 · 단추 (PLAN 553-1)
- B03 파일 카드 = 카드 부품 · [선택] · [×] = 단추 · 아이콘 단추 · 진행 막대 · 오류 띠 = 부품
- B03 현황 · 이어올리기 · 예정노선 바뀜 = 띠 알림 · LAS 없이 설계 = 켜기 · 노선 · 지형 제목 = 패널 머리
- B03 · B02 죽은 CSS 걷음 · 날 색 → 공통 값 · 가이드 촬영 선택자 · 지킴 시험 test_553_1_pages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:53:32 +09:00

262 lines
8.7 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;
/**
* 같은 카드에 더 담은 파일 — 지형 자료(포인트클라우드)만 여러 장을 받는다.
* 드론 라이다는 사업지가 넓으면 도엽별로 나뉘어 오고, 전처리가 합쳐서 쓴다
* (2026-09-06 사용자 확정). 업로드는 이 목록을 한 장씩 차례로 올린다.
*/
extraFiles?: 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; metadata?: Record<string, unknown> };
}
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: "⌁",
// `.csv`는 받지 않는다 — 내부 계산이 만드는 파일이라 사용자가 넣는 자료가 아니다
// (2026-09-03 사용자 지시). 슬롯 키 `csv`는 저장·서버 규약이라 그대로 둔다.
extensions: [".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,
},
];
/** 카드에 적을 파일 이름 — 여러 장이면 「첫 장 외 N장」. */
export function slotFileLabel(state: FileSlotState): string {
const name = state.file?.name ?? state.serverUploaded?.name ?? "";
const extras = state.extraFiles?.length ?? 0;
return extras > 0 ? `${name} 외 ${extras}장` : name;
}
/**
* 같은 카드에 파일을 더 담는다 — 담았으면 true, 이 카드가 한 장짜리면 false.
* 지형 자료(포인트클라우드)만 여러 장을 받는다. 같은 이름은 다시 담지 않는다.
*/
export function pushExtraFile(state: FileSlotState, file: File): boolean {
if (state.slot !== "las_laz") return false;
const extras = state.extraFiles ?? [];
if (!extras.some((item) => item.name === file.name)) state.extraFiles = [...extras, file];
state.error = undefined;
return true;
}
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="ui-card b03-file__card b03-file__card--empty" data-pad="12">
<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>
<span data-slot="select"></span>
<span data-slot="remove"></span>
</div>
<div class="b03-file__card-content">
<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__preview"></div>
<div class="b03-file__progress-section">
<span data-slot="progress"></span>
<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>
<span data-slot="error"></span>
</div>
</article>
`;
return template;
}