feat(B03/B04): LAS 없이 도엽등고선으로 3D 서피스를 만들어 설계한다
- B03: 'LAS 없이 설계' 토글 — LAS 필수카드 비활성화, las_free 플래그로
업로드·완료 검증 면제, 계획노선 CSV를 WF1 분석 입력으로 사용
- B04: 신규 Engine_SheetSurface — 도엽_등고선.geojson을 노선 bbox+300m
직사각형으로 절취, 배수유역 엔진의 등고선 정점구름·Delaunay TIN 보간을
재사용해 dtm_sheet.npz(1m 격자, LAS DTM과 동일 형식) + 프리뷰 glb 생성.
build_surface_sampler('sheet','dtm')로 종·횡단·배수 하류 계산 무수정 동작
- WF1: las_free면 run_sheet_surface_analysis로 분기, sheet/dtm 자동 확정.
VWorld·도엽 확보 블록을 download_geodata()로 추출해 두 경로가 공유
- LAS가 있어도 도엽 서피스를 함께 생성·등록(참고용), B04에
'도엽등고 3D 서피스' 별도 컨테이너로 표시
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,12 +69,15 @@ export async function uploadProjectFiles(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/files`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
@@ -87,19 +90,24 @@ export async function createUploadSession(
|
||||
chunkSizeBytes: number,
|
||||
fingerprint?: string | null,
|
||||
completeUpload = false,
|
||||
lasFree = false,
|
||||
): Promise<ChunkSessionCreateResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
original_filename: file.name,
|
||||
size_bytes: file.size,
|
||||
chunk_size_bytes: chunkSizeBytes,
|
||||
fingerprint: fingerprint ?? null,
|
||||
complete_upload: completeUpload,
|
||||
}),
|
||||
});
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
original_filename: file.name,
|
||||
size_bytes: file.size,
|
||||
chunk_size_bytes: chunkSizeBytes,
|
||||
fingerprint: fingerprint ?? null,
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
|
||||
}
|
||||
|
||||
@@ -128,18 +136,23 @@ export async function finalizeUploadSession(
|
||||
totalChunks: number,
|
||||
completeUpload: boolean,
|
||||
fingerprint?: string | null,
|
||||
lasFree = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
total_chunks: totalChunks,
|
||||
complete_upload: completeUpload,
|
||||
fingerprint: fingerprint ?? null,
|
||||
}),
|
||||
});
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/finalize`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
total_chunks: totalChunks,
|
||||
complete_upload: completeUpload,
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
}
|
||||
|
||||
@@ -147,10 +160,13 @@ export async function fetchUploadStatus(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<UploadStatusResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
return await readJsonOrThrow<UploadStatusResponse>(response);
|
||||
}
|
||||
|
||||
@@ -182,11 +198,16 @@ export interface UploadOverviewResponse {
|
||||
}
|
||||
|
||||
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
|
||||
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
export async function fetchUploadOverview(
|
||||
projectId: string,
|
||||
): Promise<UploadOverviewResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
return await readJsonOrThrow<UploadOverviewResponse>(response);
|
||||
}
|
||||
|
||||
@@ -200,10 +221,15 @@ export interface WF1AnalysisStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
export async function checkWF1AnalysisStatus(
|
||||
projectId: string,
|
||||
): Promise<WF1AnalysisStatus> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/surface/status`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
return await readJsonOrThrow<WF1AnalysisStatus>(response);
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ async def create_input_file(
|
||||
async def get_project_input_readiness(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
) -> tuple[set[str], int | None]:
|
||||
"""현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다."""
|
||||
) -> tuple[set[str], int | None, int | None]:
|
||||
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 CSV 입력 ID를 반환한다."""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
@@ -83,7 +83,12 @@ async def get_project_input_readiness(
|
||||
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
|
||||
None,
|
||||
)
|
||||
return file_types, point_cloud_id
|
||||
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 CSV가 분석 원천이 된다.
|
||||
route_csv_id = next(
|
||||
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "csv"),
|
||||
None,
|
||||
)
|
||||
return file_types, point_cloud_id, route_csv_id
|
||||
|
||||
|
||||
async def get_project_storage_relative_path(
|
||||
|
||||
@@ -93,15 +93,16 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
|
||||
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
|
||||
|
||||
|
||||
def _missing_required_file_types(file_types: set[str]) -> list[str]:
|
||||
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
|
||||
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
|
||||
if not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
|
||||
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다.
|
||||
if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
|
||||
missing.append("las/laz")
|
||||
return missing
|
||||
|
||||
|
||||
def _require_complete_file_set(file_types: set[str]) -> None:
|
||||
missing = _missing_required_file_types(file_types)
|
||||
def _require_complete_file_set(file_types: set[str], las_free: bool = False) -> None:
|
||||
missing = _missing_required_file_types(file_types, las_free)
|
||||
if missing:
|
||||
raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}")
|
||||
|
||||
@@ -153,11 +154,17 @@ async def _already_uploaded(
|
||||
async def _complete_file_input_if_ready(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
las_free: bool = False,
|
||||
) -> int:
|
||||
file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id)
|
||||
_require_complete_file_set(file_types)
|
||||
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
|
||||
connection, project_id
|
||||
)
|
||||
_require_complete_file_set(file_types, las_free)
|
||||
if point_cloud_input_id is None:
|
||||
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
|
||||
if not las_free:
|
||||
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
|
||||
if route_csv_input_id is None:
|
||||
raise ValueError("계획 노선 CSV 입력 파일을 찾을 수 없습니다.")
|
||||
# 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면
|
||||
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -168,7 +175,8 @@ async def _complete_file_input_if_ready(
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await reset_stages_after_input_change(cursor, str(project_id))
|
||||
await complete_stage(cursor, str(project_id), 0)
|
||||
return point_cloud_input_id
|
||||
# LAS가 있으면 LAS, 없으면(las_free) 계획노선 CSV가 WF1 분석 입력이다.
|
||||
return point_cloud_input_id if point_cloud_input_id is not None else int(route_csv_input_id)
|
||||
|
||||
|
||||
def _write_stage_metadata(
|
||||
@@ -289,6 +297,7 @@ async def _send_upload_complete_notification(
|
||||
async def upload_project_files(
|
||||
project_id: UUID,
|
||||
files: list[UploadFile] = File(...),
|
||||
las_free: bool = Form(False),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
||||
@@ -308,7 +317,8 @@ async def upload_project_files(
|
||||
content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."},
|
||||
)
|
||||
las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames)
|
||||
if las_count != 1:
|
||||
# LAS 없는 설계(las_free)는 LAS 0개를 허용한다 — 올렸다면 정상 경로로 취급.
|
||||
if las_count != 1 and not (las_free and las_count == 0):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
@@ -326,7 +336,7 @@ async def upload_project_files(
|
||||
},
|
||||
)
|
||||
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
|
||||
missing_required = _missing_required_file_types(request_file_types)
|
||||
missing_required = _missing_required_file_types(request_file_types, las_free)
|
||||
if missing_required:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@@ -385,7 +395,9 @@ async def upload_project_files(
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id)
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(
|
||||
connection, project_id, las_free
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -460,6 +472,7 @@ async def create_project_upload_session(
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(
|
||||
connection,
|
||||
project_id,
|
||||
payload.las_free,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -649,6 +662,7 @@ async def finalize_project_upload(
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(
|
||||
connection,
|
||||
project_id,
|
||||
payload.las_free,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -753,7 +767,9 @@ async def get_project_upload_overview(
|
||||
async with pool.acquire() as connection:
|
||||
files = await list_project_input_files(connection, project_id)
|
||||
sessions = await list_incomplete_upload_sessions(connection, project_id)
|
||||
file_types, point_cloud_id = await get_project_input_readiness(connection, project_id)
|
||||
file_types, point_cloud_id, _route_csv_id = await get_project_input_readiness(
|
||||
connection, project_id
|
||||
)
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
state = await get_workflow_state(cursor, str(project_id))
|
||||
stages = (state or {}).get("stages") or []
|
||||
@@ -761,6 +777,11 @@ async def get_project_upload_overview(
|
||||
int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE"
|
||||
for stage in stages
|
||||
)
|
||||
# LAS 없는 설계로 stage 0을 마친 프로젝트는 LAS가 없어도 필수 충족으로 본다.
|
||||
stage0_complete = any(
|
||||
int(stage.get("stage_no", -1)) == 0 and str(stage.get("state")) == "COMPLETE"
|
||||
for stage in stages
|
||||
)
|
||||
return UploadOverviewResponse(
|
||||
files=[
|
||||
UploadOverviewFile(
|
||||
@@ -787,7 +808,8 @@ async def get_project_upload_overview(
|
||||
)
|
||||
for row in sessions
|
||||
],
|
||||
required_complete=_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None,
|
||||
required_complete=_REQUIRED_FILE_TYPES <= file_types
|
||||
and (point_cloud_id is not None or stage0_complete),
|
||||
analysis_complete=analysis_complete,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -57,6 +57,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
|
||||
|
||||
chunk_size_bytes: int = Field(default=UPLOAD_CHUNK_SIZE_BYTES, gt=0)
|
||||
complete_upload: bool = False
|
||||
# LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다.
|
||||
las_free: bool = False
|
||||
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
|
||||
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
|
||||
fingerprint: str | None = Field(default=None, max_length=128)
|
||||
@@ -96,6 +98,8 @@ class UploadFinalizeRequest(BaseModel):
|
||||
session_id: str = Field(min_length=1, max_length=36)
|
||||
total_chunks: int = Field(gt=0)
|
||||
complete_upload: bool = True
|
||||
# LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다.
|
||||
las_free: bool = False
|
||||
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
|
||||
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
|
||||
fingerprint: str | None = Field(default=None, max_length=128)
|
||||
|
||||
@@ -20,6 +20,7 @@ from config.config_db import get_db_pool
|
||||
from config.config_system import (
|
||||
AUTO_DESIGN_CHAIN_ENABLED,
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
SURFACE_CONTOUR_INTERVAL_M,
|
||||
SURFACE_MODEL_PRECOMPUTE,
|
||||
SURFACE_MODEL_SOURCE_FILTERS,
|
||||
)
|
||||
@@ -79,9 +80,15 @@ async def trigger_wf1_analysis_and_email(
|
||||
input_file = await get_input_file(connection, project_id, input_file_id)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
las_path = project_root / Path(str(input_file["raw_file_path"]))
|
||||
if not las_path.is_file():
|
||||
raise FileNotFoundError("원본 LAS/LAZ 파일을 찾을 수 없습니다.")
|
||||
source_path = project_root / Path(str(input_file["raw_file_path"]))
|
||||
# LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다.
|
||||
las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"}
|
||||
if not source_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
"계획 노선 파일을 찾을 수 없습니다."
|
||||
if las_free
|
||||
else "원본 LAS/LAZ 파일을 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis
|
||||
from B04_PreProcess.B04_PreProcess_Repository import save_surface_analysis_to_db
|
||||
@@ -92,15 +99,27 @@ async def trigger_wf1_analysis_and_email(
|
||||
def _on_progress(percent: int, stage: str, message: str) -> None:
|
||||
write_surface_progress(project_root, percent, stage, message)
|
||||
|
||||
analysis_result = await asyncio.to_thread(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
las_path,
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=False,
|
||||
on_progress=_on_progress,
|
||||
)
|
||||
if las_free:
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
|
||||
run_sheet_surface_analysis,
|
||||
)
|
||||
|
||||
analysis_result = await asyncio.to_thread(
|
||||
run_sheet_surface_analysis,
|
||||
project_root,
|
||||
source_path,
|
||||
on_progress=_on_progress,
|
||||
)
|
||||
else:
|
||||
analysis_result = await asyncio.to_thread(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
source_path,
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=False,
|
||||
on_progress=_on_progress,
|
||||
)
|
||||
|
||||
auto_confirmation_error: str | None = None
|
||||
auto_confirmed = False
|
||||
@@ -124,7 +143,17 @@ async def trigger_wf1_analysis_and_email(
|
||||
find_surface_model_for_selection,
|
||||
)
|
||||
|
||||
selection = surface_confirmation_defaults()
|
||||
# LAS 없는 설계는 도엽 서피스 모델(sheet/dtm)로 확정한다.
|
||||
selection = (
|
||||
{
|
||||
"source_filter": "sheet",
|
||||
"method": "dtm",
|
||||
"smooth": False,
|
||||
"contour_interval_m": SURFACE_CONTOUR_INTERVAL_M,
|
||||
}
|
||||
if las_free
|
||||
else surface_confirmation_defaults()
|
||||
)
|
||||
try:
|
||||
model_id = await find_surface_model_for_selection(
|
||||
connection, project_id, selection
|
||||
|
||||
@@ -9,8 +9,14 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
import {
|
||||
createStepBar,
|
||||
WORKFLOW_STEP_ICONS,
|
||||
} from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
fetchUploadOverview,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
||||
@@ -66,6 +72,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// 단계로 넘어가는 것을 막는다(2026-08-08 사용자 지시).
|
||||
let pageRoot: HTMLElement | null = null;
|
||||
let isUploading = false;
|
||||
// LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 프로젝트별로 기억한다.
|
||||
let lasFreeDesign = activeProjectId
|
||||
? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1"
|
||||
: false;
|
||||
|
||||
function clearDerivedCaches(projectId: string): void {
|
||||
clearRouteLatestCache(projectId);
|
||||
@@ -114,7 +124,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return Array.from(slots.values()).filter((state) => state.file);
|
||||
}
|
||||
|
||||
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
|
||||
function setCardState(
|
||||
slot: FileSlot,
|
||||
stateName: "empty" | "selected" | UploadStatus,
|
||||
): void {
|
||||
const card = cardMap.get(slot);
|
||||
if (!card) return;
|
||||
card.classList.remove(
|
||||
@@ -128,7 +141,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const cssState = stateName === "failed" ? "error" : stateName;
|
||||
card.classList.add(`b03-file__card--${cssState}`);
|
||||
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__card-badge-container",
|
||||
);
|
||||
if (badgeContainer) {
|
||||
badgeContainer.replaceChildren();
|
||||
if (stateName === "empty") {
|
||||
@@ -183,18 +198,38 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const card = cardMap.get(slot);
|
||||
if (!state || !card) return;
|
||||
|
||||
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
|
||||
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
|
||||
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
|
||||
const fileName = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-name",
|
||||
);
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-size",
|
||||
);
|
||||
const progress = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__progress-bar",
|
||||
);
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-bytes",
|
||||
);
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-speed",
|
||||
);
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-eta",
|
||||
);
|
||||
const error = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__error-message",
|
||||
);
|
||||
const remove = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-remove",
|
||||
);
|
||||
|
||||
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
|
||||
const percent = state.file
|
||||
? Math.min(100, (state.progressBytes / state.file.size) * 100)
|
||||
: 0;
|
||||
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
|
||||
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileName)
|
||||
fileName.textContent =
|
||||
state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileSize) {
|
||||
fileSize.textContent = state.file
|
||||
? formatBytes(state.file.size)
|
||||
@@ -220,8 +255,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (remove) remove.hidden = !state.file;
|
||||
|
||||
if (state.error) setCardState(slot, "failed");
|
||||
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
|
||||
else if (!state.file)
|
||||
setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else
|
||||
setCardState(
|
||||
slot,
|
||||
state.uploadStatus === "pending" ? "selected" : state.uploadStatus,
|
||||
);
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
@@ -233,19 +273,29 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
renderSlot(slot);
|
||||
}
|
||||
|
||||
function validateFileForSlot(file: File, state: FileSlotState): string | null {
|
||||
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");
|
||||
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;
|
||||
}
|
||||
|
||||
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
|
||||
async function assignFileToSlot(
|
||||
file: File,
|
||||
targetSlot?: FileSlot,
|
||||
): Promise<void> {
|
||||
const extension = getExtension(file.name);
|
||||
const state = targetSlot
|
||||
? slots.get(targetSlot)
|
||||
: Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension));
|
||||
: Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
if (!state) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
return;
|
||||
@@ -256,13 +306,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (!targetSlot && state.file && state.file.name !== file.name) {
|
||||
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
|
||||
showErrorMessage(
|
||||
state.slot,
|
||||
`${L("B03_File_Error_DuplicateSlot")} ${file.name}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
|
||||
// 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다.
|
||||
if (state.serverUploaded) {
|
||||
const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name);
|
||||
const accepted = await confirmReplaceUpload(
|
||||
L(state.labelKey),
|
||||
state.serverUploaded.name,
|
||||
);
|
||||
if (!accepted) return;
|
||||
}
|
||||
|
||||
@@ -286,8 +342,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const extension = getExtension(file.name);
|
||||
const slot =
|
||||
targetSlot ??
|
||||
Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension))
|
||||
?.slot;
|
||||
Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
if (slot) occupied.add(slot);
|
||||
}
|
||||
if (occupied.size > UPLOAD_MAX_FILES) {
|
||||
@@ -325,11 +382,19 @@ 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,
|
||||
(state) =>
|
||||
state.isRequired &&
|
||||
!state.file &&
|
||||
!state.serverUploaded &&
|
||||
// LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다.
|
||||
!(lasFreeDesign && state.slot === "las_laz"),
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
const lasState = slots.get("las_laz");
|
||||
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
|
||||
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);
|
||||
@@ -358,7 +423,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
if (state) {
|
||||
state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb };
|
||||
state.serverUploaded = {
|
||||
name: file.original_filename,
|
||||
sizeMb: file.file_size_mb,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const slot of slots.keys()) renderSlot(slot);
|
||||
@@ -393,22 +461,30 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!card) throw new Error("file-card-template is invalid");
|
||||
card.dataset.slotId = state.slot;
|
||||
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
|
||||
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")}`;
|
||||
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
|
||||
const input = card.querySelector<HTMLInputElement>(
|
||||
".b03-file__slot-input",
|
||||
)!;
|
||||
input.accept = state.extensions.join(",");
|
||||
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
|
||||
const select = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-select",
|
||||
)!;
|
||||
select.textContent = L("B03_File_Card_Select");
|
||||
select.addEventListener("click", () => input.click());
|
||||
input.addEventListener("change", () => {
|
||||
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
|
||||
input.value = "";
|
||||
});
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
|
||||
const remove = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-remove",
|
||||
)!;
|
||||
remove.textContent = "×";
|
||||
remove.title = L("B03_File_Card_Remove");
|
||||
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
|
||||
@@ -417,7 +493,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return card;
|
||||
}
|
||||
|
||||
function createCardGroup(title: string, groupSlots: readonly FileSlot[]): HTMLElement {
|
||||
function createCardGroup(
|
||||
title: string,
|
||||
groupSlots: readonly FileSlot[],
|
||||
): HTMLElement {
|
||||
const group = document.createElement("section");
|
||||
group.className = "b03-file__group";
|
||||
if (title) {
|
||||
@@ -443,7 +522,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
for (const state of selectedStates()) {
|
||||
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
|
||||
const stored = localStorage.getItem(
|
||||
makeSessionKey(activeProjectId, state.file!),
|
||||
);
|
||||
if (!stored) continue;
|
||||
const session = JSON.parse(stored) as StoredUploadSession;
|
||||
state.uploadSessionId = session.uploadSessionId;
|
||||
@@ -513,7 +594,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
const detail =
|
||||
error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
|
||||
showToast(L("B03_Temp_Attach_Failed"), "error");
|
||||
}
|
||||
@@ -542,7 +624,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
|
||||
async function startChunkedUpload(
|
||||
targetStates = selectedStates(),
|
||||
): Promise<void> {
|
||||
if (isUploading) return;
|
||||
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
|
||||
if (tempPicker.selected()) {
|
||||
@@ -568,8 +652,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
for (let index = 0; index < targetStates.length; index += 1) {
|
||||
const state = targetStates[index];
|
||||
uploaded.push(
|
||||
...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1, () =>
|
||||
renderSlot(state.slot),
|
||||
...(await uploadOneFile(
|
||||
activeProjectId,
|
||||
state,
|
||||
index === targetStates.length - 1,
|
||||
() => renderSlot(state.slot),
|
||||
lasFreeDesign,
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -586,8 +674,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
const failed = targetStates.find(
|
||||
(state) => state.uploadStatus === "uploading",
|
||||
);
|
||||
const detail =
|
||||
error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
if (failed) showErrorMessage(failed.slot, detail);
|
||||
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
@@ -621,7 +712,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
function onB03_File_Drop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("is-dragging");
|
||||
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
onFileSelected(
|
||||
event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [],
|
||||
);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", onB03_File_Select_Change);
|
||||
@@ -633,7 +726,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("is-dragging");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
|
||||
dropzone.addEventListener("dragleave", () =>
|
||||
dropzone.classList.remove("is-dragging"),
|
||||
);
|
||||
dropzone.addEventListener("drop", onB03_File_Drop);
|
||||
|
||||
uploadButton = createButton({
|
||||
@@ -667,9 +762,37 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
"tif",
|
||||
]);
|
||||
|
||||
// LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다.
|
||||
const lasFreeRow = document.createElement("label");
|
||||
lasFreeRow.className = "b03-file__lasfree";
|
||||
lasFreeRow.title = L("B03_File_LasFree_Hint");
|
||||
const lasFreeCheck = document.createElement("input");
|
||||
lasFreeCheck.type = "checkbox";
|
||||
const lasFreeText = document.createElement("span");
|
||||
lasFreeText.textContent = L("B03_File_LasFree_Toggle");
|
||||
lasFreeRow.append(lasFreeCheck, lasFreeText);
|
||||
function applyLasFreeState(): void {
|
||||
lasFreeCheck.checked = lasFreeDesign;
|
||||
cardMap
|
||||
.get("las_laz")
|
||||
?.classList.toggle("b03-file__card--disabled", lasFreeDesign);
|
||||
}
|
||||
lasFreeCheck.addEventListener("change", () => {
|
||||
lasFreeDesign = lasFreeCheck.checked;
|
||||
if (activeProjectId) {
|
||||
localStorage.setItem(
|
||||
`b03_las_free_${activeProjectId}`,
|
||||
lasFreeDesign ? "1" : "0",
|
||||
);
|
||||
}
|
||||
applyLasFreeState();
|
||||
pageError.textContent = "";
|
||||
});
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
cardsContainer.append(inputsGroup);
|
||||
cardsContainer.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
cardsContainer.append(lasFreeRow, inputsGroup);
|
||||
|
||||
const workflowState = activeProjectId
|
||||
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
|
||||
@@ -705,6 +828,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
for (const slot of slots.keys()) renderSlot(slot);
|
||||
applyLasFreeState();
|
||||
void relockWhileInitialPipelineRuns();
|
||||
void registerB03ServiceWorker();
|
||||
void applyUploadOverview();
|
||||
|
||||
@@ -277,7 +277,9 @@
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
margin-right: var(
|
||||
--spacing-8
|
||||
); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
@@ -321,7 +323,9 @@
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
margin-left: var(
|
||||
--spacing-8
|
||||
); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
@@ -474,3 +478,20 @@
|
||||
grid-template-columns: 1fr; /* 모바일에서는 1행 1열 구조 */
|
||||
}
|
||||
}
|
||||
|
||||
/* LAS 없는 설계(도엽등고선 기반) 토글 — 2026-08-30 */
|
||||
.b03-file__lasfree {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 2px;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.b03-file__card--disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
||||
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
PROGRESS_UPDATE_INTERVAL_MS,
|
||||
UPLOAD_CHUNK_SIZE_MB,
|
||||
} from "@config/config_frontend";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { fileFingerprint } from "./B03_FileInput_Fingerprint";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
@@ -18,7 +24,10 @@ import {
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
|
||||
import {
|
||||
saveB03UploadedFile,
|
||||
updateB03AnalysisState,
|
||||
} from "./B03_FileInput_State";
|
||||
import {
|
||||
makeSessionKey,
|
||||
type FileSlotState,
|
||||
@@ -33,7 +42,10 @@ function L(key: keyof typeof ui_locales): string {
|
||||
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
|
||||
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
|
||||
*/
|
||||
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
|
||||
export function confirmReplaceUpload(
|
||||
slotLabel: string,
|
||||
fileName: string,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "b03-file__modal-backdrop";
|
||||
@@ -98,6 +110,7 @@ export async function uploadOneFile(
|
||||
state: FileSlotState,
|
||||
completeUpload: boolean,
|
||||
onProgress: () => void,
|
||||
lasFree = false,
|
||||
): Promise<UploadedFileResult[]> {
|
||||
const file = state.file;
|
||||
if (!file) return [];
|
||||
@@ -107,7 +120,9 @@ export async function uploadOneFile(
|
||||
|
||||
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
|
||||
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
|
||||
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
||||
const fingerprint = state.uploadSessionId
|
||||
? null
|
||||
: await fileFingerprint(file);
|
||||
let session = state.uploadSessionId;
|
||||
if (!session) {
|
||||
const created = await createUploadSession(
|
||||
@@ -116,6 +131,7 @@ export async function uploadOneFile(
|
||||
chunkSizeBytes,
|
||||
fingerprint,
|
||||
completeUpload,
|
||||
lasFree,
|
||||
);
|
||||
if (created.already_uploaded) {
|
||||
state.progressBytes = file.size;
|
||||
@@ -140,11 +156,22 @@ export async function uploadOneFile(
|
||||
const start = chunkIndex * chunkSizeBytes;
|
||||
const end = Math.min(file.size, start + chunkSizeBytes);
|
||||
const chunkStartedAt = performance.now();
|
||||
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
|
||||
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
|
||||
await uploadFileChunk(
|
||||
projectId,
|
||||
session,
|
||||
chunkIndex,
|
||||
file.slice(start, end),
|
||||
);
|
||||
const elapsedSec = Math.max(
|
||||
0.001,
|
||||
(performance.now() - chunkStartedAt) / 1000,
|
||||
);
|
||||
state.progressBytes = end;
|
||||
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
|
||||
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
|
||||
state.etaSeconds =
|
||||
state.speedMbs > 0
|
||||
? (file.size - end) / 1024 / 1024 / state.speedMbs
|
||||
: null;
|
||||
|
||||
const stored: StoredUploadSession = {
|
||||
key: storageKey,
|
||||
@@ -161,7 +188,10 @@ export async function uploadOneFile(
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const now = performance.now();
|
||||
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
|
||||
if (
|
||||
now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS ||
|
||||
chunkIndex === totalChunks - 1
|
||||
) {
|
||||
lastPaintAt = now;
|
||||
onProgress();
|
||||
}
|
||||
@@ -173,6 +203,7 @@ export async function uploadOneFile(
|
||||
totalChunks,
|
||||
completeUpload,
|
||||
fingerprint,
|
||||
lasFree,
|
||||
);
|
||||
localStorage.removeItem(storageKey);
|
||||
saveB03UploadedFile(projectId, {
|
||||
@@ -182,7 +213,10 @@ export async function uploadOneFile(
|
||||
});
|
||||
state.progressBytes = file.size;
|
||||
state.speedMbs =
|
||||
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
file.size /
|
||||
1024 /
|
||||
1024 /
|
||||
Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
state.etaSeconds = 0;
|
||||
state.uploadStatus = "completed";
|
||||
onProgress();
|
||||
@@ -203,9 +237,12 @@ export async function uploadOneFile(
|
||||
*
|
||||
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
|
||||
*/
|
||||
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
|
||||
export function isInitialPipelineRunning(
|
||||
state: WorkflowState | undefined,
|
||||
): boolean {
|
||||
if (!state?.stages?.length) return false;
|
||||
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
|
||||
const stageAt = (stageNo: number) =>
|
||||
state.stages.find((stage) => stage.stage_no === stageNo);
|
||||
const fileInput = stageAt(0);
|
||||
const preprocess = stageAt(1);
|
||||
const section = stageAt(3);
|
||||
|
||||
Reference in New Issue
Block a user