- 지형 파일 개수 제한 해제(정확히 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>
137 lines
5.6 KiB
TypeScript
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 };
|
|
}
|