사용자 지시 4건(2026-09-03). - 선택 영역의 설명 문구를 좌측 안내 패널로 옮기고 본문에는 「입력 파일 선택」만 남김. - [임시 보관함에서 불러오기] → [임시 보관함]. 버튼을 감싸던 점선 테두리 삭제 — 버튼 자신의 테두리와 이중이었음. - 계획노선 도형 카드는 `.shp` 만 받음. CSV 는 내부 계산이 만드는 파일이라 사용자가 넣는 자료가 아님(사용자 확인). 컨테이너의 CSV 안내 문구도 삭제. - 이미 `.csv` 로 올라간 옛 프로젝트가 「미등록」으로 보이지 않도록 **서버 파일 매핑에만** `.csv → 계획노선 도형` 예외를 둠. 새로 받는 확장자와 이미 받은 것을 갈라 둔다. 좌측 안내 문구도 함께 갱신 — CSV 언급 제거, 버튼 새 이름 반영. 검증: 실측 — 선택 영역 텍스트 「입력 파일 선택」, 보관함 테두리 none·라벨 「임시 보관함」, 계획노선 안내 문구 없음, 카드 확장자 `.shp`, 옛 CSV 는 「완료 · 용화_계획노선.csv」 유지. typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
238 lines
7.6 KiB
TypeScript
238 lines
7.6 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: "⌁",
|
|
// `.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,
|
|
},
|
|
];
|
|
|
|
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-select" type="button"></button>
|
|
<button class="b03-file__card-remove" type="button"></button>
|
|
</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__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;
|
|
}
|