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>
This commit is contained in:
2026-08-31 19:50:39 +09:00
co-authored by Claude Opus 5
parent 36e4decf34
commit 1eb5ad4a60
9 changed files with 301 additions and 132 deletions
+2
View File
@@ -177,6 +177,8 @@ export interface UploadOverviewFile {
file_size_mb: number;
status: string;
uploaded_at: string | null;
/** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */
relative_path: string | null;
}
export interface UploadOverviewSession {
+14 -3
View File
@@ -69,11 +69,15 @@ async def get_project_input_readiness(
계획노선은 CSV 또는 shapefile이다. 둘 다 있으면 shapefile을 고른다 —
`find_planned_route_file()`의 우선순위와 같아야 WF1 입력과 실제 판독 대상이 갈리지 않는다.
PRJ는 노선용·지형용 두 장이 온다. DB `file_type`은 둘 다 `prj`라 그대로 세면 노선
PRJ 하나로 필수가 채워진다 — 프로젝트 좌표계를 정하는 것은 **지형 PRJ**이므로,
노선 세트 폴더(`input/shp/`)에 있는 PRJ는 `route_prj`로 갈라 센다(2026-08-31).
"""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT id, LOWER(file_type) AS file_type
SELECT id, LOWER(file_type) AS file_type, raw_file_path
FROM input_files
WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED')
ORDER BY id DESC
@@ -82,7 +86,14 @@ async def get_project_input_readiness(
)
rows = await cursor.fetchall()
file_types = {str(row["file_type"]) for row in rows if row.get("file_type")}
file_types: set[str] = set()
for row in rows:
file_type = str(row.get("file_type") or "")
if not file_type:
continue
if file_type == "prj" and "/input/shp/" in str(row.get("raw_file_path") or ""):
file_type = "route_prj"
file_types.add(file_type)
point_cloud_id = next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
None,
@@ -316,7 +327,7 @@ async def list_project_input_files(
await cursor.execute(
"""
SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status,
f.upload_at
f.upload_at, f.raw_file_path
FROM input_files f
INNER JOIN (
SELECT MAX(id) AS id
+4 -2
View File
@@ -80,8 +80,9 @@ _REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"})
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
_ROUTE_FILE_TYPES = frozenset({"csv", "shp"})
# shapefile은 이이 다 있어야 열린다. .cpg는 없으면 CP949로 읽으므로 필수가 아니다.
_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"})
# shapefile은 이것들이 다 있어야 열린다. `.cpg`는 없으면 CP949로 읽으므로 필수가 아니다.
# `route_prj`는 노선 세트 폴더에 있는 PRJ — 지형 PRJ(`prj`)와 따로 센다.
_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf", "route_prj"})
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
@@ -819,6 +820,7 @@ async def get_project_upload_overview(
file_size_mb=float(row["file_size_mb"] or 0.0),
status=str(row["status"]),
uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None,
relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None),
)
for row in files
],
+3
View File
@@ -128,6 +128,9 @@ class UploadOverviewFile(BaseModel):
file_size_mb: float
status: str
uploaded_at: str | None = None
# PRJ는 노선용·지형용 두 장이 온다. 확장자로는 못 가리므로 저장 폴더로 가린다
# (노선 세트는 `B03_FileInput/input/shp/`에 모인다, 2026-08-31).
relative_path: str | None = None
class UploadOverviewSession(BaseModel):
+121 -78
View File
@@ -44,7 +44,11 @@ import {
getExtension,
initializeSlots,
makeSessionKey,
splitShapefileSelection,
planSlotAssignments,
ROUTE_SLOTS,
SHAPEFILE_DEPENDENT_SLOTS,
slotConfigs,
TERRAIN_SLOTS,
type FileSlot,
type FileSlotState,
type StoredUploadSession,
@@ -194,10 +198,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
pageError.textContent = validation ?? "";
}
/** 확장자 줄 — 지금 필수인지에 따라 "· 선택" 꼬리표가 붙고 떨어진다. */
function renderExtensionLabel(card: HTMLElement, state: FileSlotState): void {
const extLabel = state.extensions.join(", ");
const target = card.querySelector(".b03-file__card-ext");
if (!target) return;
target.textContent = isSlotRequired(state)
? extLabel
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
}
function renderSlot(slot: FileSlot): void {
const state = slots.get(slot);
const card = cardMap.get(slot);
if (!state || !card) return;
renderExtensionLabel(card, state);
const fileName = card.querySelector<HTMLSpanElement>(
".b03-file__file-name",
@@ -290,14 +305,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
async function assignFileToSlot(
file: File,
targetSlot?: FileSlot,
companions: File[] = [],
): Promise<void> {
const extension = getExtension(file.name);
const state = targetSlot
? slots.get(targetSlot)
: Array.from(slots.values()).find((candidate) =>
candidate.extensions.includes(extension),
);
const state = targetSlot ? slots.get(targetSlot) : undefined;
if (!state) {
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
return;
@@ -325,7 +334,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
state.file = file;
state.companions = companions.length ? companions : undefined;
state.uploadSessionId = undefined;
state.uploadStatus = "pending";
state.progressBytes = 0;
@@ -333,6 +341,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
state.etaSeconds = null;
state.error = undefined;
renderSlot(state.slot);
// 노선 도형이 CSV↔shapefile로 바뀌면 형제 카드의 필수 표시도 따라 바뀐다.
if (state.slot === "csv") renderRouteDependentSlots();
}
function renderRouteDependentSlots(): void {
for (const slot of SHAPEFILE_DEPENDENT_SLOTS) renderSlot(slot);
updateUploadButton();
}
function onFileSelected(
@@ -356,18 +371,17 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다.
// 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른
// 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08).
// 계획노선 shapefile은 파일 한 벌이 슬롯 하나로 간다 — 확장자만 보면 노선 PRJ가
// 지형 PRJ 슬롯을 덮어쓴다(2026-08-31).
const { shapefile, rest } = splitShapefileSelection(files);
// 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이
// 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시).
const routeFile = slots.get("csv")?.file?.name;
const assignments = planSlotAssignments(
files,
slotConfigs(),
routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined,
);
const occupied = new Set(selectedStates().map((state) => state.slot));
if (shapefile) occupied.add(targetSlot ?? "csv");
for (const file of rest) {
const extension = getExtension(file.name);
const slot =
targetSlot ??
Array.from(slots.values()).find((candidate) =>
candidate.extensions.includes(extension),
)?.slot;
for (const item of assignments) {
const slot = targetSlot ?? item.slot;
if (slot) occupied.add(slot);
}
if (occupied.size > UPLOAD_MAX_FILES) {
@@ -376,14 +390,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : "";
void (async () => {
if (shapefile) {
await assignFileToSlot(
shapefile.primary,
targetSlot,
shapefile.companions,
);
for (const item of assignments) {
const slot = targetSlot ?? item.slot;
if (!slot) {
pageError.textContent = `${L("B03_File_Error_Extension")} ${item.file.name}`;
continue;
}
await assignFileToSlot(item.file, slot);
}
for (const file of rest) await assignFileToSlot(file, targetSlot);
await detectPausedUploads();
})();
}
@@ -395,7 +409,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
localStorage.removeItem(makeSessionKey(activeProjectId, state.file));
}
state.file = undefined;
state.companions = undefined;
state.uploadSessionId = undefined;
state.uploadStatus = "pending";
state.progressBytes = 0;
@@ -403,6 +416,27 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
state.etaSeconds = null;
state.error = undefined;
renderSlot(slot);
if (slot === "csv") renderRouteDependentSlots();
}
/** 노선 도형이 shapefile인가 — 로컬 선택과 서버 정본을 함께 본다. */
function routeIsShapefile(): boolean {
const state = slots.get("csv");
const name = state?.file?.name ?? state?.serverUploaded?.name;
return getExtension(name ?? "") === ".shp";
}
/**
* 이 카드가 지금 필수인가.
*
* shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 —
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
*/
function isSlotRequired(state: FileSlotState): boolean {
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot))
return routeIsShapefile();
if (state.slot === "las_laz") return !lasFreeDesign;
return state.isRequired;
}
function validateSlots(): string | null {
@@ -414,11 +448,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
const missingRequired = Array.from(slots.values()).some(
(state) =>
state.isRequired &&
!state.file &&
!state.serverUploaded &&
// LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다.
!(lasFreeDesign && state.slot === "las_laz"),
isSlotRequired(state) && !state.file && !state.serverUploaded,
);
if (missingRequired) return L("B03_File_Error_RequiredSlots");
if (!lasFreeDesign) {
@@ -450,9 +480,20 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
for (const state of slots.values()) state.serverUploaded = undefined;
for (const file of overview.files) {
const extension = `.${file.file_type.toLowerCase()}`;
const state = Array.from(slots.values()).find((candidate) =>
candidate.extensions.includes(extension),
);
// PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로
// 저장 경로로 가린다(2026-08-31).
const inRouteSet = (file.relative_path ?? "").includes("/input/shp/");
const slot: FileSlot | undefined =
extension === ".prj"
? inRouteSet
? "route_prj"
: "prj"
: Array.from(slots.values()).find(
(candidate) =>
candidate.slot !== "route_prj" &&
candidate.extensions.includes(extension),
)?.slot;
const state = slot ? slots.get(slot) : undefined;
if (state) {
state.serverUploaded = {
name: file.original_filename,
@@ -495,17 +536,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
card.querySelector(".b03-file__card-label")!.textContent = L(
state.labelKey,
);
// 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다.
const extLabel = state.extensions.join(", ");
card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired
? extLabel
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
renderExtensionLabel(card, state);
const input = card.querySelector<HTMLInputElement>(
".b03-file__slot-input",
)!;
input.accept = state.extensions.join(",");
// 계획노선 shapefile은 한 벌(.shp/.shx/.dbf/.cpg/.prj)을 같이 골라야 한다.
input.multiple = state.extensions.includes(".shp");
const select = card.querySelector<HTMLButtonElement>(
".b03-file__card-select",
)!;
@@ -529,15 +564,24 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
function createCardGroup(
title: string,
groupSlots: readonly FileSlot[],
modifier?: string,
hint?: string,
): HTMLElement {
const group = document.createElement("section");
group.className = "b03-file__group";
if (modifier) group.classList.add(modifier);
if (title) {
const groupTitle = document.createElement("h3");
groupTitle.className = "b03-file__group-title";
groupTitle.textContent = title;
group.append(groupTitle);
}
if (hint) {
const groupHint = document.createElement("p");
groupHint.className = "b03-file__group-hint";
groupHint.textContent = hint;
group.append(groupHint);
}
const content = document.createElement("div");
content.className = "b03-file__group-content";
for (const slot of groupSlots) {
@@ -682,32 +726,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
setUploading(true);
const uploaded: UploadedFileResult[] = [];
try {
// 슬롯 하나가 파일 여럿일 수 있다(계획노선 shapefile 세트) — 완료 신호는 **마지막
// 파일 한 건**에만 붙어야 하므로 슬롯이 아니라 파일 단위로 펼쳐서 센다.
const jobs: { state: FileSlotState; file: File }[] = [];
for (const state of targetStates) {
for (const companion of state.companions ?? [])
jobs.push({ state, file: companion });
if (state.file) jobs.push({ state, file: state.file });
}
for (let index = 0; index < jobs.length; index += 1) {
const { state, file } = jobs[index];
// 동반 파일은 진행률·세션을 대표 파일과 섞지 않도록 임시 상태로 올린다.
const uploadState =
file === state.file
? state
: {
...state,
file,
companions: undefined,
uploadSessionId: undefined,
progressBytes: 0,
};
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
uploaded.push(
...(await uploadOneFile(
activeProjectId,
uploadState,
index === jobs.length - 1,
state,
index === targetStates.length - 1,
() => renderSlot(state.slot),
lasFreeDesign,
)),
@@ -804,15 +829,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
resultList,
);
// 계획노선과 지형 자료는 지형 래스터(tif)만 빼면 모두 필수라 따로 묶지 않는다
// (2026-08-08 사용자 지시).
const inputsGroup = createCardGroup(L("B03_File_Group_Inputs"), [
"csv",
"las_laz",
"prj",
"tfw",
"tif",
]);
// 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터).
// 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지
// 화면만 보고 안다(2026-08-31 사용자 지시).
const routeGroup = createCardGroup(
L("B03_File_Group_Route"),
ROUTE_SLOTS,
"b03-file__group--route",
L("B03_File_Group_Route_Hint"),
);
const terrainGroup = createCardGroup(
L("B03_File_Group_Terrain"),
TERRAIN_SLOTS,
"b03-file__group--terrain",
L("B03_File_Group_Terrain_Hint"),
);
// LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다.
const lasFreeRow = document.createElement("label");
@@ -850,10 +881,22 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
pageError.textContent = "";
});
const cardsContainer = document.createElement("div");
cardsContainer.className =
// LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다.
terrainGroup.append(lasFreeRow);
const routePanel = document.createElement("div");
routePanel.className =
"b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(lasFreeRow, inputsGroup);
routePanel.append(routeGroup);
const terrainPanel = document.createElement("div");
terrainPanel.className =
"b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.append(terrainGroup);
const cardsContainer = document.createElement("div");
cardsContainer.className = "b03-file__columns";
cardsContainer.append(routePanel, terrainPanel);
const workflowState = activeProjectId
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
+33 -7
View File
@@ -180,12 +180,27 @@
gap: var(--spacing-32);
}
/* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시).
노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다. */
.b03-file__columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-24);
align-items: start;
}
.b03-file__group {
display: flex;
flex-direction: column;
gap: var(--spacing-20);
}
.b03-file__group-hint {
font-size: var(--text-body-sm, 14px);
color: var(--color-slate, #615e6e);
margin: calc(var(--spacing-8) * -1) 0 0 0;
}
.b03-file__group-title {
font-size: var(--text-subheading, 24px);
color: var(--color-deep-iris, #26114a);
@@ -195,7 +210,7 @@
.b03-file__group-content {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 3열 구조 */
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-24);
}
@@ -260,14 +275,17 @@
border-color: var(--color-danger, #dc2626);
}
/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이
글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */
.b03-file__card-header {
display: grid;
grid-template-columns: auto 1fr auto auto; /* 아이콘 및 닫기 버튼 주변 확보 */
gap: var(--spacing-12);
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.b03-file__card-icon {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: var(--radius-icons, 8px);
@@ -284,6 +302,7 @@
.b03-file__card-heading {
min-width: 0;
flex: 1 1 55%;
display: flex;
flex-direction: column;
gap: 2px;
@@ -294,6 +313,11 @@
color: var(--color-deep-iris, #26114a);
font-size: var(--text-body-sm, 14px);
font-weight: var(--font-weight-medium, 500);
/* 한글은 글자 단위로 끊기므로 어절을 지켜 준다 — "계획
노선
도형" 방지. */
word-break: keep-all;
overflow-wrap: break-word;
}
.b03-file__card-ext,
@@ -306,6 +330,7 @@
.b03-file__card-badge-container {
display: flex;
align-items: center;
margin-left: auto;
margin-right: var(--spacing-4);
}
@@ -463,9 +488,10 @@
border-bottom: 0;
}
@media (max-width: 1280px) {
.b03-file__group-content {
grid-template-columns: repeat(2, 1fr); /* 좁은 창에서는 2열 */
@media (max-width: 1440px) {
/* 좁아지면 두 컨테이너를 위아래로 쌓는다 — 카드가 눌려 글자가 접히는 것을 막는다. */
.b03-file__columns {
grid-template-columns: 1fr;
}
}
+98 -33
View File
@@ -1,6 +1,45 @@
import { ui_locales } from "@ui/ui_template_locale";
export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
/**
* = . 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 {
@@ -13,12 +52,6 @@ export interface SlotConfig {
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;
@@ -54,6 +87,34 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
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",
@@ -94,37 +155,41 @@ export function getBaseName(fileName: string): string {
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` .
* `.prj` .
* ** (.shp) basename이 ** ,
* . `routeStem` 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;
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 (
SHAPEFILE_COMPANION_EXT.includes(extension) &&
getBaseName(file.name) === stem
) {
companions.push(file);
} else {
rest.push(file);
if (extension === ".prj") {
const isRoute = stem !== undefined && getBaseName(file.name) === stem;
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
}
}
return { shapefile: { primary, companions }, rest };
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 {