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:
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}: 날 색"
|
||||
|
||||
Reference in New Issue
Block a user