Files
Aislo/B03_FileInput/B03_FileInput_UI_Page_Rules.ts
T
eomsangdonandClaude Opus 5 72075563e3 feat(B03·B04): 지형 라이다 여러 장 입력·병합 전처리
- 지형 파일 개수 제한 해제(정확히 1개 → 1장 이상), 한 카드에 여러 장 담기
  (화면 표시 「용화_서편.las 외 1장」, 업로드는 한 장씩 차례로 전송)
- 구조화 엔진이 여러 파일을 합친 범위로 한 벌 생성 — WF1 자동 전처리·B04 재분석 모두
  프로젝트 지형 파일 전부를 대상으로 실행
- 점이 1억 개를 넘으면 씨닝 — 지면 분류점은 전부 남기고 나머지만 0.5m 칸 최저점으로 축소
  (용화 실측: 4,900만점 → 249만점, 지면점 1,140,716개 그대로, 1m 지면격자 표고차 0.0000m)
- 머리글만 읽어 5km 넘게 떨어진 파일은 업로드 거부 (임도는 길어도 2~3km)
- 화면 조립부 700줄 준수를 위해 terrainCoverage 를 판정 모듈로 이동

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:45:59 +09:00

137 lines
5.6 KiB
TypeScript

/* =============================================================================
* 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<FileSlot, FileSlotState>;
/** 노선 도형 카드에 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 };
}