/* ============================================================================= * B03_FileInput_UI_Page_Rules.ts * 파일 입력 화면의 판정 규칙 — 어떤 카드가 지금 필수인가, 고른 파일이 그 자리에 맞는가, * 업로드를 시작해도 되는가, 서버 파일이 어느 카드로 가는가. * * 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). * 화면 상태를 가두지 않고 **인자로 받는 순수 함수**만 둔다 — 판정 결과는 종전과 같다. * ========================================================================== */ import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch"; import { readCrsLabel, readExtent, type PreviewExtent } from "./B03_FileInput_UI_Preview"; import { getExtension, SHAPEFILE_DEPENDENT_SLOTS, TERRAIN_SLOTS, type FileSlot, type FileSlotState, } from "./B03_FileInput_UI_Support"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export type SlotMap = Map; /** 노선 도형 카드에 shapefile이 들어와 있는가. */ export function routeIsShapefile(slots: SlotMap): 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). */ export function isSlotRequired( state: FileSlotState, slots: SlotMap, lasFreeDesign: boolean, ): boolean { if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile(slots); // LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다. if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired; return state.isRequired; } /** 고른 파일이 그 카드의 확장자·크기 규칙에 맞는가. 어긋나면 안내 문구를 돌려준다. */ export function validateFileForSlot(file: File, state: FileSlotState): string | null { const extension = getExtension(file.name); const maxBytes = UPLOAD_MAX_MB * 1024 * 1024; if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType"); if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size"); return null; } /** 업로드를 시작해도 되는가. 안 되면 첫 번째 사유를 돌려준다. */ export function validateSlots( slots: SlotMap, selected: FileSlotState[], activeProjectId: string, lasFreeDesign: boolean, ): string | null { if (!activeProjectId) return L("B03_File_Error_Project"); if (selected.length === 0) return L("B03_File_Error_Required"); if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count"); // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시). const missingRequired = Array.from(slots.values()).some( (state) => isSlotRequired(state, slots, lasFreeDesign) && !state.file && !state.serverUploaded, ); if (missingRequired) return L("B03_File_Error_RequiredSlots"); if (!lasFreeDesign) { const lasState = slots.get("las_laz"); if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las"); } for (const state of selected) { if (state.error) return state.error; const validation = validateFileForSlot(state.file!, state); if (validation) return validation; } return null; } /** * 서버 현황의 파일 한 건이 어느 카드로 가는가. * * PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 저장 경로로 * 가린다(2026-08-31). 옛 프로젝트의 노선은 `.csv`로 올라가 있어 계획노선 도형 카드로 * 보낸다(이제 새로 받지는 않는다, 2026-09-03). */ export function slotForOverviewFile( file: UploadOverviewFile, slots: SlotMap, ): FileSlot | undefined { const extension = `.${file.file_type.toLowerCase()}`; if (extension === ".prj") { return (file.relative_path ?? "").includes("/input/shp/") ? "route_prj" : "prj"; } if (extension === ".csv") return "csv"; return Array.from(slots.values()).find( (candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension), )?.slot; } /** * 서버에 올라온 지형 자료가 덮는 범위와 좌표계 — 카드 미리보기가 쓴다. * 여러 카드(포인트클라우드·좌표계·래스터)의 범위를 합친다. 화면 조립부가 700줄을 * 넘어 옮겨 온 순수 함수다(2026-09-06). */ export function terrainCoverage(slots: SlotMap): { extent: PreviewExtent | null; crs: string | null; } { let extent: PreviewExtent | null = null; let crs: string | null = null; for (const slot of TERRAIN_SLOTS) { const metadata = slots.get(slot)?.serverUploaded?.metadata; const next = readExtent(metadata); if (!next) continue; crs ??= readCrsLabel(metadata); extent = extent ? { xMin: Math.min(extent.xMin, next.xMin), xMax: Math.max(extent.xMax, next.xMax), yMin: Math.min(extent.yMin, next.yMin), yMax: Math.max(extent.yMax, next.yMax), } : next; } return { extent, crs }; }