From 13c2522f47b7cbc94935a82ea5b1e29263c1cb90 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 09:29:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04/B05):=20=ED=94=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=8A=A4=20=EC=84=9C=ED=81=B4=EC=9D=84=203D=C2=B7?= =?UTF-8?q?=EC=A7=80=EB=8F=84=C2=B7=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=A0=84?= =?UTF-8?q?=20=EC=98=81=EC=97=AD=EC=97=90=20=EA=B3=B5=ED=86=B5=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통 서클 양식을 로딩 스피너(.ui-spinner) 기준으로 통일하고 overlay 옵션 신설 (컨테이너 정중앙 배치를 페이지별 CSS 없이 처리) - B05 3D: 위쪽 18% → 뷰포트 정중앙 (하단 패널에 가려져도 무방) - B04 포인트클라우드 뷰어: setLoading() 신설, render() 시 자동 해제 - B04 지형 뷰어: GLTF/PLY 로더 진행 이벤트로 실제 바이트 진행률 표시 - B04 2D 지도: 도엽 레이어 n/10 진행률 - B05 종단면 그래프: body-wrap으로 감싸 서클 유지, 자료 조회 전후로 토글 - B05 배수유역도: 배경도 → 도엽 레이어 → 세부유역 산정 단계 진행률 --- .../B04_wf1_Surface_UI_MapViewer.ts | 21 ++++++++++++ B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 2 ++ .../B04_wf1_Surface_UI_TerrainViewer.ts | 33 +++++++++++++++++-- B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 14 ++++++++ .../B05_wf2_Route_UI_Drainage_Panel.ts | 18 +++++++++- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 9 +++-- .../B05_wf2_Route_UI_Profile_Panel.ts | 15 ++++++++- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 20 +++++------ ui_template/ui_template_progress.css | 16 +++++++-- ui_template/ui_template_progress.ts | 6 ++-- 10 files changed, 132 insertions(+), 22 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index c23319a8..30499e4e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -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); } } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 4b2d5a50..19137412 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -300,6 +300,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { 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 { models = modelResponse.models; renderInputFiles(inputs.files); renderStatus(status); + viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index b5cde486..6160fa2f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -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); } } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index ae2c5c87..8f1160cc 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -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) { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 84187701..226e7682 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -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); } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index c83c7a60..d811af61 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -409,15 +409,18 @@ export async function renderB05Route(root: HTMLElement): Promise { async function restoreSections(routeId: number): Promise { 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 { * 자료가 끝나는 순서대로 채운다. 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 { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 58f426c4..9b9cece8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index e6674dca..d09c8761 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -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 { diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index 29f463bf..9deb740c 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -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; } diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index b133fed5..00c12cfb 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -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`);