From 1eb5ad4a609c9f8f39351aebe7065f784f1928ad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 31 Aug 2026 19:50:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(B03):=20=EC=9E=85=EB=A0=A5=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20=EA=B3=84=ED=9A=8D=EB=85=B8=EC=84=A0/?= =?UTF-8?q?=EC=A7=80=ED=98=95=20=EB=91=90=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EB=A1=9C=20=EB=82=98=EB=88=84=EA=B3=A0=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A7=88=EB=8B=A4=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노선 파일이 다섯이면 카드도 다섯이어야 어느 것이 왔는지 보인다(사용자 지시). 세트를 슬롯 하나에 몰아 담던 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) --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 2 + B03_FileInput/B03_FileInput_Repository.py | 17 +- B03_FileInput/B03_FileInput_Router.py | 6 +- B03_FileInput/B03_FileInput_Schema.py | 3 + B03_FileInput/B03_FileInput_UI_Page.ts | 199 +++++++++++++--------- B03_FileInput/B03_FileInput_UI_Style.css | 40 ++++- B03_FileInput/B03_FileInput_UI_Support.ts | 131 ++++++++++---- config/config_frontend.ts | 6 +- ui_template/ui_template_locale_b1.ts | 29 +++- 9 files changed, 301 insertions(+), 132 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index d2f53bb2..27265301 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -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 { diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index 5460975b..de194576 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -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 diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 33df2550..d63f236c 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -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 ], diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 3506e764..18d24b7a 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -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): diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 9447cec8..463629f7 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -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 { 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( ".b03-file__file-name", @@ -290,14 +305,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { async function assignFileToSlot( file: File, targetSlot?: FileSlot, - companions: File[] = [], ): Promise { - 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 { } 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 { 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 { // 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다. // 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 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 { } 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 { 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 { 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 { // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(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 { 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 { 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( ".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( ".b03-file__card-select", )!; @@ -529,15 +564,24 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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 { 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 { 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 { 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) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index ba5c4e5a..550b1ed7 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -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; } } diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index d601a250..ea3502f9 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -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 { diff --git a/config/config_frontend.ts b/config/config_frontend.ts index b8128140..c4a653ea 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -30,7 +30,9 @@ export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id"; export const UPLOAD_MAX_MB = 30 * 1024; /** 한 요청에서 선택 가능한 최대 파일 수 */ -export const UPLOAD_MAX_FILES = 5; +// 한 번에 채울 수 있는 **카드 수**. 계획노선 shapefile이 카드 5장을 쓰므로 +// 노선 5 + 지형 4 = 9가 최대다(2026-08-31). +export const UPLOAD_MAX_FILES = 10; /** 청크 업로드 단위 (MB) */ export const UPLOAD_CHUNK_SIZE_MB = 1024; @@ -56,7 +58,7 @@ export const UPLOAD_ALLOWED_EXT = [ ".dxf", ] as const; -/** 계획노선 shapefile 한 벌 — 노선 슬롯에 통째로 담긴다. */ +/** 계획노선 shapefile 한 벌 — 카드 다섯 장에 한 개씩 담긴다. */ export const SHAPEFILE_MEMBER_EXT = [".shp", ".shx", ".dbf", ".cpg", ".prj"] as const; /* ----------------------------------------------------------------------------- diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 44277d3f..4753e41a 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -276,8 +276,8 @@ export const ui_locales_b1 = { ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], B03_File_Select_Hint: [ - "계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.", - "Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.", + "계획노선(CSV 또는 shapefile 5개)과 LAS/LAZ 1개, 지형 PRJ·TFW를 함께 고르면 카드에 나뉩니다. TIF는 선택 사항입니다.", + "Pick the planned route (a CSV or the five shapefile files), one LAS/LAZ, and the terrain PRJ and TFW together — they are sorted into cards. TIF is optional.", ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], @@ -330,9 +330,23 @@ export const ui_locales_b1 = { B03_File_Result_Path: ["저장 경로", "Stored path"], B03_File_Group_Required: ["필수 파일", "Required files"], B03_File_Group_Optional: ["선택 파일", "Optional files"], - /* 지형 래스터만 선택 항목이라 계획노선·지형 자료를 한 묶음으로 둔다(2026-08-08). */ + /* 자료 출처가 갈려 컨테이너를 둘로 나눈다 — 계획노선 / 지형(LAS)(2026-08-31). */ B03_File_Group_Inputs: ["입력 자료", "Input files"], - B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"], + B03_File_Group_Route: ["계획노선 자료", "Planned route files"], + B03_File_Group_Terrain: ["지형 자료 (LAS)", "Terrain files (LAS)"], + B03_File_Group_Route_Hint: [ + "shapefile은 파일 5개가 한 벌입니다. CSV 한 장으로 넣어도 됩니다.", + "A shapefile is a set of five files. A single CSV also works.", + ], + B03_File_Group_Terrain_Hint: [ + "여기의 PRJ·TFW는 지형 자료의 좌표계입니다 — 노선 PRJ와 별개입니다.", + "The PRJ and TFW here describe the terrain data, separate from the route PRJ.", + ], + B03_File_Slot_PlannedRoute: ["계획노선 도형", "Planned Route Geometry"], + B03_File_Slot_RouteIndex: ["노선 도형 색인", "Route Shape Index"], + B03_File_Slot_RouteAttribute: ["노선 속성", "Route Attributes"], + B03_File_Slot_RouteEncoding: ["노선 속성 인코딩", "Route Attribute Encoding"], + B03_File_Slot_RouteProjection: ["노선 좌표계", "Route Projection"], B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"], B03_File_LasFree_Toggle: [ "LAS 없이 설계 (도엽등고선 기반)", @@ -342,7 +356,7 @@ export const ui_locales_b1 = { "포인트클라우드 없이 1:5,000 수치지형도 등고선으로 지형을 만듭니다.", "Terrain is built from 1:5,000 map sheet contours without a point cloud.", ], - B03_File_Slot_Projection: ["좌표계 정의", "Projection"], + B03_File_Slot_Projection: ["지형 좌표계", "Terrain Projection"], B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"], B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"], B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], @@ -385,8 +399,9 @@ export const ui_locales_b1 = { "A file for this slot is already selected.", ], B03_File_Error_RequiredSlots: [ - "필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.", - "Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.", + "필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.", + "Fill every required card: the planned route (a CSV or a full shapefile set), " + + "LAS/LAZ, and the terrain PRJ and TFW.", ], B03_File_Error_SlotType: [ "선택한 파일 유형이 이 카드와 맞지 않습니다.",