feat(B04/B05): 프로그레스 서클을 3D·지도·그래프 전 영역에 공통 적용

- 공통 서클 양식을 로딩 스피너(.ui-spinner) 기준으로 통일하고 overlay 옵션 신설
  (컨테이너 정중앙 배치를 페이지별 CSS 없이 처리)
- B05 3D: 위쪽 18% → 뷰포트 정중앙 (하단 패널에 가려져도 무방)
- B04 포인트클라우드 뷰어: setLoading() 신설, render() 시 자동 해제
- B04 지형 뷰어: GLTF/PLY 로더 진행 이벤트로 실제 바이트 진행률 표시
- B04 2D 지도: 도엽 레이어 n/10 진행률
- B05 종단면 그래프: body-wrap으로 감싸 서클 유지, 자료 조회 전후로 토글
- B05 배수유역도: 배경도 → 도엽 레이어 → 세부유역 산정 단계 진행률
This commit is contained in:
2026-08-01 09:29:53 +09:00
parent f6be8d7307
commit 13c2522f47
10 changed files with 132 additions and 22 deletions
@@ -1,4 +1,5 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import {
fetchGisGeoJson,
fetchVWorldMeta,
@@ -125,13 +126,23 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
scaleBar.className = "b04-map__scale";
const scaleText = document.createElement("span");
scaleBar.append(scaleText);
// 지도 정중앙 로딩 서클 — 도엽 레이어가 10종이라 다 받을 때까지 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewport.append(
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
canvas,
empty,
statusStack,
scaleBar,
progress.root,
);
/** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(ratio, label);
}
root.append(header, viewport);
let currentProjectId: string | null = null;
@@ -366,8 +377,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
preparedLayers.clear();
resetView();
status.textContent = L("B04_Surface_Map_Loading");
showProgress(0, L("B04_Surface_Map_Loading"));
try {
const nextMeta = await fetchVWorldMeta(projectId, "satellite");
// 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다.
let done = 0;
const loadedLayers = await Promise.all(
GIS_LAYERS.map(async (layer) => {
try {
@@ -375,6 +389,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
return [layer, data] as const;
} catch {
return [layer, null] as const;
} finally {
done += 1;
if (sequence === loadSequence) {
showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`);
}
}
}),
);
@@ -397,9 +416,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
);
resetView();
syncLayerVisibility();
showProgress(null, null);
} catch (error) {
if (sequence !== loadSequence) return;
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
showProgress(null, null);
}
}
@@ -300,6 +300,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
showLoadingOverlay();
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
terrainViewer.setReferenceBounds(pointCloud.bounds);
@@ -324,6 +325,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
models = modelResponse.models;
renderInputFiles(inputs.files);
renderStatus(status);
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
terrainViewer.setReferenceBounds(pointCloud.bounds);
@@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend";
import { createProgressCircle } from "@ui/ui_template_progress";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
import {
bindCursorPivotControls,
@@ -168,6 +169,17 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
legendBar.append(maxValSpan, gradientDiv, minValSpan);
viewerArea.append(legendBar);
// 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewerArea.append(progress.root);
/** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(ratio, label);
}
// Three.js context variables
let currentProjectId = "";
let currentModelsList: readonly SurfaceModelSummary[] = [];
@@ -311,6 +323,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
currentModelId = null;
scaleBar.hidden = true;
statusSpan.textContent = "모델 조회 중...";
showProgress(null, "모델 조회 중…");
// 1. Find matching model in list
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
@@ -327,6 +340,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
if (!match) {
statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다.";
showProgress(null, null);
return;
}
@@ -337,8 +351,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const generation = ++loadGeneration;
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
showProgress(0, "3D 메쉬 내려받는 중…");
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
/** 로더 진행 이벤트 → 서클. 서버가 길이를 안 주면(gzip) 진행률 없이 회전만 시킨다. */
const onDownload = (event: ProgressEvent): void => {
if (generation !== loadGeneration) return;
const ratio = event.lengthComputable && event.total > 0 ? event.loaded / event.total : null;
showProgress(ratio, "3D 메쉬 내려받는 중…");
};
try {
if (activeMethod === "meshfree") {
new PLYLoader().load(
@@ -359,12 +381,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
terrainMesh = points;
scene.add(points);
fitCamera(points);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
},
undefined,
onDownload,
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
showProgress(null, null);
},
);
} else {
@@ -385,17 +410,21 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
terrainMesh = gltf.scene;
scene.add(gltf.scene);
fitCamera(gltf.scene);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
},
undefined,
onDownload,
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
}
} catch (e) {
statusSpan.textContent = "에러 발생";
showProgress(null, null);
}
}
@@ -1,4 +1,5 @@
import { RENDER_OPTIONS } from "@config/config_frontend";
import { createProgressCircle } from "@ui/ui_template_progress";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
@@ -20,6 +21,8 @@ export interface SurfacePointCloudViewer {
controlsGroup: HTMLElement;
optionsGroup: HTMLElement;
statusSpan: HTMLElement;
/** 로딩 서클 표시. 문구를 주면 켜고, null이면 끈다. `render()` 시 자동으로 꺼진다. */
setLoading: (label: string | null) => void;
render: (data: SurfacePointCloudSampleResponse | null) => void;
setAxesVisible: (visible: boolean) => void;
applyCameraState: (state: SurfaceCameraState) => void;
@@ -101,6 +104,15 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
scaleBar.append(scaleText);
viewerArea.append(canvas, scaleBar, statusSpan);
root.append(viewerArea);
// 뷰포트 정중앙 로딩 서클 — 지도·그래프·다른 3D 뷰어와 같은 공통 컴포넌트.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewerArea.append(progress.root);
function setLoading(label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(null, label);
}
const renderer = new THREE.WebGLRenderer({
canvas,
@@ -306,7 +318,9 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
controlsGroup,
optionsGroup,
statusSpan,
setLoading,
render(data) {
setLoading(null);
currentData = data;
clearPoints();
if (!data) {
@@ -31,6 +31,7 @@ import {
type BoundaryOverrideEntry,
} from "./B05_wf2_Route_UI_Drainage_Boundary";
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
import { createProgressCircle } from "@ui/ui_template_progress";
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동).
@@ -147,7 +148,16 @@ export function createDrainagePanel(): DrainagePanel {
const status = document.createElement("span");
status.className = "b05-drainage__status";
status.textContent = "노선을 확정하면 배수유역도가 표시됩니다.";
viewport.append(backgroundImage, canvas, status);
// 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewport.append(backgroundImage, canvas, status, progress.root);
/** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(ratio, label);
}
// 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다.
const basinList = document.createElement("div");
basinList.className = "b05-drainage__basins";
@@ -412,6 +422,7 @@ export function createDrainagePanel(): DrainagePanel {
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = "세부유역을 산정하는 중…";
showProgress(null, "세부유역을 산정하는 중…");
try {
const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined;
const response = await fetchDrainageBasins(projectId, chainages);
@@ -441,6 +452,7 @@ export function createDrainagePanel(): DrainagePanel {
status.textContent = error instanceof Error ? error.message : "세부유역 산정에 실패했습니다.";
} finally {
analyzeButton.disabled = false;
showProgress(null, null);
}
}
@@ -503,8 +515,10 @@ export function createDrainagePanel(): DrainagePanel {
backgroundImage.removeAttribute("src");
status.hidden = false;
status.textContent = "배경도를 불러오는 중…";
showProgress(0, "배경도를 불러오는 중…");
try {
const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite");
showProgress(1 / 3, "도엽 레이어를 불러오는 중…");
const loaded = await Promise.all(
DRAINAGE_LAYERS.map(async (layer) => {
try {
@@ -531,12 +545,14 @@ export function createDrainagePanel(): DrainagePanel {
if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요.";
fitToRoute();
scheduleDraw();
showProgress(2 / 3, "세부유역을 산정하는 중…");
// B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다.
void analyze(true);
} catch (error) {
if (sequence !== loadSequence) return;
status.hidden = false;
status.textContent = error instanceof Error ? error.message : "배경도를 불러오지 못했습니다.";
showProgress(null, null);
}
}
+6 -3
View File
@@ -409,15 +409,18 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
async function restoreSections(routeId: number): Promise<void> {
let detail: SectionDetailResponse;
profilePanel.setLoading("종단면 자료를 불러오는 중…");
try {
detail = await fetchSectionDetail(activeProjectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
currentSectionDetail = null;
profilePanel.setLoading(null);
profilePanel.clear();
viewer.renderStationLines([], 0);
return;
}
profilePanel.setLoading(null);
try {
renderSections(detail, routeId);
// 복귀/최초 진입 시(클라이언트 목록이 비어 있을 때만) 확정된 비정규 측점을 사이드바에 복원한다.
@@ -603,9 +606,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
* 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안
* 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */
const LOAD_STEP_COUNT = 5;
const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…" });
// 하단 종단 패널(z-index 3)보다 아래에 둔다 — 패널을 볼 때 서클이 방해하지 않는다.
progress.root.classList.add("b05-route__progress");
// 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다
// (2026-08-01 사용자 지시).
const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true });
viewer.root.append(progress.root);
let loadedSteps = 0;
function advanceLoading(label: string): void {
@@ -22,6 +22,7 @@ import {
import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch";
import type {
@@ -272,10 +273,17 @@ export function createRouteProfilePanel(
body.append(empty);
// 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성.
// 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
bodyWrap.append(body, progress.root);
const content = document.createElement("div");
content.className = "b05-route-profile__content";
const drainagePanel = createDrainagePanel();
content.append(body, drainagePanel.root);
content.append(bodyWrap, drainagePanel.root);
root.append(panelHandle.root, balanceBar, content);
drainagePanel.load(projectId);
@@ -639,6 +647,11 @@ export function createRouteProfilePanel(
},
/** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */
drainage: drainagePanel,
/** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */
setLoading(label: string | null) {
progress.root.hidden = label === null;
if (label !== null) progress.set(null, label);
},
dispose() {
window.clearTimeout(resizeTimer);
resizeObserver.disconnect();
+10 -10
View File
@@ -69,6 +69,15 @@
min-width: 0;
}
/* 그래프 본문 + 로딩 서클을 겹치기 위한 칸. body는 그릴 때마다 자식이 교체된다. */
.b05-route-profile__body-wrap {
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.b05-route-profile__body {
box-sizing: border-box;
flex: 1 1 auto;
@@ -126,6 +135,7 @@
transform: translateY(-50%);
}
.b05-route-profile.is-collapsed .b05-route-profile__body-wrap,
.b05-route-profile.is-collapsed .b05-route-profile__body,
.b05-route-profile.is-collapsed .b05-route-profile__balance {
display: none;
@@ -182,16 +192,6 @@
pointer-events: none;
}
/* 진입 로딩 서클 — 3D 뷰포트 위쪽 가운데. 하단 종단 패널(z-index 3)보다 아래에 둬서
패널을 볼 때 가리지 않는다(2026-08-01 사용자 지시). */
.b05-route__progress {
position: absolute;
z-index: 1;
top: 18%;
left: 50%;
transform: translateX(-50%);
}
/* 뷰셋·표시 토글 버튼 묶음 — 좌상단(2026-08-01 사용자 지시).
안내 문구가 우상단으로 옮겨져 좌상단이 비었다. */
.b05-route__view-controls {
+13 -3
View File
@@ -1,6 +1,7 @@
/* 공통 프로그레스 서클 — 뷰포트 위에 얹는 원형 진행 표시. */
/* 공통 프로그레스 서클 — 뷰포트 위에 얹는 원형 진행 표시.
테두리 두께·색은 공통 로딩 스피너(.ui-spinner)와 같게 맞추고, 가운데에 진행률만 더한다. */
.ui-progress-circle {
--ui-progress-size: 96px;
--ui-progress-size: 72px;
display: flex;
flex-direction: column;
align-items: center;
@@ -9,6 +10,15 @@
user-select: none;
}
/* 컨테이너 정중앙 오버레이 — 3D 뷰포트·지도·그래프 어디에나 같은 방식으로 얹는다.
컨테이너에 position: relative 가 있어야 한다. */
.ui-progress-circle--overlay {
position: absolute;
z-index: 1;
inset: 0;
justify-content: center;
}
.ui-progress-circle__dial {
position: relative;
width: var(--ui-progress-size);
@@ -24,7 +34,7 @@
.ui-progress-circle__track {
fill: none;
stroke: color-mix(in srgb, var(--color-border) 70%, transparent);
stroke: var(--color-mist-violet);
stroke-width: 8;
}
+4 -2
View File
@@ -15,10 +15,12 @@ const RADIUS = 42;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
export interface ProgressCircleOptions {
/** 지름(px). 기본 96. */
/** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */
size?: number;
/** 서클 아래 안내 문구. */
label?: string;
/** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */
overlay?: boolean;
}
export interface ProgressCircleHandle {
@@ -31,7 +33,7 @@ export interface ProgressCircleHandle {
export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle {
const root = document.createElement("div");
root.className = "ui-progress-circle";
root.className = "ui-progress-circle" + (options.overlay ? " ui-progress-circle--overlay" : "");
root.setAttribute("role", "status");
root.setAttribute("aria-live", "polite");
if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`);