refactor(B04): 전처리 화면 자체 변종을 부품 상자 부품으로 — 켜기 칩 · 분할 고르기 · 고르기 · 슬라이더 · 값 목록 · 카드 · 도구줄 (PLAN 553-1)

- 지도 층 단추 열다섯 안팎 · 표시 토글 셋 · 라이다 겹침 = 켜기 칩(층 색 점) · 도엽 보간 방식 = 분할 고르기 · 도구줄 부품
- 고르기 38 → 고르기 부품 · 점 크기 · 밀도 = 슬라이더 · 입력 파일 정보 = 값 목록 · 초기화 = 위험 고스트 · 묶음 상자 = 카드 + 패널 머리
- 죽은 CSS 열여섯 걷음 · 날 색 → 공통 값 · 가이드 촬영 선택자 · 지킴 시험 test_553_1_pages 늘림

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:09:36 +09:00
co-authored by Claude Sonnet 5.5
parent a826804277
commit 58dafb1572
14 changed files with 441 additions and 858 deletions
+9 -9
View File
@@ -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"
}
]
},
+26 -26
View File
@@ -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,
@@ -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;
+20
View File
@@ -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;
}
@@ -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;
}
+41 -51
View File
@@ -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<BackgroundLayer, string> = {
@@ -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;
+97 -113
View File
@@ -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<void> {
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<void> {
// 도엽등고 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<string, HTMLButtonElement>();
// (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<void> {
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<void> {
models = (await listSurfaceModels(projectId)).models;
// 만들었으니 「미생성」 표시를 걷는다 — 버튼 줄을 통째로 다시 세우지는 않는다
// (현재 선택·스무딩 상태가 날아간다).
const button = sheetMethodButtons.get(method);
const button = sheetSegment?.root.querySelector<HTMLElement>(`[data-value="${method}"]`);
if (button) {
button.classList.remove("is-unbuilt");
button.removeAttribute("title");
@@ -217,33 +199,34 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
} 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<void> {
});
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<void> {
? `${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<void> {
);
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);
+21 -379
View File
@@ -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;
}
+7 -88
View File
@@ -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;
}
@@ -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<typeof buildTerrainViewerChrome>;
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,
@@ -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;
+28 -59
View File
@@ -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");
},
+29 -37
View File
@@ -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;
+49
View File
@@ -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 "<button" not in ts and 'createElement("button")' not in ts
def test_b04_guide_selectors_alive():
"""가이드 촬영 장면이 잡는 B04 자기 클래스는 소스에 살아 있어야 함."""
text = (ROOT / "A05_EduDetail/guide/B04/_scenes.json").read_text(encoding="utf-8")
for name in set(re.findall(r"\.(b04-[a-z0-9_-]+|contour-[a-z-]+|model-display-options)", text)):
assert _defined("B04_PreProcess", name), f"가이드 선택자 .{name} 가 B04 소스에 없음"
def test_b04_old_variants_gone():
"""날 단추 · 날 체크 · 손 고르기 · 손 슬라이더 · 손 값 줄이 부품으로 바뀜."""
ts = "".join(_src("B04_PreProcess", ".ts").values())
css = "".join(_src("B04_PreProcess", ".css").values())
for old in (
"b04-map__layer-button",
"toggle-button",
"toggle-label",
"b04-surface__select",
"viewer-option-row",
"b04-surface__sheet-method",
"b04-surface__line",
"contour-interval-submit",
"b04-surface__reset",
):
pattern = re.compile(
re.escape(old) + r"(?![a-z_-])"
) # 컨테이너 `...-buttons` 는 자리 이름이라 둠
assert not pattern.search(ts + css), f"옛 변종 {old} 가 남음"
for part in (
"createToggle",
"createSegment",
"createSelectField",
"createSlider",
"createKv",
"createToolbar",
"createPanelHead",
"createBadge",
):
assert part in ts, f"B04 가 부품 {part} 를 안 씀"
assert 'createElement("button")' not in ts # 단추는 부품이 만듦
assert 'createElement("select")' not in ts
assert 'variant: "danger-ghost"' in ts # 초기화 = 위험 고스트 부품
def test_b04_css_common_values_only():
for name, css in _src("B04_PreProcess", ".css").items():
assert not OLD_ALIAS.search(css), f"{name}: 옛 보라 별칭"
body = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
assert not RAW_COLOR.search(body), f"{name}: 날 색"