diff --git a/A05_EduDetail/guide/B04/_scenes.json b/A05_EduDetail/guide/B04/_scenes.json index e40cde8a8..0c15bcf5f 100644 --- a/A05_EduDetail/guide/B04/_scenes.json +++ b/A05_EduDetail/guide/B04/_scenes.json @@ -162,23 +162,23 @@ "callouts": [ { "n": 1, - "sel": ".model-display-options > .toggle-button:nth-child(1)" + "sel": ".model-display-options > .ui-toggle:nth-child(1)" }, { "n": 2, - "sel": ".contour-interval-input" + "sel": ".contour-interval-form .ui-input" }, { "n": 3, - "sel": ".contour-interval-submit" + "sel": ".contour-interval-form .ui-btn" }, { "n": 4, - "sel": ".viewer-option-row:nth-of-type(1)" + "sel": ".b04-surface__options > .ui-slider:nth-of-type(1)" }, { "n": 5, - "sel": ".viewer-option-row:nth-of-type(2)" + "sel": ".b04-surface__options > .ui-slider:nth-of-type(2)" } ] }, @@ -248,7 +248,7 @@ }, { "n": 3, - "sel": ".b04-surface__reset" + "sel": ".ui-sidebar-actions .ui-btn--danger-ghost" } ] }, @@ -311,15 +311,15 @@ "callouts": [ { "n": 1, - "sel": ".b04-surface__sheet-toolbar" + "sel": ".b04-surface__sheet-section .ui-toolbar" }, { "n": 2, - "sel": ".b04-surface__sheet-smoothing" + "sel": ".b04-surface__sheet-section .ui-toolbar .ui-field" }, { "n": 3, - "sel": ".b04-surface__sheet-lidar" + "sel": ".b04-surface__sheet-section .ui-toggle--chip" } ] }, diff --git a/B04_PreProcess/B04_PreProcess_UI_Basins.ts b/B04_PreProcess/B04_PreProcess_UI_Basins.ts index 408614e69..96f61cc8b 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Basins.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Basins.ts @@ -19,6 +19,7 @@ import { pointInRings } from "./B04_PreProcess_UI_MapOverlays"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; +import { createLayerToggle, setLayerLabel } from "./B04_PreProcess_UI_LayerToggle"; import { computeDetailBasins, fetchDetailPipePoints, @@ -179,30 +180,33 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl const menu = createMapContextMenu("b04-map"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "b04-map__layer-button b04-map__layer-button--gis"; - button.textContent = L("B04_Surface_Basin_Btn"); - button.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a")); - button.title = L("B04_Surface_Basin_Btn_Tip"); + // 분석 단추 — 층 칩 꼴 · 눌림은 남기지 않고 바로 다시 계산한다 + const toggle = createLayerToggle({ + label: L("B04_Surface_Basin_Btn"), + color: themeColor("--map-pipe-user", "#16a34a"), + title: L("B04_Surface_Basin_Btn_Tip"), + onChange: () => { + toggle.set(false); + void recompute(); + }, + }); + const button = toggle.root; // 관 마커와 세부유역은 늘 같이 본다 — 따로 끄면 관을 옮겨도 유역이 안 보여 판단할 수 없다. // 그래서 토글은 하나뿐이다(2026-08-01 사용자 지시). let shown = true; - const partButton = document.createElement("button"); - partButton.type = "button"; - partButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; - partButton.textContent = L("B04_Surface_Basin_Part_Basins"); - partButton.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a")); - partButton.setAttribute("aria-pressed", "true"); - partButton.addEventListener("click", () => { - shown = !shown; - partButton.classList.toggle("is-active", shown); - partButton.setAttribute("aria-pressed", String(shown)); - if (!shown) menu.close(); - onChange(); - }); - const partButtons = [partButton]; + const partButtons = [ + createLayerToggle({ + label: L("B04_Surface_Basin_Part_Basins"), + color: themeColor("--map-pipe-user", "#16a34a"), + on: true, + onChange: (on) => { + shown = on; + if (!shown) menu.close(); + onChange(); + }, + }).root, + ]; function say(text: string): void { statusElement.textContent = text; @@ -328,7 +332,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl if (!projectId || busy) return; busy = true; button.disabled = true; - button.textContent = L("B04_Surface_Basin_Btn_Busy"); + setLayerLabel(toggle, L("B04_Surface_Basin_Btn_Busy")); const sequence = ++requestSequence; try { const response = await computeDetailBasins( @@ -343,7 +347,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl } finally { busy = false; button.disabled = false; - button.textContent = L("B04_Surface_Basin_Btn"); + setLayerLabel(toggle, L("B04_Surface_Basin_Btn")); onChange(); } } @@ -366,10 +370,6 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl onChange(); } - button.addEventListener("click", () => { - void recompute(); - }); - return { button, partButtons, diff --git a/B04_PreProcess/B04_PreProcess_UI_FlowStrength.ts b/B04_PreProcess/B04_PreProcess_UI_FlowStrength.ts index a6c6ea3bf..104e9c937 100644 --- a/B04_PreProcess/B04_PreProcess_UI_FlowStrength.ts +++ b/B04_PreProcess/B04_PreProcess_UI_FlowStrength.ts @@ -12,6 +12,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; +import { createLayerToggle } from "./B04_PreProcess_UI_LayerToggle"; import { fetchRoadInflow, type VWorldMeta } from "./B04_PreProcess_Api_Fetch"; import { haloColor, @@ -84,15 +85,19 @@ function formatArea(areaM2: number): string { } export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOverlay { - const button = document.createElement("button"); - button.type = "button"; // 기본 켜짐(2026-08-01 사용자 지시) — 분석 결과가 들어오면 바로 보이게 한다. let shown = true; - button.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; - button.textContent = L("B04_Surface_Flow_Strength"); - button.style.setProperty("--b04-layer-color", themeColor("--map-flow-ramp-5", "#dc2626")); - button.setAttribute("aria-pressed", "true"); - button.title = L("B04_Surface_Flow_Strength_Tip"); + const button = createLayerToggle({ + label: L("B04_Surface_Flow_Strength"), + color: themeColor("--map-flow-ramp-5", "#dc2626"), + on: true, + title: L("B04_Surface_Flow_Strength_Tip"), + onChange: (on) => { + shown = on; + syncLegend(); + onChange(); + }, + }).root; const legend = createFlowLegend(); /** 범례는 강도 색칠이 켜져 있고 값이 있을 때만 띄운다. */ @@ -100,38 +105,26 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve legend.update(maxStrength, shown); } - button.addEventListener("click", () => { - shown = !shown; - button.classList.toggle("is-active", shown); - button.setAttribute("aria-pressed", String(shown)); - syncLegend(); - onChange(); - }); - // 유입 집중점 마커는 관 매설 마커와 같은 계획선 위에 찍혀 서로 가린다. 그래서 강도 색칠과 // 떼어 내 별도 토글을 두고, 기본은 꺼 둔다(2026-08-01 사용자 지시). let markersShown = false; - const markerButton = document.createElement("button"); - markerButton.type = "button"; - markerButton.className = "b04-map__layer-button b04-map__layer-button--gis"; - markerButton.textContent = L("B04_Surface_Flow_Hotspots"); - markerButton.style.setProperty("--b04-layer-color", themeColor("--map-flow-ramp-4", "#f97316")); - markerButton.setAttribute("aria-pressed", "false"); - markerButton.title = L("B04_Surface_Flow_Hotspots_Tip"); - markerButton.addEventListener("click", () => { - markersShown = !markersShown; - markerButton.classList.toggle("is-active", markersShown); - markerButton.setAttribute("aria-pressed", String(markersShown)); - if (!markersShown) { - // 마커를 감추면 골라 둔 유입 외곽선도 같이 걷는다 — 고른 마커가 안 보이는데 외곽선만 - // 남으면 무엇을 본 결과인지 알 수 없다. - selected = null; - selectionRings = []; - statusText = ""; - requestSequence += 1; - } - onChange(); - }); + const markerButton = createLayerToggle({ + label: L("B04_Surface_Flow_Hotspots"), + color: themeColor("--map-flow-ramp-4", "#f97316"), + title: L("B04_Surface_Flow_Hotspots_Tip"), + onChange: (on) => { + markersShown = on; + if (!markersShown) { + // 마커를 감추면 골라 둔 유입 외곽선도 같이 걷는다 — 고른 마커가 안 보이는데 외곽선만 + // 남으면 무엇을 본 결과인지 알 수 없다. + selected = null; + selectionRings = []; + statusText = ""; + requestSequence += 1; + } + onChange(); + }, + }).root; let projectId: string | null = null; let meta: VWorldMeta | null = null; diff --git a/B04_PreProcess/B04_PreProcess_UI_Group.ts b/B04_PreProcess/B04_PreProcess_UI_Group.ts new file mode 100644 index 000000000..ed77335d6 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_Group.ts @@ -0,0 +1,20 @@ +/* ============================================================================= + * B04_PreProcess_UI_Group.ts + * 묶음 상자 — 카드 부품 + 패널 머리 부품을 한 번에 조립한다(왼쪽 패널 넷 · 도엽 서피스 칸). + * ========================================================================== */ + +import { createPanelHead } from "@ui/ui_template_panel_head"; + +/** 제목이 달린 카드. `className` 은 칸 안 배치(기본 = 세로 쌓임) 자리 이름. */ +export function buildSurfaceGroup( + title: string, + children: HTMLElement[], + className = "b04-surface__group", +): HTMLElement { + const root = document.createElement("section"); + // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). + root.className = `ui-card ui-sidebar-section ${className}`; + root.dataset.pad = "16"; + root.append(createPanelHead({ title }).root, ...children); + return root; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_LayerToggle.ts b/B04_PreProcess/B04_PreProcess_UI_LayerToggle.ts new file mode 100644 index 000000000..f4b839755 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_LayerToggle.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * B04_PreProcess_UI_LayerToggle.ts + * 지도 층 켜기 칩 — 켜기 부품(칩)에 층 색 점을 붙여 지도 층 단추 자리를 채운다. + * 점 색 = 그 층을 지도에 그리는 선 색(그림 값 그대로 · 이 파일은 색을 정하지 않음). + * ========================================================================== */ + +import { createToggle, type ToggleHandle } from "@ui/ui_template_toggle"; + +/** 층 칩 한 개. `color` 가 없으면 점 없이 글만. */ +export function createLayerToggle(opts: { + label: string; + color?: string; + on?: boolean; + title?: string; + onChange?: (on: boolean) => void; +}): ToggleHandle { + const toggle = createToggle({ + kind: "chip", + label: opts.label, + dot: opts.color, + checked: opts.on ?? false, + size: 28, + onChange: opts.onChange, + }); + if (opts.title) toggle.root.title = opts.title; + return toggle; +} + +/** 칩 글자만 바꿈(분석 중 · 불러오는 중 표시) */ +export function setLayerLabel(toggle: ToggleHandle, text: string): void { + const node = toggle.root.querySelector(".ui-toggle__text"); + if (node) node.textContent = text; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index cf4ec2f40..91978e232 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -1,6 +1,8 @@ +import { createButton } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { wheelZoomsIn } from "../A00_Common/touch_bridge"; import { createProgressCircle } from "@ui/ui_template_progress"; +import { createToolbar } from "@ui/ui_template_toolbar"; import { fetchPlannedRoute, fetchVWorldMeta, @@ -23,6 +25,7 @@ import { type GisLayer, } from "./B04_PreProcess_UI_MapLayers"; import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength"; +import { createLayerToggle } from "./B04_PreProcess_UI_LayerToggle"; import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed"; import { computeMapRect, @@ -60,15 +63,16 @@ function L(key: keyof typeof ui_locales): string { export function createSurfaceMapViewer(): SurfaceMapViewer { const root = document.createElement("section"); - root.className = "b04-map"; + root.className = "ui-card b04-map"; + root.dataset.pad = "16"; const header = document.createElement("div"); header.className = "b04-map__header"; const title = document.createElement("h3"); title.textContent = L("B04_Surface_Map_Title"); - const controls = document.createElement("div"); - controls.className = "b04-map__controls"; + const controls = createToolbar({ items: [], size: 28 }); + controls.classList.add("b04-map__controls"); const backgroundGroup = document.createElement("div"); backgroundGroup.className = "b04-map__control-group"; const backgroundTitle = document.createElement("span"); @@ -85,9 +89,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { gisButtons.className = "b04-map__layer-buttons"; gisGroup.append(gisTitle, gisButtons); - const resetButton = document.createElement("button"); - resetButton.type = "button"; - resetButton.textContent = L("B04_Surface_Map_Reset"); + const resetButton = createButton({ + label: L("B04_Surface_Map_Reset"), + variant: "ghost", + size: 28, + }); controls.append(backgroundGroup, gisGroup, resetButton); header.append(title, controls); @@ -188,25 +194,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { layer: T, color?: string, ): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - const initialActive = activeLayers.has(layer); - button.className = "b04-map__layer-button" + (initialActive ? " is-active" : ""); - button.textContent = label; - button.setAttribute("aria-pressed", String(initialActive)); - if (color) { - button.classList.add("b04-map__layer-button--gis"); - button.style.setProperty("--b04-layer-color", color); - } - button.addEventListener("click", () => { - if (activeLayers.has(layer)) activeLayers.delete(layer); - else activeLayers.add(layer); - const isActive = activeLayers.has(layer); - button.classList.toggle("is-active", isActive); - button.setAttribute("aria-pressed", String(isActive)); - syncLayerVisibility(); - }); - return button; + return createLayerToggle({ + label, + color, + on: activeLayers.has(layer), + onChange: (on) => { + if (on) activeLayers.add(layer); + else activeLayers.delete(layer); + syncLayerVisibility(); + }, + }).root; } const backgroundLabels: Record = { @@ -231,19 +228,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }; // 계획선 토글 — 도엽 레이어와 같은 양식으로 레이어 줄 맨 앞에 둔다. 색은 B05 배수유역도와 // 같은 주황(정의처: MapRender). 계획노선을 아직 올리지 않았으면 버튼이 눌리지 않는다. - const routeButton = document.createElement("button"); - routeButton.type = "button"; - routeButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; - routeButton.textContent = L("B04_Surface_Map_PlannedRoute"); - routeButton.style.setProperty("--b04-layer-color", routeLineColor()); - routeButton.setAttribute("aria-pressed", "true"); - routeButton.addEventListener("click", () => { - showRoute = !showRoute; - routeButton.classList.toggle("is-active", showRoute); - routeButton.setAttribute("aria-pressed", String(showRoute)); - scheduleDraw(); + const routeToggle = createLayerToggle({ + label: L("B04_Surface_Map_PlannedRoute"), + color: routeLineColor(), + on: true, + onChange: (on) => { + showRoute = on; + scheduleDraw(); + }, }); - gisButtons.append(routeButton); + gisButtons.append(routeToggle.root); GIS_LAYERS.forEach((layer) => { gisButtons.append( @@ -252,19 +246,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }); // 등고 라벨 보기/숨기기 (등고선·도엽 등고선의 계곡선 수치 표기) - const contourLabelButton = document.createElement("button"); - contourLabelButton.type = "button"; - contourLabelButton.className = - "b04-map__layer-button" + (CONTOUR_LABEL_DEFAULT_ON ? " is-active" : ""); - contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel"); - contourLabelButton.setAttribute("aria-pressed", String(CONTOUR_LABEL_DEFAULT_ON)); - contourLabelButton.addEventListener("click", () => { - showContourLabels = !showContourLabels; - contourLabelButton.classList.toggle("is-active", showContourLabels); - contourLabelButton.setAttribute("aria-pressed", String(showContourLabels)); - scheduleDraw(); + const contourLabelToggle = createLayerToggle({ + label: L("B04_Surface_Map_ContourLabel"), + on: CONTOUR_LABEL_DEFAULT_ON, + onChange: (on) => { + showContourLabels = on; + scheduleDraw(); + }, }); - gisButtons.append(contourLabelButton); + gisButtons.append(contourLabelToggle.root); // 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다. // 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면 @@ -505,8 +495,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { // 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다. detailBasins.setRoute(planned.points, nextMeta); // 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다. - routeButton.disabled = routeLayer === null; - routeButton.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : ""; + routeToggle.root.disabled = routeLayer === null; + routeToggle.root.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : ""; let featureCount = 0; loadedLayers.forEach(([layer, data]) => { if (!data) return; diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 6b97dd5e0..ccb57834c 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -2,12 +2,17 @@ import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, + createSelectField, createTag, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createKv, type KvRow } from "@ui/ui_template_kv"; +import { createSegment, type SegmentHandle } from "@ui/ui_template_segment"; +import { createToggle } from "@ui/ui_template_toggle"; +import { createToolbar } from "@ui/ui_template_toolbar"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache"; @@ -35,6 +40,7 @@ import { type SurfacePointCloudSampleResponse, type SurfaceStatusResponse, } from "./B04_PreProcess_Api_Fetch"; +import { buildSurfaceGroup } from "./B04_PreProcess_UI_Group"; import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer"; import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer"; import { createSurfacePointCloudViewer } from "./B04_PreProcess_UI_Viewer"; @@ -56,15 +62,8 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -function buildInfoLine(label: string, value: unknown): HTMLElement { - const row = document.createElement("div"); - row.className = "b04-surface__line"; - const key = document.createElement("span"); - key.textContent = label; - const val = document.createElement("strong"); - val.textContent = value == null || value === "" ? "-" : String(value); - row.append(key, val); - return row; +function infoRow(label: string, value: unknown): KvRow { + return { label, value: value == null || value === "" ? "-" : String(value) }; } /** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */ @@ -73,35 +72,19 @@ function buildSelectField( values: readonly string[], defaultValue: string, ): { root: HTMLElement; select: HTMLSelectElement } { - const root = document.createElement("label"); - root.className = "b04-surface__field"; - const caption = document.createElement("span"); - caption.textContent = label; - const select = document.createElement("select"); - select.className = "b04-surface__select"; - values.forEach((value) => { - const option = document.createElement("option"); - option.value = value; - option.textContent = value.replaceAll("_", " ").toUpperCase(); - select.append(option); + const { root, select } = createSelectField({ + label, + options: values.map((value) => ({ + value, + text: value.replaceAll("_", " ").toUpperCase(), + })), + value: defaultValue, + size: 36, }); - select.value = defaultValue; - root.append(caption, select); + root.classList.add("b04-surface__field"); return { root, select }; } -/** 제목이 달린 사이드 패널 컨테이너. */ -function buildGroup(title: string, ...children: HTMLElement[]): HTMLElement { - const root = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). - root.className = "b04-surface__group ui-sidebar-section"; - const heading = document.createElement("h3"); - heading.className = "b04-surface__panel-title"; - heading.textContent = title; - root.append(heading, ...children); - return root; -} - function getModelFilter(model: SurfaceModelSummary): string { const configured = model.generation_params?.source_filter; if (typeof configured === "string") return configured.toLowerCase(); @@ -131,8 +114,12 @@ export async function renderB04Surface(root: HTMLElement): Promise { let models: SurfaceModelSummary[] = []; let pointCloud: SurfacePointCloudSampleResponse | null = null; - const inputSelect = document.createElement("select"); - inputSelect.className = "b04-surface__select"; + const inputSelectField = createSelectField({ + options: [], + size: 36, + ariaLabel: L("B04_Surface_InputFiles"), + }); + const inputSelect = inputSelectField.select; const inputInfo = document.createElement("div"); inputInfo.className = "b04-surface__input-info"; const statusBox = document.createElement("div"); @@ -157,23 +144,15 @@ export async function renderB04Surface(root: HTMLElement): Promise { // 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천, // 2026-08-30). 모델 목록에 sheet/dtm이 있을 때만 별도 컨테이너로 보여준다. const sheetViewer = createSurfaceTerrainViewer(); - const sheetSection = document.createElement("section"); - sheetSection.className = "b04-surface__sheet-section ui-sidebar-section"; - const sheetTitle = document.createElement("h3"); - sheetTitle.className = "b04-surface__panel-title"; - sheetTitle.textContent = L("B04_Surface_SheetSurface"); // 보간 방식 전환 줄 — 어느 방식이 이 지형에 맞는지 눈으로 비교해 정한다 - // (2026-08-30 사용자 지시). 버튼 목록은 실제 생성된 모델에서 만든다. - const sheetToolbar = document.createElement("div"); - sheetToolbar.className = "b04-surface__sheet-toolbar"; - const sheetMethodButtons = new Map(); + // (2026-08-30 사용자 지시). 방식 목록은 실제 생성된 모델에서 만든다. + let sheetToolbar = createToolbar({ items: [], size: 28 }); + let sheetSegment: SegmentHandle | null = null; let sheetMethod = ""; function selectSheetMethod(method: string): void { sheetMethod = method; - for (const [key, button] of sheetMethodButtons) { - button.classList.toggle("is-active", key === method); - } + sheetSegment?.set(method); const projectId = getProjectId(); if (!projectId) return; sheetViewer.setSelection(`sheet_${method}`, "dtm"); @@ -195,7 +174,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { const message = L("B04_Surface_Build_Confirm") .replace("{filter}", label) .replace("{method}", "도엽등고"); - if (!(await showConfirmDialog(message, L("B04_Surface_Build_Action")))) return; + if (!(await showConfirmDialog(message, L("B04_Surface_Build_Action")))) { + sheetSegment?.set(sheetMethod); // 취소 — 고름 표시를 원래 방식으로 되돌린다 + return; + } showLoadingOverlay(); showToast(L("B04_Surface_Build_Running"), "info"); @@ -204,7 +186,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { models = (await listSurfaceModels(projectId)).models; // 만들었으니 「미생성」 표시를 걷는다 — 버튼 줄을 통째로 다시 세우지는 않는다 // (현재 선택·스무딩 상태가 날아간다). - const button = sheetMethodButtons.get(method); + const button = sheetSegment?.root.querySelector(`[data-value="${method}"]`); if (button) { button.classList.remove("is-unbuilt"); button.removeAttribute("title"); @@ -217,33 +199,34 @@ export async function renderB04Surface(root: HTMLElement): Promise { } catch (error) { const detail = error instanceof Error ? error.message : ""; showToast(`${L("B04_Surface_Build_Failed")} ${detail}`, "error"); + sheetSegment?.set(sheetMethod); } finally { hideLoadingOverlay(); } } // 라이다 지표면 겹쳐 보기 — 확정 필터의 DTM을 반투명으로 얹는다. - const lidarLabel = document.createElement("label"); - lidarLabel.className = "toggle-label toggle-button b04-surface__sheet-lidar"; - const lidarCheck = document.createElement("input"); - lidarCheck.type = "checkbox"; - lidarLabel.append(lidarCheck, document.createTextNode(` ${L("B04_Surface_SheetLidar")}`)); - lidarCheck.addEventListener("change", () => { - void sheetViewer - .showOverlay( - lidarCheck.checked ? filterGroup.select.value : "", - "dtm", - terrainViewer.isSmoothingEnabled(), - ) - .then((loaded) => { - if (lidarCheck.checked && !loaded) { - showToast(L("B04_Surface_SheetLidar_Missing"), "warning"); - lidarCheck.checked = false; - } - }); + const lidarToggle = createToggle({ + kind: "chip", + label: L("B04_Surface_SheetLidar"), + size: 28, + onChange: (on) => { + void sheetViewer + .showOverlay(on ? filterGroup.select.value : "", "dtm", terrainViewer.isSmoothingEnabled()) + .then((loaded) => { + if (on && !loaded) { + showToast(L("B04_Surface_SheetLidar_Missing"), "warning"); + lidarToggle.set(false); + } + }); + }, }); - sheetSection.append(sheetTitle, sheetToolbar, sheetViewer.root); + const sheetSection = buildSurfaceGroup( + L("B04_Surface_SheetSurface"), + [sheetToolbar, sheetViewer.root], + "b04-surface__sheet-section", + ); sheetSection.hidden = true; let syncingCamera = false; @@ -271,32 +254,27 @@ export async function renderB04Surface(root: HTMLElement): Promise { }); const resetButton = createButton({ label: L("Common_Btn_Reset"), - variant: "ghost", + // 가시성 톤 다운(2026-08-18 사용자 지시) — 붉은 테두리·글자만, 채움 없음(위험 고스트). + variant: "danger-ghost", onClick: () => void onB04_Surface_Reset_Click(), }); - // 가시성 톤 다운(2026-08-18 사용자 지시) — 붉은 테두리·글자만, 채움 없음(ghost 배경). - resetButton.classList.add("b04-surface__reset"); - const inputGroup = document.createElement("section"); - inputGroup.className = "b04-surface__group ui-sidebar-section"; - const inputTitle = document.createElement("h3"); - inputTitle.className = "b04-surface__panel-title"; - inputTitle.textContent = L("B04_Surface_InputFiles"); - inputGroup.append(inputTitle, inputSelect, inputInfo); + const inputGroup = buildSurfaceGroup(L("B04_Surface_InputFiles"), [ + inputSelectField.root, + inputInfo, + ]); // 지면 필터 · 서피스 · 스무딩은 함께 모델 하나를 정하는 값이라 한 컨테이너에 모은다. - const analysisGroup = buildGroup( - L("B04_Surface_Group_Analysis"), + const analysisGroup = buildSurfaceGroup(L("B04_Surface_Group_Analysis"), [ filterGroup.root, methodGroup.root, terrainViewer.smoothingField, - ); + ]); // 표시 옵션은 3D 모델 토글과 포인트 슬라이더를 한 칸에 둔다(슬라이더가 맨 아래). - const displayGroup = buildGroup( - L("B04_Surface_Group_Display"), + const displayGroup = buildSurfaceGroup(L("B04_Surface_Group_Display"), [ terrainViewer.optionsContent, viewer.optionsContent, - ); + ]); const panel = document.createElement("div"); panel.className = "b04-surface__form"; @@ -383,19 +361,18 @@ export async function renderB04Surface(root: HTMLElement): Promise { ? `${bounds.z_min.toFixed(2)} m ~ ${bounds.z_max.toFixed(2)} m` : null; inputInfo.append( - buildInfoLine( - "좌표계", - selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null, - ), - buildInfoLine( - "크기", - selectedInputFile.file_size_mb == null - ? null - : `${selectedInputFile.file_size_mb.toFixed(2)} MB`, - ), - buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()), - buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()), - buildInfoLine("높이 범위", heightRange), + createKv([ + infoRow("좌표계", selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null), + infoRow( + "크기", + selectedInputFile.file_size_mb == null + ? null + : `${selectedInputFile.file_size_mb.toFixed(2)} MB`, + ), + infoRow("포인트 수", pointCloud?.point_count.toLocaleString()), + infoRow("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()), + infoRow("높이 범위", heightRange), + ]), ); } @@ -573,25 +550,32 @@ export async function renderB04Surface(root: HTMLElement): Promise { ); sheetSection.hidden = sheetMethods.length === 0; if (sheetMethods.length) { - sheetToolbar.replaceChildren(); - sheetMethodButtons.clear(); + const segment = createSegment({ + options: sheetMethods.map((method) => ({ + value: method.key, + label: method.label, + // 아직 안 만든 방식은 눌러야 만들어진다 — 눌러 보기 전에 알 수 있게 표시한다. + title: method.built ? undefined : L("B04_Surface_Build_Action"), + })), + value: "", + size: 28, + ariaLabel: L("B04_Surface_SheetSurface"), + onChange: (key) => + void onSheetMethodClick(key, sheetMethods.find((m) => m.key === key)?.label ?? key), + }); for (const method of sheetMethods) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b04-surface__sheet-method"; - button.textContent = method.label; - // 아직 안 만든 방식은 눌러야 만들어진다 — 눌러 보기 전에 알 수 있게 표시한다. - button.classList.toggle("is-unbuilt", !method.built); - if (!method.built) button.title = L("B04_Surface_Build_Action"); - button.addEventListener("click", () => { - void onSheetMethodClick(method.key, method.label); - }); - sheetToolbar.append(button); - sheetMethodButtons.set(method.key, button); + segment.root + .querySelector(`[data-value="${method.key}"]`) + ?.classList.toggle("is-unbuilt", !method.built); } + sheetSegment = segment; // 스무딩 드롭다운과 라이다 토글은 오른쪽 끝에 함께 둔다. - sheetViewer.smoothingField.classList.add("b04-surface__sheet-smoothing"); - sheetToolbar.append(sheetViewer.smoothingField, lidarLabel); + const nextToolbar = createToolbar({ + items: [segment.root, "spacer", sheetViewer.smoothingField, lidarToggle.root], + size: 28, + }); + sheetToolbar.replaceWith(nextToolbar); + sheetToolbar = nextToolbar; sheetViewer.setSmoothing(true); // 처음 열 때 안 만든 방식을 고르면 묻지도 않았는데 모달이 뜬다 — 만들어 둔 것을 고른다. const firstBuilt = sheetMethods.find((method) => method.built); diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 566e0999e..327fef38c 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -15,104 +15,25 @@ padding: var(--spacing-16); } -/* 초기화 버튼 톤 다운 — 붉은 테두리·글자만 남기고 채움은 두지 않는다(ghost 배경 - 그대로 = 임시저장류 버튼과 동일). 꽉 찬 붉은 채움이 과하게 눈에 띈다는 보고 - (2026-08-18 사용자 지시). */ -.b04-surface__reset.ui-btn { - border: 1px solid var(--color-danger); - color: var(--color-danger); - background: none; -} - -.b04-surface__reset.ui-btn:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-danger) 10%, transparent); -} - +/* 묶음 상자 = 카드 부품 · 안 배치(세로 쌓임)만 여기서 정함 */ .b04-surface__group { display: flex; flex-direction: column; gap: var(--spacing-8); margin: 0; - padding: var(--spacing-16); - /* 테두리는 공통 ui-sidebar-section(ui_template_overlay.css)이 전담한다 — 여기서 - border 전체 선언을 다시 쓰면 로드 순서상 공통 색(진한 혼합)을 덮어 흐려진다 - (2026-08-06 사용자 보고: B04~B06만 시인성 개선이 안 보이던 원인). */ - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); -} - -.b04-surface__group-legend { - padding: 0 var(--spacing-8); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); -} - -.b04-surface__check { - display: flex; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-body-sm); - color: var(--color-text-body); - cursor: pointer; -} - -.b04-surface__check input { - accent-color: var(--color-primary); -} - -.b04-surface__panel-title { - font-size: var(--text-body-sm); - color: var(--color-text); -} - -/* 라벨 한 줄 + 드롭다운 — 지표면 분석 컨테이너의 항목 한 칸. */ -.b04-surface__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - font-size: var(--text-caption); - color: var(--color-text-secondary); } +/* 잠긴 칸(스무딩 미지원 방식) — 고르기 부품 곁에서 이름까지 흐리게 */ .b04-surface__field.is-disabled { - color: var(--color-text-muted); opacity: 0.55; } -.b04-surface__select { - width: 100%; - min-height: 38px; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-canvas); - color: var(--color-text-body); - padding: var(--spacing-8); -} - -.b04-surface__input-info, -.b04-surface__model-meta, -.b04-surface__stats { +.b04-surface__input-info { display: flex; flex-direction: column; gap: var(--spacing-8); } -.b04-surface__line { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--spacing-16); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b04-surface__line strong { - color: var(--color-text-body); - text-align: right; - word-break: break-word; -} - /* --- 우측 결과 영역 --- */ .b04-surface__workspace { display: flex; @@ -141,20 +62,6 @@ min-width: 0; } -.b04-surface__topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-16); - padding: var(--spacing-16) var(--spacing-24); - border-bottom: 1px solid var(--color-border); -} - -.b04-surface__topbar h3, -.b04-surface__section h3 { - font-size: var(--text-body); -} - .b04-surface__status { display: flex; align-items: center; @@ -177,141 +84,18 @@ border-radius: var(--radius-cards); } -.b04-surface__bottom { - display: grid; - grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); - gap: var(--spacing-16); - padding: var(--spacing-16) var(--spacing-24) var(--spacing-24); - border-top: 1px solid var(--color-border); -} - -.b04-surface__section { - display: flex; - flex-direction: column; - gap: var(--spacing-16); +/* 3D 뷰어 틀 = 카드 부품 · 여백 없이 뷰어가 가득 참 */ +.point-viewer, +.terrain-model-group { min-width: 0; -} - -.b04-surface__result { - display: flex; - flex-direction: column; - gap: var(--spacing-16); - padding: var(--spacing-24); -} - -.b04-surface__result-head { - display: flex; - align-items: center; - justify-content: space-between; -} - -.b04-surface__result-head h3 { - font-size: var(--text-subheading); - color: var(--color-text); -} - -.b04-surface__models { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: var(--spacing-16); -} - -.b04-surface__empty { - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.b04-surface__model-card { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); - box-shadow: var(--shadow-sm); -} - -.b04-surface__stat-card { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); -} - -.b04-surface__model-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8); -} - -.b04-surface__model-head strong { - font-family: var(--font-display); - color: var(--color-plum-velvet); - text-transform: uppercase; -} - -.b04-surface__model-meta { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - font-size: var(--text-caption); - color: var(--color-text-secondary); - word-break: break-all; -} - -@media (max-width: 980px) { - .b04-surface__bottom { - grid-template-columns: 1fr; - } + padding: 0; + overflow: hidden; + background: var(--color-surface); } .point-viewer { display: flex; flex-direction: column; - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - overflow: hidden; - background: var(--color-surface); -} - -.terrain-model-group { - min-width: 0; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); -} - -.point-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-12); - padding: var(--spacing-12) var(--spacing-16); - border-bottom: 1px solid var(--color-border); - background: var(--color-surface-raised); -} - -.point-toolbar--stacked { - align-items: flex-start; - flex-wrap: wrap; -} - -.point-toolbar div { - display: flex; - align-items: baseline; - gap: var(--spacing-8); - flex-wrap: wrap; -} - -.point-toolbar span { - color: var(--color-text-secondary); - font-size: var(--text-caption); } .viewer-controls { @@ -321,85 +105,23 @@ width: 100%; } -.viewer-controls button { +.viewer-controls > .ui-btn { width: 100%; min-width: 0; - min-height: 32px; - padding: 0 var(--spacing-4); - font-size: var(--text-caption); - border: 1px solid var(--color-border); - border-radius: var(--radius-buttons); - background: var(--color-canvas); - color: var(--color-text); - cursor: pointer; } -.viewer-controls button:hover { - border-color: var(--color-accent); - background: var(--color-mist-violet); -} - -.toggle-label { - display: inline-flex; - align-items: center; - gap: var(--spacing-4); - min-height: 32px; - font-size: var(--text-caption); - color: var(--color-text-secondary); - cursor: pointer; -} - -.viewer-options { - display: flex; - align-items: center; - gap: var(--spacing-16); - flex-wrap: wrap; - padding: var(--spacing-12) var(--spacing-16); - border-bottom: 1px solid var(--color-border); - background: var(--color-surface-raised); -} - -.viewer-options label { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-caption); - color: var(--color-text-secondary); -} - -.viewer-options label.is-disabled { - color: var(--color-text-muted); - cursor: not-allowed; - opacity: 0.55; -} - -/* 점 크기·밀도 — 컨테이너 맨 아래에 한 줄씩. 게이지 **앞**에 현재값을 적는다 - (2026-08-01 사용자 지시). 값 칸 폭을 고정해 끌 때 게이지가 흔들리지 않게 한다. */ -.b04-surface__group .viewer-options { - flex-direction: column; - align-items: stretch; - gap: var(--spacing-8); - padding: 0; - border-bottom: 0; - background: transparent; -} - -/* `.viewer-options label`(0,1,1)이 display:inline-flex를 걸고 있어 클래스 하나(0,1,0)로는 - 못 이긴다. 같은 특이도 이상으로 올려야 3열 배치가 실제로 적용된다. */ -.viewer-options .viewer-option-row { +/* 점 크기 · 밀도 슬라이더 묶음(슬라이더 부품) */ +.b04-surface__options { display: grid; - grid-template-columns: auto 44px 1fr; - align-items: center; + grid-template-columns: auto minmax(40px, 1fr) auto; gap: var(--spacing-8); } -.viewer-option-name { - white-space: nowrap; -} - -.viewer-options .viewer-option-row input[type="range"] { - width: 100%; - min-width: 0; +/* 두 줄의 이름 · 막대 · 값 칸을 맞춤(슬라이더 부품은 줄마다 제 폭) */ +.b04-surface__options > .ui-slider { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; } /* 3D 모델 표시 토글 + 상태 줄 묶음. */ @@ -419,97 +141,21 @@ width: 100%; } -.model-display-options .toggle-button { - position: relative; +.model-display-options > .ui-toggle { justify-content: center; width: 100%; min-width: 0; - min-height: 34px; - padding: 0 var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-buttons); - background: var(--color-canvas); - color: var(--color-text-secondary); - cursor: pointer; - user-select: none; -} - -.model-display-options .toggle-button input { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; -} - -.model-display-options .toggle-button:has(input:checked) { - border-color: var(--color-accent); - background: var(--color-mist-violet); - color: var(--color-accent); - font-weight: var(--font-weight-semibold); -} - -.model-display-options .toggle-button:has(input:focus-visible) { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - -.model-display-options .toggle-button.is-disabled { - cursor: not-allowed; } .contour-interval-form { display: grid; grid-column: 1 / -1; - grid-template-columns: auto minmax(64px, 1fr) auto auto; + grid-template-columns: auto minmax(64px, 1fr) auto; align-items: center; gap: var(--spacing-8); width: 100%; } -.contour-interval-input, -.contour-interval-submit { - min-height: 34px; - border: 1px solid var(--color-border); - border-radius: var(--radius-buttons); - background: var(--color-canvas); - color: var(--color-text); -} - -.contour-interval-input { - width: 100%; - min-width: 0; - padding: 0 var(--spacing-8); -} - -.contour-interval-submit { - padding: 0 var(--spacing-12); - cursor: pointer; -} - -.contour-interval-submit:disabled { - cursor: wait; - opacity: 0.6; -} - -/* 게이지 앞 현재값 — 정의는 여기 하나뿐이다(위쪽에 있던 중복 정의를 합쳤다). */ -.viewer-option-val { - display: inline-block; - min-width: 38px; - text-align: right; - color: var(--color-text); - font-variant-numeric: tabular-nums; - font-weight: var(--font-weight-semibold); -} - -.subtle-notice { - color: var(--color-text-muted); - background: var(--color-surface-raised); - border-bottom: 1px solid var(--color-border); - padding: var(--spacing-8) var(--spacing-16); - font-size: var(--text-caption); -} - .three-viewer { position: relative; width: 100%; @@ -517,16 +163,12 @@ background: var(--color-canvas); } +/* 뷰어 위 상태 글(배지 부품) — 자리만 */ .b04-surface__status-info { position: absolute; top: var(--spacing-12); left: var(--spacing-12); z-index: 2; - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-inputs); - background: var(--color-surface-raised); - color: var(--color-text-secondary); - font-size: var(--text-caption); pointer-events: none; } diff --git a/B04_PreProcess/B04_PreProcess_UI_Style_Map.css b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css index 72c8079fd..802a39d49 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style_Map.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css @@ -13,14 +13,9 @@ flex-direction: column; gap: var(--spacing-12); margin: var(--spacing-24); - padding: var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); } .b04-map__header, -.b04-map__controls, .b04-map__control-group, .b04-map__layer-buttons { display: flex; @@ -39,7 +34,6 @@ .b04-map__controls { gap: var(--spacing-12); - flex-wrap: wrap; } .b04-map__control-group { @@ -53,40 +47,6 @@ flex-wrap: wrap; } -.b04-map__controls button { - min-height: 34px; - padding: 0 var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-canvas); - color: var(--color-text-body); -} - -.b04-map__controls button { - cursor: pointer; -} - -.b04-map__controls .b04-map__layer-button { - padding: var(--spacing-8); - opacity: 0.55; -} - -.b04-map__layer-button.is-active { - opacity: 1; - box-shadow: inset 0 0 0 1px currentColor; -} - -.b04-map__layer-button--gis { - border-color: var(--color-border); - color: var(--color-text-secondary); -} - -.b04-map__layer-button--gis.is-active { - border-color: var(--b04-layer-color); - color: var(--b04-layer-color); - box-shadow: inset 0 0 0 1px var(--b04-layer-color); -} - /* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치 · 모양은 공용 `.ui-ctx-menu`. */ .b04-map__context-menu { position: absolute; @@ -219,7 +179,6 @@ /* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */ .b04-surface__sheet-section { margin: 0 var(--spacing-24) var(--spacing-16); - padding: var(--spacing-16); box-sizing: border-box; } @@ -227,59 +186,19 @@ margin-top: var(--spacing-12); } -/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ -.b04-surface__sheet-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); +/* 도엽 서피스 줄 — 방식 고르기(분할 고르기) · 도구줄 부품 · 스무딩 고르기는 이름 옆에 한 줄로 */ +.b04-surface__sheet-section .ui-toolbar { margin: var(--spacing-12) 0; } -.b04-surface__sheet-method { - padding: 4px 10px; - font-size: var(--text-caption); - color: var(--color-text-body); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - cursor: pointer; -} - -.b04-surface__sheet-method:hover { - border-color: var(--color-primary); -} - -.b04-surface__sheet-method.is-active { - color: var(--color-on-primary, #fff); - background: var(--color-primary); - border-color: var(--color-primary); -} - -/* 아직 만들지 않은 방식 — 누르면 계산이 시작된다는 것을 눌러 보기 전에 알린다. */ -.b04-surface__sheet-method.is-unbuilt:not(.is-active) { - color: var(--color-text-muted, var(--color-text-body)); - border-style: dashed; -} - -.b04-surface__sheet-lidar { - margin-left: auto; -} - -/* 도엽 서피스 스무딩 드롭다운 — 방식 버튼 줄 오른쪽 끝 */ -.b04-surface__sheet-smoothing { - margin-left: auto; +.b04-surface__sheet-section .ui-toolbar .ui-field { display: flex; align-items: center; gap: var(--spacing-8); - font-size: var(--text-caption); } -.b04-surface__sheet-smoothing .b04-surface__select { - width: auto; - min-width: 96px; -} - -.b04-surface__sheet-toolbar .b04-surface__sheet-lidar { - margin-left: 0; +/* 아직 만들지 않은 방식 — 누르면 계산이 시작된다는 것을 눌러 보기 전에 알린다. */ +.b04-surface__sheet-section .ui-segment__item.is-unbuilt:not([aria-checked="true"]) { + color: var(--color-text-muted); + font-style: italic; } diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts index 9df8648a3..01651d77f 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts @@ -9,19 +9,38 @@ * ========================================================================== */ import { createTerrainCompass } from "@ui/ui_template_compass"; +import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; +import { createToggle } from "@ui/ui_template_toggle"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/** 켜기 칩 부품에 옛 체크 칸 꼴(checked · change)을 입힘 — 뷰어 본체 배선은 그대로. */ +function chipCheck(label: string, checked: boolean) { + const toggle = createToggle({ kind: "chip", label, checked, size: 32 }); + return { + root: toggle.root, + get checked(): boolean { + return toggle.get(); + }, + set checked(on: boolean) { + toggle.set(on); + }, + // 칩의 자기 클릭 처리가 먼저 서므로 여기서는 이미 바뀐 값을 읽는다. + addEventListener: (_type: "change", listener: () => void) => + toggle.root.addEventListener("click", listener), + }; +} + /** 뷰어 본체가 이어 쓸 엘리먼트 묶음 — 이름은 분리 전 지역변수와 같다. */ export type TerrainViewerChrome = ReturnType; export function buildTerrainViewerChrome() { const root = document.createElement("div"); - root.className = "terrain-model-group"; + root.className = "ui-card terrain-model-group"; const statusSpan = document.createElement("span"); statusSpan.className = "terrain-status"; @@ -29,79 +48,57 @@ export function buildTerrainViewerChrome() { statusSpan.style.color = "var(--color-text-secondary)"; statusSpan.textContent = "모델 선택 대기 중..."; - const axesCheck = document.createElement("input"); - axesCheck.type = "checkbox"; - axesCheck.checked = false; + const axesCheck = chipCheck("축", false); const rightControls = document.createElement("div"); - rightControls.className = "viewer-options model-display-options"; + rightControls.className = "model-display-options"; // Surface Toggle - const surfLabel = document.createElement("label"); - surfLabel.className = "toggle-label toggle-button"; - const surfCheck = document.createElement("input"); - surfCheck.type = "checkbox"; - surfCheck.checked = true; - surfLabel.append(surfCheck, document.createTextNode(" 서피스")); + const surfCheck = chipCheck("서피스", true); // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. - const smoothLabel = document.createElement("label"); - smoothLabel.className = "b04-surface__field"; - const smoothCaption = document.createElement("span"); - smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); - const smoothSelect = document.createElement("select"); - smoothSelect.className = "b04-surface__select"; - ( - [ - ["on", "B04_Surface_Smoothing_On"], - ["off", "B04_Surface_Smoothing_Off"], - ] as const - ).forEach(([value, key]) => { - const option = document.createElement("option"); - option.value = value; - option.textContent = L(key); - smoothSelect.append(option); + const smoothField = createSelectField({ + label: L("B04_Surface_Field_Smoothing"), + options: [ + { value: "on", text: L("B04_Surface_Smoothing_On") }, + { value: "off", text: L("B04_Surface_Smoothing_Off") }, + ], + value: "on", + size: 36, }); - smoothSelect.value = "on"; - smoothLabel.append(smoothCaption, smoothSelect); + const smoothLabel = smoothField.root; + smoothLabel.classList.add("b04-surface__field"); + const smoothSelect = smoothField.select; // Contour Toggle - const contourLabel = document.createElement("label"); - contourLabel.className = "toggle-label toggle-button"; - const contourCheck = document.createElement("input"); - contourCheck.type = "checkbox"; - contourCheck.checked = true; - contourLabel.append(contourCheck, document.createTextNode(" 등고선")); + const contourCheck = chipCheck("등고선", true); // Contour Interval input form const intervalForm = document.createElement("form"); intervalForm.className = "contour-interval-form"; - const intervalInput = document.createElement("input"); - intervalInput.type = "number"; - intervalInput.value = "1.0"; + const intervalField = createInputField({ + type: "number", + value: "1.0", + min: 0.5, + unit: "m", + size: 32, + }); + const intervalInput = intervalField.input; intervalInput.step = "0.5"; - intervalInput.min = "0.5"; - intervalInput.className = "contour-interval-input"; - const intervalSubmit = document.createElement("button"); - intervalSubmit.type = "submit"; - intervalSubmit.textContent = "적용"; - intervalSubmit.className = "contour-interval-submit"; + const intervalSubmit = createButton({ + label: "적용", + variant: "ghost", + size: 32, + type: "submit", + }); - intervalForm.append( - document.createTextNode("간격 "), - intervalInput, - document.createTextNode("m "), - intervalSubmit, - ); + intervalForm.append(document.createTextNode("간격 "), intervalField.root, intervalSubmit); - const axesLabel = document.createElement("label"); - axesLabel.className = "toggle-label toggle-button"; - axesLabel.append(axesCheck, document.createTextNode(" 축")); - rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); + rightControls.append(axesCheck.root, surfCheck.root, contourCheck.root, intervalForm); // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. const optionsContent = document.createElement("div"); @@ -182,18 +179,13 @@ export function buildTerrainViewerChrome() { root, statusSpan, axesCheck, - rightControls, - surfLabel, surfCheck, smoothLabel, - smoothCaption, smoothSelect, - contourLabel, contourCheck, intervalForm, intervalInput, intervalSubmit, - axesLabel, optionsContent, viewerArea, canvas, diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts index 32c90eea2..3c422ff54 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts @@ -24,7 +24,7 @@ export interface ContourLoadContext { intervalInput: HTMLInputElement; camera: THREE.PerspectiveCamera; contourGroup: THREE.Group; - contourCheck: HTMLInputElement; + contourCheck: { checked: boolean }; /** 지금 보고 있는 프로젝트·모델 — 응답이 늦게 와도 최신 요청만 반영하려고 함수로 받는다. */ projectId: () => string; currentModelId: () => number | null; diff --git a/B04_PreProcess/B04_PreProcess_UI_Viewer.ts b/B04_PreProcess/B04_PreProcess_UI_Viewer.ts index b54ca3a88..c6fdfed8f 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Viewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Viewer.ts @@ -1,9 +1,13 @@ import { RENDER_OPTIONS } from "@config/config_frontend"; +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_elements"; +import { createSlider } from "@ui/ui_template_input"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; 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_PreProcess_Api_Fetch"; +import { buildSurfaceGroup } from "./B04_PreProcess_UI_Group"; import { bindCursorPivotControls, bindSurfaceViewerTheme, @@ -41,19 +45,16 @@ export interface SurfacePointCloudViewer { type CameraView = "iso" | "top" | "front" | "side"; function makeButton(label: string): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - return button; + return createButton({ label, variant: "ghost", size: 28 }); } export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { const root = document.createElement("div"); - root.className = "point-viewer"; + root.className = "ui-card point-viewer"; - const statusSpan = document.createElement("span"); - statusSpan.className = "b04-surface__status-info"; - statusSpan.textContent = "포인트 데이터 로딩 중..."; + // 뷰어 위 상태 글 — 배지 부품 · 자리(좌상단)만 B04 쪽 클래스가 정함 + const statusSpan = createBadge("포인트 데이터 로딩 중...", "neutral"); + statusSpan.classList.add("b04-surface__status-info"); const controls = document.createElement("div"); controls.className = "viewer-controls"; @@ -65,60 +66,31 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { ]; controls.append(...viewButtons.map(([, button]) => button)); - const controlsGroup = document.createElement("section"); - controlsGroup.className = "b04-surface__group"; - const controlsTitle = document.createElement("h3"); - controlsTitle.className = "b04-surface__panel-title"; - controlsTitle.textContent = L("B04_Surface_Group_ViewControls"); - controlsGroup.append(controlsTitle, controls); - - /** 슬라이더 한 줄 — `이름 [현재값] [게이지]`. 값이 게이지 앞에 온다(2026-08-01 사용자 지시). */ - function buildSlider( - label: string, - attributes: { min: string; max: string; step: string; value: string }, - ): { root: HTMLLabelElement; input: HTMLInputElement; value: HTMLSpanElement } { - const root = document.createElement("label"); - root.className = "viewer-option-row"; - const name = document.createElement("span"); - name.className = "viewer-option-name"; - name.textContent = label; - const value = document.createElement("span"); - value.className = "viewer-option-val"; - const input = document.createElement("input"); - input.type = "range"; - input.min = attributes.min; - input.max = attributes.max; - input.step = attributes.step; - input.value = attributes.value; - root.append(name, value, input); - return { root, input, value }; - } + const controlsGroup = buildSurfaceGroup(L("B04_Surface_Group_ViewControls"), [controls]); + /** 점 크기 · 밀도 — 슬라이더 부품(이름 · 막대 · 현재값). */ const options = document.createElement("div"); - options.className = "viewer-options"; - const size = buildSlider(L("B04_Surface_Opt_PointSize"), { - min: "0.1", - max: "2.5", - step: "0.01", - value: "0.42", + options.className = "b04-surface__options"; + const size = createSlider({ + label: L("B04_Surface_Opt_PointSize"), + min: 0.1, + max: 2.5, + step: 0.01, + value: 0.42, + format: (value) => value.toFixed(2), }); - const density = buildSlider(L("B04_Surface_Opt_Density"), { - min: "1", - max: "10", - step: "1", - value: "10", + const density = createSlider({ + label: L("B04_Surface_Opt_Density"), + min: 1, + max: 10, + step: 1, + value: 10, + format: (value) => `${value * 10}%`, }); const sizeInput = size.input; const densityInput = density.input; options.append(size.root, density.root); - /** 게이지 앞 숫자를 현재값으로 맞춘다. */ - function syncOptionValues(): void { - size.value.textContent = Number(sizeInput.value).toFixed(2); - density.value.textContent = `${Number(densityInput.value) * 10}%`; - } - syncOptionValues(); - const viewerArea = document.createElement("div"); viewerArea.className = "three-viewer"; const canvas = document.createElement("canvas"); @@ -329,12 +301,10 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { button.addEventListener("click", () => setCameraView(view)); }); sizeInput.addEventListener("input", () => { - syncOptionValues(); if (pointsObject) (pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value); }); densityInput.addEventListener("input", () => { - syncOptionValues(); if (currentData) renderPointCloud(currentData); }); orbit.addEventListener("change", emitCameraState); @@ -366,9 +336,8 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { }, resetOptions() { axes.visible = false; - sizeInput.value = "0.42"; - densityInput.value = "10"; - syncOptionValues(); + size.set(0.42); + density.set(10); if (currentData) renderPointCloud(currentData); setCameraView("iso"); }, diff --git a/B04_PreProcess/B04_PreProcess_UI_Watershed.ts b/B04_PreProcess/B04_PreProcess_UI_Watershed.ts index 3556fa8cd..991b396fc 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Watershed.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Watershed.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; +import { createLayerToggle, setLayerLabel } from "./B04_PreProcess_UI_LayerToggle"; import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_PreProcess_Api_Fetch"; import { drawFlowArrows } from "./B04_PreProcess_UI_FlowArrows"; import type { Normalizer, ViewState } from "./B04_PreProcess_UI_MapRender"; @@ -127,13 +128,14 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { busyElement.hidden = text === ""; } - const button = document.createElement("button"); - button.type = "button"; - button.className = "b04-map__layer-button b04-map__layer-button--gis"; - button.textContent = L("B04_Surface_Watershed_Btn"); - button.style.setProperty("--b04-layer-color", themeColor("--color-danger", "#dc2626")); - button.setAttribute("aria-pressed", "false"); - button.title = L("B04_Surface_Watershed_Btn_Tip"); + // 재산정 단추 — 층 칩 꼴 · 눌러도 켜짐은 분석 결과가 정함(loadAnalysis 가 맞춤) + const toggle = createLayerToggle({ + label: L("B04_Surface_Watershed_Btn"), + color: themeColor("--color-danger", "#dc2626"), + title: L("B04_Surface_Watershed_Btn_Tip"), + onChange: () => void loadAnalysis(true), + }); + const button = toggle.root; // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. // 1차 유역·유역 방향은 격자가 지도를 덮어 판독을 방해하므로 기본 꺼짐(2026-08-01 사용자 지시). @@ -143,23 +145,18 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { flow: false, arrows: true, }; - const partButtons = PARTS.map((part) => { - const element = document.createElement("button"); - element.type = "button"; - const initialActive = shownParts[part.key]; - element.className = - "b04-map__layer-button b04-map__layer-button--gis" + (initialActive ? " is-active" : ""); - element.textContent = L(part.labelKey); - element.style.setProperty("--b04-layer-color", themeColor(part.token[0], part.token[1])); - element.setAttribute("aria-pressed", String(initialActive)); - element.addEventListener("click", () => { - shownParts[part.key] = !shownParts[part.key]; - element.classList.toggle("is-active", shownParts[part.key]); - element.setAttribute("aria-pressed", String(shownParts[part.key])); - onChange(); - }); - return element; - }); + const partButtons = PARTS.map( + (part) => + createLayerToggle({ + label: L(part.labelKey), + color: themeColor(part.token[0], part.token[1]), + on: shownParts[part.key], + onChange: (on) => { + shownParts[part.key] = on; + onChange(); + }, + }).root, + ); let projectId: string | null = null; @@ -495,8 +492,9 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { if (!projectId || busy) return; busy = true; button.disabled = true; - button.textContent = L( - refresh ? "B04_Surface_Watershed_Btn_Analyzing" : "B04_Surface_Watershed_Btn_Loading", + setLayerLabel( + toggle, + L(refresh ? "B04_Surface_Watershed_Btn_Analyzing" : "B04_Surface_Watershed_Btn_Loading"), ); // 진행 문구는 우상단 전용 줄에 띄운다 — 좌상단의 직전 결과를 지우지 않는다. sayBusy( @@ -508,8 +506,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { try { analysis = await fetchWatershedAnalysis(projectId, refresh); shown = true; - button.classList.add("is-active"); - button.setAttribute("aria-pressed", "true"); + toggle.set(true); const seconds = ((performance.now() - started) / 1000).toFixed(1); const origin = analysis.from_cache ? L("B04_Surface_Watershed_Origin_Cached") @@ -518,8 +515,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { } catch (error) { analysis = null; shown = false; - button.classList.remove("is-active"); - button.setAttribute("aria-pressed", "false"); + toggle.set(false); const message = error instanceof Error ? error.message : L("B04_Surface_Watershed_LoadFailed"); // 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다. @@ -532,17 +528,14 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { busy = false; sayBusy(""); // 끝났으면 진행 문구를 거둔다 — 결과는 좌상단 줄에 남는다 button.disabled = false; - button.textContent = L("B04_Surface_Watershed_Btn"); + setLayerLabel(toggle, L("B04_Surface_Watershed_Btn")); onChange(); } } - // 재산정 버튼은 언제나 처음부터 다시 분석한다. + // 재산정 버튼은 언제나 처음부터 다시 분석한다(칩의 onChange = 위 toggle). // (저장분을 자동으로 띄우게 바꾼 뒤로 이 버튼이 표시 토글로 먼저 걸려, 눌러도 아무 일이 // 없는 것처럼 보였다 — 2026-08-01. 보이기/숨기기는 갈래별 버튼이 맡는다.) - button.addEventListener("click", () => { - void loadAnalysis(true); - }); return { button, @@ -557,8 +550,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { flowCache = null; shown = false; say(""); - button.classList.remove("is-active"); - button.setAttribute("aria-pressed", "false"); + toggle.set(false); }, setProject(next: string) { projectId = next; diff --git a/resources/tester/test_553_1_pages.py b/resources/tester/test_553_1_pages.py index 8d28ada8d..fd77426f3 100644 --- a/resources/tester/test_553_1_pages.py +++ b/resources/tester/test_553_1_pages.py @@ -57,3 +57,52 @@ def test_b02_no_dead_select_and_no_raw_widgets(): assert "b02-proj__select" not in css # 안 쓰던 고르기 화살표 CSS — 고르기 부품이 냄 ts = "".join(_src("B02_ProjRegister", ".ts").values()) assert "