원청 정식 계획노선이 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>
147 lines
5.0 KiB
TypeScript
147 lines
5.0 KiB
TypeScript
/* =============================================================================
|
|
* B01_Dashboard_UI_TempModal.ts
|
|
* 임시 보관함 — 자료 등록/파일 추가 모달.
|
|
*
|
|
* 모달 껍데기는 대시보드의 다른 모달(B01_Dashboard_UI_Modals)과 같은 클래스를 써서
|
|
* 생김새를 맞춘다. 파일을 고르면 모달 안에 리스트로 보여 주고, [확인]을 눌러야
|
|
* 그룹 생성·업로드가 시작된다(2026-08-08 사용자 지시).
|
|
*
|
|
* 크기·확장자 표기 헬퍼도 여기에 둔다 — 보관함 본체(TempUpload)가 이 파일을 가져다
|
|
* 쓰므로 import 방향이 한쪽으로만 흐른다.
|
|
* ========================================================================== */
|
|
|
|
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
|
import { table, text } from "@ui/ui_template_general_blocks";
|
|
import { L } from "./B01_Dashboard_UI_Common";
|
|
|
|
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
|
|
export function tempFileType(fileName: string): string {
|
|
const index = fileName.lastIndexOf(".");
|
|
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
|
|
}
|
|
|
|
export function formatTempBytes(bytes: number): string {
|
|
const gb = bytes / 1024 / 1024 / 1024;
|
|
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
|
const mb = bytes / 1024 / 1024;
|
|
if (mb >= 1) return `${mb.toFixed(1)} MB`;
|
|
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
}
|
|
|
|
export interface TempModalOptions {
|
|
title: string;
|
|
/** 신규 등록이면 true — 그룹 이름을 함께 받는다. 기존 그룹 파일 추가면 false. */
|
|
askName: boolean;
|
|
/** [확인] 눌렀을 때. 모달은 먼저 닫히고, 업로드는 목록에서 진행 상황을 보여 준다. */
|
|
onConfirm: (payload: { name: string; files: File[] }) => void;
|
|
}
|
|
|
|
export function openTempFileModal(options: TempModalOptions): void {
|
|
const modal = document.createElement("div");
|
|
modal.className = "b01-dashboard__modal";
|
|
const panel = document.createElement("div");
|
|
panel.className = "b01-dashboard__modal-panel";
|
|
const heading = document.createElement("h3");
|
|
heading.className = "b01-dashboard__modal-title";
|
|
heading.textContent = options.title;
|
|
|
|
const nameField = createInputField({
|
|
label: L("B01_Temp_Field_Name"),
|
|
placeholder: L("B01_Temp_Field_Name_Placeholder"),
|
|
required: true,
|
|
});
|
|
|
|
const picker = document.createElement("input");
|
|
picker.type = "file";
|
|
picker.multiple = true;
|
|
picker.accept = ".csv,.shp,.shx,.dbf,.cpg,.las,.laz,.tif,.tfw,.prj";
|
|
picker.className = "b01-temp__hidden-input";
|
|
|
|
const pickRow = document.createElement("div");
|
|
pickRow.className = "b01-temp__modal-pick";
|
|
const caption = document.createElement("span");
|
|
caption.textContent = L("B01_Temp_Field_Files");
|
|
pickRow.append(
|
|
caption,
|
|
createButton({
|
|
label: L("B01_Temp_Btn_Pick"),
|
|
variant: "ghost",
|
|
onClick: () => picker.click(),
|
|
}),
|
|
);
|
|
|
|
const listHost = document.createElement("div");
|
|
listHost.className = "b01-temp__modal-list";
|
|
const chosen: File[] = [];
|
|
|
|
function renderList(): void {
|
|
listHost.replaceChildren(
|
|
table(
|
|
[
|
|
L("B01_Temp_Table_Type"),
|
|
L("B01_Temp_Table_Name"),
|
|
L("B01_Temp_Table_Size"),
|
|
L("B01_Temp_Table_Action"),
|
|
],
|
|
chosen.map((file) => [
|
|
text(tempFileType(file.name).toUpperCase()),
|
|
text(file.name),
|
|
text(formatTempBytes(file.size)),
|
|
createButton({
|
|
label: L("Common_Btn_Delete"),
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
const index = chosen.indexOf(file);
|
|
if (index >= 0) chosen.splice(index, 1);
|
|
renderList();
|
|
},
|
|
}),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
picker.addEventListener("change", () => {
|
|
for (const file of Array.from(picker.files ?? [])) {
|
|
// 같은 파일을 두 번 고른 경우는 무시한다.
|
|
const duplicated = chosen.some((item) => item.name === file.name && item.size === file.size);
|
|
if (!duplicated) chosen.push(file);
|
|
}
|
|
picker.value = "";
|
|
renderList();
|
|
});
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "b01-dashboard__actions";
|
|
actions.append(
|
|
createButton({
|
|
label: L("Common_Btn_Cancel"),
|
|
variant: "ghost",
|
|
onClick: () => modal.remove(),
|
|
}),
|
|
createButton({
|
|
label: L("Common_Btn_Confirm"),
|
|
onClick: () => {
|
|
const name = nameField.input.value.trim();
|
|
if (options.askName && !name) {
|
|
showToast(L("B01_Temp_Error_Name"), "error");
|
|
return;
|
|
}
|
|
if (chosen.length === 0) {
|
|
showToast(L("B01_Temp_Error_Files"), "error");
|
|
return;
|
|
}
|
|
modal.remove();
|
|
options.onConfirm({ name, files: [...chosen] });
|
|
},
|
|
}),
|
|
);
|
|
|
|
renderList();
|
|
panel.append(heading);
|
|
if (options.askName) panel.append(nameField.root);
|
|
panel.append(pickRow, listHost, picker, actions);
|
|
modal.append(panel);
|
|
document.body.append(modal);
|
|
}
|