diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 247e300a..7ef13814 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -36,6 +36,24 @@ from B06_Section.B06_Section_Repository import ( list_recent_company_projects, update_cross_section_design, ) +from B06_Section.B06_Section_Router_Design import ( + PREVIEW_DESIGN_FIELDS as _PREVIEW_DESIGN_FIELDS, +) +from B06_Section.B06_Section_Router_Design import ( + attach_default_designs as _attach_default_designs, +) +from B06_Section.B06_Section_Router_Design import ( + default_section_modes as _default_section_modes, +) +from B06_Section.B06_Section_Router_Design import ( + pavement_suggestions as _pavement_suggestions, +) +from B06_Section.B06_Section_Router_Design import ( + read_cross_design_inputs as _read_cross_design_inputs, +) +from B06_Section.B06_Section_Router_Design import ( + recompute_designs_for_alignment as _recompute_designs_for_alignment, +) from B06_Section.B06_Section_Schema import ( CompanyStandardListResponse, CompanyStandardProject, @@ -451,169 +469,6 @@ async def regenerate_sections( ) -def _read_cross_design_inputs( - project_root: Path, longitudinal_file_path: str, chainage_m: float -) -> tuple[list[dict], float | None, bool]: - """측점 하나의 지반 샘플·계획고·포장 제안 여부를 파일에서 읽는다 (경로 이탈 검증 포함).""" - root = project_root.resolve() - longitudinal_path = (root / longitudinal_file_path).resolve() - if root not in longitudinal_path.parents: - raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.") - if not longitudinal_path.is_file(): - raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.") - longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) - design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m) - pavement_suggested = _pavement_suggestions(longitudinal).get(round(chainage_m, 3), False) - - cross_dir = longitudinal_path.parent.parent / "cross_sections" - cross_path = (cross_dir / cross_filename(chainage_m)).resolve() - if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file(): - raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.") - cross = json.loads(cross_path.read_text(encoding="utf-8")) - samples = cross.get("samples") if isinstance(cross, dict) else None - if not isinstance(samples, list): - raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") - return samples, design_elevation, pavement_suggested - - -def _pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]: - """측점별 포장 제안(B05 solve가 법정 경사 기준으로 판정) 매핑을 만든다.""" - stations = longitudinal.get("stations") - mapping: dict[float, bool] = {} - if isinstance(stations, list): - for station in stations: - suggested = station.get("pavement_suggested") - if isinstance(suggested, bool): - mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested - return mapping - - -def _default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: - """측점별 기본 단면유형 매핑: 상단측(uphill_side)이 절토측이 되는 편절편성. - - B05 solve가 자동 판정하고 사용자가 3D 램프로 바꾼 값(확정 시 정본 병합)을 그대로 - 소비한다. 판정 불가 측점은 매핑에서 빠지고 호출부가 좌절토로 폴백한다. - """ - stations = longitudinal.get("stations") - mapping: dict[float, str] = {} - if isinstance(stations, list): - for station in stations: - side = station.get("uphill_side") - if side in ("left", "right"): - mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut" - return mapping - - -def _attach_default_designs( - longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]] -) -> None: - """지정 설계가 없는 횡단에 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토) 프리뷰를 얹는다. - - detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이 - 전 측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다. - - 기본 지반을 토사가 아니라 **리핑암 + 지표 아래 0.5m 암반 경계**로 두는 이유(2026-08-03 - 사용자 확정): 산지 절토는 대부분 표토 아래에서 암이 나오므로, 전량 토사 가정은 물량이 - 낙관적으로 나온다. 지표 0.5m까지 토사·그 아래 리핑암인 2단 단면이 안전한 출발값이고, - 발파암이 있으면 사용자가 B06에서 측점별로 고친다. - """ - default_modes = _default_section_modes(longitudinal) - pavement = _pavement_suggestions(longitudinal) - for section in cross_sections: - if section.get("design"): - continue - try: - chainage_m = float(section.get("chainage_m", 0.0)) - suggested = pavement.get(round(chainage_m, 3), False) - design = compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal, chainage_m), - ground_type="ripping_rock", - section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), - paved=suggested, - rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, - ) - design["status"] = "provisional" - design["pavement_suggested"] = suggested - section["design"] = design - except (ValueError, KeyError): - continue - - -#: 계획선 프리뷰 응답에 실을 설계 필드 — 유토곡선 계산(면적·지반유형·자연방토)과 -#: 계획고 변경 감지에 쓰는 것만. 나머지(설계선 좌표 등)는 B06이 상세를 받을 때 온다. -_PREVIEW_DESIGN_FIELDS = ( - "ground_type", - "roadbed_width_m", - "cut_area_m2", - "cut_soil_area_m2", - "cut_rock_area_m2", - "cut_rock_kind", - "fill_area_m2", - "fill_ground_slope", - "design_elevation_m", -) - - -def _recompute_designs_for_alignment( - longitudinal: dict[str, Any], - cross_sections: list[dict[str, Any]], - stored_designs: list[dict[str, Any]], - standard: dict[str, Any] | None, - rock_boundary_offsets: dict[str, float] | None = None, -) -> None: - """새 계획선 기준으로 전 측점 횡단 설계를 다시 계산해 얹는다(메모리 프리뷰). - - 사용자가 이미 고른 지반유형·단면유형·측구·암 경계는 **그대로 유지**하고 계획고만 - 새 선형 값으로 바꿔 넣는다 — 계획선을 끌었다고 측점 선택이 초기화되면 안 된다. - 지정이 없는 측점은 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토)으로 계산한다. - `rock_boundary_offsets`(B06 세션값)가 오면 DB 저장분보다 우선한다. - """ - default_modes = _default_section_modes(longitudinal) - pavement = _pavement_suggestions(longitudinal) - stored_by_chainage = { - round(float(record["chainage_m"]), 3): (record.get("design") or {}) - for record in stored_designs - } - # 세션 오프셋 키는 프론트 표기(소수 자릿수)가 제각각일 수 있어 수치로 정규화한다. - session_offsets: dict[float, float] = {} - for raw_key, offset in (rock_boundary_offsets or {}).items(): - try: - session_offsets[round(float(raw_key), 3)] = float(offset) - except (TypeError, ValueError): - continue - for section in cross_sections: - chainage_m = float(section.get("chainage_m", 0.0)) - key = round(chainage_m, 3) - stored = stored_by_chainage.get(key) or {} - suggested = pavement.get(key, False) - rock_offset = session_offsets.get( - key, stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M) - ) - try: - design = compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal, chainage_m), - ground_type=str(stored.get("ground_type") or "ripping_rock"), - section_mode=str(stored.get("section_mode") or default_modes.get(key, "left_cut")), - ditch_side=stored.get("ditch_side"), - ditch_type=str(stored.get("ditch_type") or "standard"), - paved=bool(stored.get("paved", suggested)), - standard=standard, - rock_boundary_offset_m=rock_offset, - two_stage_slope=bool(stored.get("two_stage_slope", True)), - ditch_enabled=stored.get("ditch_enabled"), - ) - except (ValueError, KeyError): - continue - design["status"] = "provisional" - design["pavement_suggested"] = suggested - # 표시 설정(측점 개별 반폭)은 계산과 무관 — 재계산이 지우면 안 된다(2026-08-06). - if stored.get("display_half_width_m") is not None: - design["display_half_width_m"] = stored["display_half_width_m"] - section["design"] = design - - @router.post( "/{project_id}/sections/{route_id}/cross-design/preview", response_model=CrossDesignPreviewResponse, diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py new file mode 100644 index 00000000..39b9452a --- /dev/null +++ b/B06_Section/B06_Section_Router_Design.py @@ -0,0 +1,139 @@ +"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산.""" + +import json +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_Sections import cross_filename +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from common_util.common_util_route_profile import design_elevation_from_longitudinal +from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M + +PREVIEW_DESIGN_FIELDS = ( + "ground_type", + "roadbed_width_m", + "cut_area_m2", + "cut_soil_area_m2", + "cut_rock_area_m2", + "cut_rock_kind", + "fill_area_m2", + "fill_ground_slope", + "design_elevation_m", +) + + +def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]: + mapping: dict[float, bool] = {} + stations = longitudinal.get("stations") + for station in stations if isinstance(stations, list) else []: + suggested = station.get("pavement_suggested") + if isinstance(suggested, bool): + mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested + return mapping + + +def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: + mapping: dict[float, str] = {} + stations = longitudinal.get("stations") + for station in stations if isinstance(stations, list) else []: + side = station.get("uphill_side") + if side in ("left", "right"): + mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut" + return mapping + + +def read_cross_design_inputs( + project_root: Path, longitudinal_file_path: str, chainage_m: float +) -> tuple[list[dict], float | None, bool]: + root = project_root.resolve() + longitudinal_path = (root / longitudinal_file_path).resolve() + if root not in longitudinal_path.parents: + raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.") + if not longitudinal_path.is_file(): + raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.") + longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m) + suggested = pavement_suggestions(longitudinal).get(round(chainage_m, 3), False) + cross_dir = longitudinal_path.parent.parent / "cross_sections" + cross_path = (cross_dir / cross_filename(chainage_m)).resolve() + if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file(): + raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.") + cross = json.loads(cross_path.read_text(encoding="utf-8")) + samples = cross.get("samples") if isinstance(cross, dict) else None + if not isinstance(samples, list): + raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") + return samples, design_elevation, suggested + + +def attach_default_designs( + longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]] +) -> None: + modes = default_section_modes(longitudinal) + pavement = pavement_suggestions(longitudinal) + for section in cross_sections: + if section.get("design"): + continue + try: + chainage = float(section.get("chainage_m", 0.0)) + suggested = pavement.get(round(chainage, 3), False) + design = compute_cross_design( + section.get("samples", []), + design_elevation_from_longitudinal(longitudinal, chainage), + ground_type="ripping_rock", + section_mode=modes.get(round(chainage, 3), "left_cut"), + paved=suggested, + rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + ) + design.update(status="provisional", pavement_suggested=suggested) + section["design"] = design + except (ValueError, KeyError): + continue + + +def recompute_designs_for_alignment( + longitudinal: dict[str, Any], + cross_sections: list[dict[str, Any]], + stored_designs: list[dict[str, Any]], + standard: dict[str, Any] | None, + rock_boundary_offsets: dict[str, float] | None = None, +) -> None: + modes = default_section_modes(longitudinal) + pavement = pavement_suggestions(longitudinal) + stored_by_chainage = { + round(float(record["chainage_m"]), 3): (record.get("design") or {}) + for record in stored_designs + } + session_offsets: dict[float, float] = {} + for raw_key, offset in (rock_boundary_offsets or {}).items(): + try: + session_offsets[round(float(raw_key), 3)] = float(offset) + except (TypeError, ValueError): + continue + for section in cross_sections: + chainage = float(section.get("chainage_m", 0.0)) + key = round(chainage, 3) + stored = stored_by_chainage.get(key) or {} + suggested = pavement.get(key, False) + try: + design = compute_cross_design( + section.get("samples", []), + design_elevation_from_longitudinal(longitudinal, chainage), + ground_type=str(stored.get("ground_type") or "ripping_rock"), + section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")), + ditch_side=stored.get("ditch_side"), + ditch_type=str(stored.get("ditch_type") or "standard"), + paved=bool(stored.get("paved", suggested)), + standard=standard, + rock_boundary_offset_m=session_offsets.get( + key, + stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), + ), + two_stage_slope=bool(stored.get("two_stage_slope", True)), + ditch_enabled=stored.get("ditch_enabled"), + ) + except (ValueError, KeyError): + continue + design.update(status="provisional", pavement_suggested=suggested) + if stored.get("display_half_width_m") is not None: + design["display_half_width_m"] = stored["display_half_width_m"] + section["design"] = design diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 393d988a..33aed366 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -35,6 +35,7 @@ import type { BasinShape, CulvertLayout, EndFace, + InletStructureChoice, OffsetPoint, PipeEnd, WallAdjust, @@ -69,9 +70,6 @@ import { STRAY_LIMIT_M, } from "./B06_Section_UI_Cross_Culvert_Solve"; -/** 유입측 구조물 사용자 선택(2026-08-22) — auto = 규칙(사면≤3m → 집수정 ㄴ형). */ -export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U"; - /** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */ export function computeCulvertLayout( section: CrossSection, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 49f56902..cc4dae12 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -180,3 +180,5 @@ export interface CulvertLayout { */ inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean }; } +/** 유입측 구조물 사용자 선택 — auto = 규칙 기반 자동 선택. */ +export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U"; diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index b06cd9d2..fa40b843 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -24,6 +24,10 @@ import type { SectionMode, SectionSample, } from "./B06_Section_Api_Fetch"; +export { + appendPavementOverlay, + appendRockBoundaryOverlay, +} from "./B06_Section_UI_Cross_Design_Surface"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -638,53 +642,3 @@ export function appendCrossDesignOverlay( * 리핑암·발파암 지반에서만 호출한다. offsetM 음수 = 하향. * 무효 샘플 구간은 지반선과 동일하게 선을 끊어 그린다. */ -export function appendRockBoundaryOverlay( - svg: SVGElement, - groundSamples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>, - offsetM: number, - x: (offset: number) => number, - toDisplayY: (elevation: number) => number, -): void { - const segments: string[][] = []; - let current: string[] = []; - for (const sample of groundSamples) { - const elevation = sample.elevation_m; - if (sample.valid === false || elevation === null || !Number.isFinite(elevation ?? NaN)) { - if (current.length > 1) segments.push(current); - current = []; - continue; - } - current.push(`${x(sample.offset_m ?? 0)},${toDisplayY((elevation as number) + offsetM)}`); - } - if (current.length > 1) segments.push(current); - for (const points of segments) { - const polyline = document.createElementNS(SVG_NS, "polyline"); - polyline.setAttribute("points", points.join(" ")); - polyline.setAttribute("class", "b06-chart__rock-boundary"); - svg.append(polyline); - } -} - -/** 포장 측점의 노면 포장층 박스를 겹쳐 그린다 (노면 양 끝점 기준, 두께만큼 하향). */ -export function appendPavementOverlay( - svg: SVGElement, - design: CrossDesign, - x: (offset: number) => number, - toDisplayY: (elevation: number) => number, -): void { - // 포장은 차도(노견 제외)만 덮는다(D-5). 구 데이터 폴백으로 road_edges를 쓴다. - const edges = design.carriageway_edges ?? design.road_edges; - if (!design.paved || !edges) return; - const thickness = design.pavement_thickness_m ?? 0.2; - const { left, right } = edges; - const points = [ - `${x(left.offset_m)},${toDisplayY(left.elevation_m)}`, - `${x(right.offset_m)},${toDisplayY(right.elevation_m)}`, - `${x(right.offset_m)},${toDisplayY(right.elevation_m - thickness)}`, - `${x(left.offset_m)},${toDisplayY(left.elevation_m - thickness)}`, - ]; - const polygon = document.createElementNS(SVG_NS, "polygon"); - polygon.setAttribute("points", points.join(" ")); - polygon.setAttribute("class", "b06-chart__pavement"); - svg.append(polygon); -} diff --git a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts new file mode 100644 index 00000000..b35b7391 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts @@ -0,0 +1,54 @@ +import type { CrossDesign } from "./B06_Section_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +export function appendRockBoundaryOverlay( + svg: SVGElement, + samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>, + offsetM: number, + x: (offset: number) => number, + y: (elevation: number) => number, +): void { + const segments: string[][] = []; + let current: string[] = []; + for (const sample of samples) { + const elevation = sample.elevation_m; + if (sample.valid === false || elevation === null || !Number.isFinite(elevation ?? NaN)) { + if (current.length > 1) segments.push(current); + current = []; + continue; + } + current.push(`${x(sample.offset_m ?? 0)},${y((elevation as number) + offsetM)}`); + } + if (current.length > 1) segments.push(current); + for (const points of segments) { + const polyline = document.createElementNS(SVG_NS, "polyline"); + polyline.setAttribute("points", points.join(" ")); + polyline.setAttribute("class", "b06-chart__rock-boundary"); + svg.append(polyline); + } +} + +export function appendPavementOverlay( + svg: SVGElement, + design: CrossDesign, + x: (offset: number) => number, + y: (elevation: number) => number, +): void { + const edges = design.carriageway_edges ?? design.road_edges; + if (!design.paved || !edges) return; + const thickness = design.pavement_thickness_m ?? 0.2; + const { left, right } = edges; + const polygon = document.createElementNS(SVG_NS, "polygon"); + polygon.setAttribute( + "points", + [ + `${x(left.offset_m)},${y(left.elevation_m)}`, + `${x(right.offset_m)},${y(right.elevation_m)}`, + `${x(right.offset_m)},${y(right.elevation_m - thickness)}`, + `${x(left.offset_m)},${y(left.elevation_m - thickness)}`, + ].join(" "), + ); + polygon.setAttribute("class", "b06-chart__pavement"); + svg.append(polygon); +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 22240986..bb4f9db7 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -7,7 +7,7 @@ * 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다. * ========================================================================== */ -import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; import { appendCrossAreaBands, type AreaHighlightSetter, @@ -37,7 +37,6 @@ import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culv import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { - CROSS_HEIGHT, CROSS_PAD, CROSS_WIDTH, type DesignChangeHandler, @@ -48,6 +47,8 @@ import { svgText, validElevation, } from "./B06_Section_UI_Section_Common"; +import { crossPlotMetrics } from "./B06_Section_UI_Cross_View_Metrics"; +export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics"; /** * 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다 @@ -57,96 +58,6 @@ export interface CrossCardElement extends HTMLElement { applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void; } -// 절·성토 값 오버레이(그래프 상단 고정)가 계획고 선을 가리는 드문 경우를 대비해, Y 플롯 -// 최대값에 더하는 상단 여유(px). 오버레이·라벨은 손대지 않고 데이터를 그만큼 아래로 내린다. -// 오버레이가 칩 한 줄에서 표(머리글 + 절토 + 성토 3행)로 바뀌어 그만큼 키웠다. -const AREA_OVERLAY_HEADROOM_PX = 58; - -interface CrossPlotMetrics { - sourceSamples: SectionSample[]; - minOffset: number; - maxOffset: number; - elevationMid: number; - displaySpan: number; - displayMax: number; - pixelsPerMeter: number; - heightPx: number; -} - -/** - * 횡단 카드의 X:Y 1:1 스케일 지표를 계산한다. - * X pixels-per-meter는 반폭(offset 범위)이 카드 폭을 채우도록 고정하고, 같은 ppm을 Y에도 써서 - * 형상 왜곡을 없앤다(과장=1 → 정확히 1:1). 카드 높이는 데이터가 요구하는 자연 높이에 최소 - * `CROSS_HEIGHT`(250px) 바닥을 적용하며, `forcedHeightPx`가 오면 그 높이로 강제한다(같은 행 높이 통일). - * 어느 경우든 displaySpan을 실제 plotHeight에 맞춰 되계산하므로 ppm(=1:1)은 그대로 보존되고, - * 남는 세로 공간은 elevationMid를 중심으로 대칭 여백이 된다. - */ -function crossPlotMetrics( - section: CrossSection, - verticalExaggeration: number, - widthPx: number, - crossHalfWidth?: number, - designElevation?: number, - forcedHeightPx?: number, -): CrossPlotMetrics | null { - const sourceSamples = section.samples.filter( - (sample) => - crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6, - ); - const valid = sourceSamples.filter(validElevation); - if (!valid.length) return null; - const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0); - const minOffset = Math.min(...offsets, -1); - const maxOffset = Math.max(...offsets, 1); - const hasDesign = designElevation !== undefined && Number.isFinite(designElevation); - const elevations = valid.map((sample) => sample.elevation_m); - if (hasDesign) elevations.push(designElevation as number); - const rawMin = Math.min(...elevations); - const rawMax = Math.max(...elevations); - const elevationMid = (rawMin + rawMax) / 2; - const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; - const exaggeration = Math.max(verticalExaggeration, 0.1); - const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right; - const xSpan = Math.max(maxOffset - minOffset, 1e-6); - const pixelsPerMeter = plotWidth / xSpan; - const rawSpan = Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1e-6); - // 상단 오버레이 여유(headroom)를 자연 높이에 더해 카드가 그만큼 커지게 한다. - const naturalHeight = - rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; - // 강제 높이가 있으면 그것을, 없으면 자연 높이에 250px 바닥 적용. - const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT); - const plotHeight = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6); - // 실제 plotHeight에 맞춰 표시 표고폭을 되계산 → ppm(1:1) 보존, 여유분은 상하 대칭 여백. - const displaySpan = plotHeight / pixelsPerMeter; - // 늘린 세로 여유의 절반을 위쪽에 몰아 데이터·계획선을 오버레이 아래로 내린다(라벨 위치 불변). - const headroomM = AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter; - const displayMax = elevationMid + displaySpan / 2 + headroomM / 2; - return { - sourceSamples, - minOffset, - maxOffset, - elevationMid, - displaySpan, - displayMax, - pixelsPerMeter, - heightPx, - }; -} - -/** 같은 행 높이 통일을 위해 카드를 만들지 않고 자연(바닥 적용) 높이만 계산한다. */ -export function crossCardNaturalHeight( - section: CrossSection, - verticalExaggeration: number, - widthPx: number, - crossHalfWidth?: number, - designElevation?: number, -): number { - return ( - crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation) - ?.heightPx ?? CROSS_HEIGHT - ); -} - /** * 카드 SVG에 확대·축소 + 드래그 팬 + 더블클릭 원복을 붙인다(E-5). * diff --git a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts new file mode 100644 index 00000000..04cdd36b --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts @@ -0,0 +1,75 @@ +import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; +import { CROSS_HEIGHT, CROSS_PAD, validElevation } from "./B06_Section_UI_Section_Common"; + +const AREA_OVERLAY_HEADROOM_PX = 58; + +interface CrossPlotMetrics { + sourceSamples: SectionSample[]; + minOffset: number; + maxOffset: number; + elevationMid: number; + displaySpan: number; + displayMax: number; + pixelsPerMeter: number; + heightPx: number; +} + +export function crossPlotMetrics( + section: CrossSection, + verticalExaggeration: number, + widthPx: number, + crossHalfWidth?: number, + designElevation?: number, + forcedHeightPx?: number, +): CrossPlotMetrics | null { + const sourceSamples = section.samples.filter( + (sample) => + crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6, + ); + const valid = sourceSamples.filter(validElevation); + if (!valid.length) return null; + const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0); + const minOffset = Math.min(...offsets, -1); + const maxOffset = Math.max(...offsets, 1); + const elevations = valid.map((sample) => sample.elevation_m); + if (designElevation !== undefined && Number.isFinite(designElevation)) + elevations.push(designElevation); + const rawMin = Math.min(...elevations); + const rawMax = Math.max(...elevations); + const elevationMid = (rawMin + rawMax) / 2; + const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; + const pixelsPerMeter = + (widthPx - CROSS_PAD.left - CROSS_PAD.right) / Math.max(maxOffset - minOffset, 1e-6); + const rawSpan = Math.max( + (rawMax - rawMin + padding * 2) * Math.max(verticalExaggeration, 0.1), + 1e-6, + ); + const naturalHeight = + rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; + const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT); + const displaySpan = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6) / pixelsPerMeter; + const displayMax = elevationMid + displaySpan / 2 + AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter / 2; + return { + sourceSamples, + minOffset, + maxOffset, + elevationMid, + displaySpan, + displayMax, + pixelsPerMeter, + heightPx, + }; +} + +export function crossCardNaturalHeight( + section: CrossSection, + verticalExaggeration: number, + widthPx: number, + crossHalfWidth?: number, + designElevation?: number, +): number { + return ( + crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation) + ?.heightPx ?? CROSS_HEIGHT + ); +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index c3be686e..d274b946 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,7 +1,6 @@ import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { navigateTo } from "../A00_Common/router"; -import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, @@ -46,26 +45,12 @@ import { designElevationAt } from "./B06_Section_UI_Section_Common"; import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel"; import "./B06_Section_UI_Style.css"; import "./B06_Section_UI_Style_Cross.css"; +import "./B06_Section_UI_Style_Cross_Controls.css"; import "./B06_Section_UI_Style_Cross_Areas.css"; import { loadSectionDetail, replaceSectionDetail } from "./B06_Section_Section_Store"; +import { buildGroup, L } from "./B06_Section_UI_Page_Common"; import "@util/common_util_mass_haul.css"; -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -// B05 사이드패널 접기 컨테이너 템플릿 재사용(N-4-1): 제목 행 클릭 토글, 우측 ▾/▸ 캐럿. -function buildGroup(legend: string, collapsed = false): HTMLElement { - const group = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). - group.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; - const legendElement = document.createElement("h3"); - legendElement.className = "b06-profile__group-legend ui-collapsible__title"; - legendElement.textContent = legend; - group.append(legendElement); - return group; -} - export async function renderB06ProfileCross(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let currentRouteId: number | null = null; diff --git a/B06_Section/B06_Section_UI_Page_Common.ts b/B06_Section/B06_Section_UI_Page_Common.ts new file mode 100644 index 00000000..ef240f38 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Common.ts @@ -0,0 +1,15 @@ +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; + +export function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export function buildGroup(legend: string, collapsed = false): HTMLElement { + const group = document.createElement("section"); + group.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; + const title = document.createElement("h3"); + title.className = "b06-profile__group-legend ui-collapsible__title"; + title.textContent = legend; + group.append(title); + return group; +} diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 61df6bb6..d4ca38d3 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -39,8 +39,6 @@ import { applyLegendToggle, computeMassHaulSeries, MASS_HAUL_BALANCE_KEY, - MASS_HAUL_DEFAULT_VISIBLE, - normalizeVisibleBasis, type MassHaulSeries, } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; @@ -52,9 +50,21 @@ import { createMassHaulChart, createMassHaulLegend, createMassHaulSummary, - MASS_HAUL_HEIGHT, MASS_HAUL_MIN_HEIGHT, } from "@util/common_util_mass_haul_view"; +import { + BASE_PANEL_HEIGHT, + chartHeights, + MASS_HAUL_VISIBLE_KEY, + MAX_PANEL_HEIGHT_RATIO, + MIN_LONG_HEIGHT, + MIN_PANEL_HEIGHT, + PANEL_CHROME_PX, + PANEL_COLLAPSED_KEY, + PANEL_HEIGHT_KEY, + readVisibleSeries, + unwrapChart, +} from "./B06_Section_UI_Section_View_Panel"; import { calculateYScale, CROSS_GRID_GAP, @@ -66,7 +76,6 @@ import { emptyView, inferStationInterval, L, - LONG_HEIGHT, longitudinalMaxChainage, LONG_PAD, type YScaleOptions, @@ -81,73 +90,6 @@ export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; * 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는 * 출발값이고, 최소 패널 높이 계산의 기준이기도 하다. */ -const PANEL_CHROME_PX = 102; -/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */ -const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX; -const MIN_LONG_HEIGHT = 110; -const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX; -const MAX_PANEL_HEIGHT_RATIO = 0.8; -const PANEL_HEIGHT_KEY = "b06:profile-panel-height"; -const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed"; -// 곡선 키·기본값이 바뀔 때마다 저장 키의 판을 올려 갈아탄다(옛 세션값을 읽으면 의도와 다른 -// 곡선이 켜진다). v2: 키가 `${기준}_${환산}` → `${기준}`. v3: 기본 표시가 횡단 기준 하나로. -// v4: 토량 분배 레이어(`balance`)가 범례에 합류. -const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v4"; - -/** 곡선 기준 + 토량 분배 레이어. 저장값이 없을 때 켜 두는 항목. */ -const DEFAULT_VISIBLE_KEYS: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY]; - -/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */ -function readVisibleSeries(): Set { - try { - const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY); - if (!raw) return new Set(DEFAULT_VISIBLE_KEYS); - const parsed: unknown = JSON.parse(raw); - // 기준 키는 라디오라 항상 하나로 눌러 맞춘다(분배 레이어 켜짐 여부는 저장값 그대로). - return Array.isArray(parsed) - ? normalizeVisibleBasis(new Set(parsed.map(String))) - : new Set(DEFAULT_VISIBLE_KEYS); - } catch { - return new Set(DEFAULT_VISIBLE_KEYS); - } -} - -/** - * 패널 높이를 두 그래프에 나눈다 (2026-08-02 사용자 지시). - * 기본값 이상으로 키우면 → 종단도는 현재 높이 그대로 두고 늘어난 몫은 유토곡선이 먹는다. - * 기본값보다 줄이면 → 두 그래프가 같은 비율로 함께 작아진다. - */ -function chartHeights(availableHeightPx: number): { long: number; mass: number } { - if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) { - return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT }; - } - // 두 그래프가 나눠 가질 실제 몫. **합이 이 값과 정확히 같아야** 한다 — 넘으면 세로 스크롤이, - // 모자라면 유토곡선 아래에 빈 공간이 남는다(2026-08-02 사용자 지적). - const available = Math.max(availableHeightPx, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT); - // 기본 높이 이상이면 종단도는 고정, 늘어난 몫은 유토곡선이 전부 흡수한다. - if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) { - return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT }; - } - const ratio = available / (LONG_HEIGHT + MASS_HAUL_HEIGHT); - // 유토곡선 몫을 먼저 잡되 종단도 하한을 남겨 둔다 — 그래야 두 하한이 동시에 걸려도 합이 넘지 않는다. - const mass = Math.min( - Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)), - available - MIN_LONG_HEIGHT, - ); - return { long: available - mass, mass }; -} - -/** - * 종단 렌더러가 돌려준 `chart-wrap`에서 SVG만 꺼낸다. - * 종단도와 유토곡선이 **하나의 가로 스크롤 컨테이너**를 공유해야 X축 정렬이 유지된다 - * (각자 wrap을 가지면 스크롤이 따로 놀아 같은 측점이 어긋나 보인다). - */ -function unwrapChart(node: HTMLElement): Element { - return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild - ? node.firstElementChild - : node; -} - export interface SectionViewController { root: HTMLElement; render: ( diff --git a/B06_Section/B06_Section_UI_Section_View_Panel.ts b/B06_Section/B06_Section_UI_Section_View_Panel.ts new file mode 100644 index 00000000..021a86a1 --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Panel.ts @@ -0,0 +1,53 @@ +import { + MASS_HAUL_BALANCE_KEY, + MASS_HAUL_DEFAULT_VISIBLE, + normalizeVisibleBasis, +} from "@util/common_util_mass_haul"; +import { MASS_HAUL_HEIGHT, MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; +import { LONG_HEIGHT } from "./B06_Section_UI_Section_Common"; + +export const PANEL_CHROME_PX = 102; +export const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX; +export const MIN_LONG_HEIGHT = 110; +export const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX; +export const MAX_PANEL_HEIGHT_RATIO = 0.8; +export const PANEL_HEIGHT_KEY = "b06:profile-panel-height"; +export const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed"; +export const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v4"; + +const DEFAULT_VISIBLE_KEYS = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY]; + +export function readVisibleSeries(): Set { + try { + const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY); + if (!raw) return new Set(DEFAULT_VISIBLE_KEYS); + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) + ? normalizeVisibleBasis(new Set(parsed.map(String))) + : new Set(DEFAULT_VISIBLE_KEYS); + } catch { + return new Set(DEFAULT_VISIBLE_KEYS); + } +} + +export function chartHeights(availableHeightPx: number): { long: number; mass: number } { + if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) { + return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT }; + } + const available = Math.max(availableHeightPx, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT); + if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) { + return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT }; + } + const ratio = available / (LONG_HEIGHT + MASS_HAUL_HEIGHT); + const mass = Math.min( + Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)), + available - MIN_LONG_HEIGHT, + ); + return { long: available - mass, mass }; +} + +export function unwrapChart(node: HTMLElement): Element { + return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild + ? node.firstElementChild + : node; +} diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index e64c8504..036aaad8 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -562,207 +562,3 @@ gap: 2px; margin-left: auto; } - -/* 구조물 위치 조정 오버레이 창(2026-08-21 사용자 확정) — **횡단도 안**에 뜬다. - 줌 버튼이 우측 상단, 면적표가 중상단이라 이 창은 좌측 하단에 둔다. */ -.b06-structure-panel { - position: absolute; - bottom: var(--spacing-8); - left: var(--spacing-8); - z-index: 3; - display: flex; - flex-direction: column; - gap: 2px; - padding: var(--spacing-8); - border: 1px solid var(--color-danger); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 94%, transparent); -} - -.b06-structure-panel.is-hidden { - display: none; -} - -.b06-structure-panel__head { - display: flex; - align-items: center; - gap: var(--spacing-8); - justify-content: space-between; -} - -.b06-structure-panel__title { - color: var(--color-danger); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); -} - -.b06-structure-panel__value span.is-hidden { - display: none; -} - -.b06-structure-panel__value { - display: flex; - flex-direction: column; - gap: 1px; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -/* 이동 조작 = 십자(D-pad) 배치(2026-08-22 사용자) — 중앙이 초기화. - __controls(flex)보다 뒤에 선언된 것과 무관하게 이기도록 복합 선택자. */ -.b06-structure-panel__controls.b06-structure-panel__buttons { - display: grid; - grid-template-areas: - ". up ." - "left reset right" - ". down equal"; - /* 십자 그룹은 패널 폭 기준 수평 가운데(2026-08-22 사용자) — 버튼 크기는 유지. */ - justify-content: center; - gap: 2px; -} - -.b06-structure-panel__btn--up { - grid-area: up; -} - -.b06-structure-panel__btn--down { - grid-area: down; -} - -.b06-structure-panel__btn--left { - grid-area: left; -} - -.b06-structure-panel__btn--right { - grid-area: right; -} - -.b06-structure-panel__btn--reset { - grid-area: reset; -} - -.b06-structure-panel__btn--equal { - grid-area: equal; -} - -.b06-structure-panel__btn.is-hidden { - display: none; -} - -/* 값 조작 버튼은 정사각 고정 폭 — 높이·단 수·십자 이동의 행열이 맞는다 - (2026-08-22 사용자 — 버튼 행열 정렬). */ -.b06-structure-panel__controls .b06-structure-panel__btn { - box-sizing: border-box; - width: 26px; - height: 22px; - padding: 0; - text-align: center; - line-height: 1; -} - -.b06-structure-panel__hval { - box-sizing: border-box; - width: 3.5em; - text-align: center; - color: var(--color-text-body); - font-size: var(--text-caption); - align-self: center; -} - -.b06-structure-panel__buttons.is-hidden { - display: none; -} - -/* 유입 구조물 형식 드롭다운(2026-08-22 사용자) — 라벨 + select 한 줄. */ -/* 항목 행 = 2행 구조(2026-08-22 사용자): 1행 이름 라벨, 2행 값 조작. */ -.b06-structure-panel__struct { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 2px; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b06-structure-panel__label { - color: var(--color-text-muted); - font-size: var(--text-caption); - line-height: 1.2; -} - -.b06-structure-panel__controls { - display: flex; - align-items: center; - gap: 2px; -} - -.b06-structure-panel__struct.is-hidden { - display: none; -} - -.b06-structure-panel__select { - flex: 1; - padding: 1px var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text); - font-size: var(--text-caption); -} - -.b06-structure-panel__btn { - padding: 0 var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-secondary); - cursor: pointer; - font-size: var(--text-caption); - line-height: 1.6; -} - -.b06-structure-panel__btn:hover { - border-color: var(--color-danger); - color: var(--color-danger); -} - -.b06-structure-panel__close { - padding: 0 4px; - border-color: transparent; - background: transparent; -} - -/* 줌 버튼 — 그래프 우측 상단 오버레이. 휠 대신 쓰는 조작구다(2026-08-02 사용자 확정). */ -.b06-cross-card__zoom { - position: absolute; - top: var(--spacing-4); - right: var(--spacing-4); - z-index: 2; - display: flex; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); -} - -.b06-cross-card__zoom-btn { - width: 22px; - height: 22px; - padding: 0; - border: none; - border-left: 1px solid var(--color-border); - background: none; - color: var(--color-text-secondary); - font-size: 0.8rem; - line-height: 1; - cursor: pointer; -} - -.b06-cross-card__zoom-btn:first-child { - border-left: none; -} - -.b06-cross-card__zoom-btn:hover { - color: var(--color-text); - background: var(--color-surface); -} diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css new file mode 100644 index 00000000..980bc6df --- /dev/null +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -0,0 +1,174 @@ +/* 구조물 위치 조정 오버레이 창. */ +.b06-structure-panel { + position: absolute; + bottom: var(--spacing-8); + left: var(--spacing-8); + z-index: 3; + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8); + border: 1px solid var(--color-danger); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 94%, transparent); +} + +.b06-structure-panel.is-hidden, +.b06-structure-panel__value span.is-hidden, +.b06-structure-panel__btn.is-hidden, +.b06-structure-panel__buttons.is-hidden, +.b06-structure-panel__struct.is-hidden { + display: none; +} + +.b06-structure-panel__head { + display: flex; + align-items: center; + gap: var(--spacing-8); + justify-content: space-between; +} + +.b06-structure-panel__title { + color: var(--color-danger); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); +} + +.b06-structure-panel__value { + display: flex; + flex-direction: column; + gap: 1px; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-structure-panel__controls.b06-structure-panel__buttons { + display: grid; + grid-template-areas: ". up ." "left reset right" ". down equal"; + justify-content: center; + gap: 2px; +} + +.b06-structure-panel__btn--up { + grid-area: up; +} +.b06-structure-panel__btn--down { + grid-area: down; +} +.b06-structure-panel__btn--left { + grid-area: left; +} +.b06-structure-panel__btn--right { + grid-area: right; +} +.b06-structure-panel__btn--reset { + grid-area: reset; +} +.b06-structure-panel__btn--equal { + grid-area: equal; +} + +.b06-structure-panel__controls .b06-structure-panel__btn { + box-sizing: border-box; + width: 26px; + height: 22px; + padding: 0; + text-align: center; + line-height: 1; +} + +.b06-structure-panel__hval { + box-sizing: border-box; + width: 3.5em; + text-align: center; + color: var(--color-text-body); + font-size: var(--text-caption); + align-self: center; +} + +.b06-structure-panel__struct { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 2px; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-structure-panel__label { + color: var(--color-text-muted); + font-size: var(--text-caption); + line-height: 1.2; +} + +.b06-structure-panel__controls { + display: flex; + align-items: center; + gap: 2px; +} + +.b06-structure-panel__select { + flex: 1; + padding: 1px var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--text-caption); +} + +.b06-structure-panel__btn { + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-secondary); + cursor: pointer; + font-size: var(--text-caption); + line-height: 1.6; +} + +.b06-structure-panel__btn:hover { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.b06-structure-panel__close { + padding: 0 4px; + border-color: transparent; + background: transparent; +} + +.b06-cross-card__zoom { + position: absolute; + top: var(--spacing-4); + right: var(--spacing-4); + z-index: 2; + display: flex; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); +} + +.b06-cross-card__zoom-btn { + width: 22px; + height: 22px; + padding: 0; + border: none; + border-left: 1px solid var(--color-border); + background: none; + color: var(--color-text-secondary); + font-size: 0.8rem; + line-height: 1; + cursor: pointer; +} + +.b06-cross-card__zoom-btn:first-child { + border-left: none; +} + +.b06-cross-card__zoom-btn:hover { + color: var(--color-text); + background: var(--color-surface); +}