diff --git a/A00_Common/b_structures_section.ts b/A00_Common/b_structures_section.ts index 051169fc..653431d8 100644 --- a/A00_Common/b_structures_section.ts +++ b/A00_Common/b_structures_section.ts @@ -7,6 +7,9 @@ * 스타일 동봉만 한다 — B06이 이 파일 하나만 import해도 B05와 같은 모양이 실린다. * ========================================================================== */ import "../B05_Profile/B05_Profile_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "../B05_Profile/B05_Profile_UI_Style_Table.css"; +import "../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../B05_Profile/B05_Profile_UI_Style_Structures.css"; export { diff --git a/B04_PreProcess/B04_PreProcess_Router.py b/B04_PreProcess/B04_PreProcess_Router.py index 8615af91..c4e6ad6a 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -7,10 +7,9 @@ from pathlib import Path from typing import Any from uuid import UUID -import aiomysql import numpy as np -from fastapi import APIRouter, Depends, Request -from fastapi.responses import JSONResponse, Response +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine import ( @@ -30,6 +29,10 @@ from B04_PreProcess.B04_PreProcess_Repository import ( list_surface_models, save_surface_analysis_to_db, ) +from B04_PreProcess.B04_PreProcess_Router_Progress import ( + write_surface_progress, +) +from B04_PreProcess.B04_PreProcess_Router_Status import router as status_router from B04_PreProcess.B04_PreProcess_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, @@ -45,9 +48,6 @@ from B04_PreProcess.B04_PreProcess_Schema import ( ) from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection from common_util.common_util_auth import require_system_admin -from common_util.common_util_http_cache import cached_file_response -from common_util.common_util_initial_snapshot import is_designing -from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( get_surface_confirmation_params, @@ -63,37 +63,10 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) POINT_CLOUD_SAMPLE_LIMIT = 500_000 -# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. -PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json") - -def _progress_file_path(project_root: Path) -> Path: - return project_root.joinpath(*PROGRESS_FILE_RELATIVE) - - -def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: - """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" - try: - path = _progress_file_path(project_root) - path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json( - path, - {"progress_percent": percent, "current_stage": stage, "message": message}, - ) - except OSError: - logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) - - -def read_surface_progress(project_root: Path) -> dict[str, Any] | None: - """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" - path = _progress_file_path(project_root) - if not path.is_file(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else None - except (OSError, ValueError): - return None +# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내 +# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04). +router.include_router(status_router) @router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse) @@ -518,201 +491,3 @@ async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsRespon status_code=500, content={"status": "error", "message": "지면 통계 조회 중 오류가 발생했습니다."}, ) - - -@router.get("/{project_id}/surface/status") -async def get_wf1_analysis_status(project_id: UUID) -> dict: - """WF1 분석 상태를 조회한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """ - SELECT state, progress_percent, message, - (SELECT COUNT(*) FROM surface_models - WHERE project_id = %s) as model_count - FROM project_workflow_stages - WHERE project_id = %s AND stage_no = 1 - """, - (str(project_id), str(project_id)), - ) - row = await cursor.fetchone() - - # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) - if not row: - await cursor.execute( - """ - SELECT p.status as project_status, COUNT(sm.id) as model_count - FROM projects p - LEFT JOIN surface_models sm ON sm.project_id = p.id - WHERE p.id = %s AND p.deleted_at IS NULL - GROUP BY p.id, p.status - """, - (str(project_id),), - ) - fallback_row = await cursor.fetchone() - if not fallback_row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, - ) - model_count = int(fallback_row["model_count"]) - project_status = str(fallback_row.get("project_status") or "NEW") - - if project_status == "WF1_FAILED": - state = "FAILED" - progress_percent = 0 - message = "WF1 분석에 실패했습니다." - elif model_count > 0 or project_status == "WF1_COMPLETE": - state = "COMPLETE" - progress_percent = 100 - message = "WF1 분석이 완료되었습니다." - elif project_status == "WF1_ANALYZING": - state = "IN_PROGRESS" - progress_percent = 30 - message = "WF1 분석이 진행 중입니다." - else: - state = "NOT_STARTED" - progress_percent = 0 - message = "WF1 분석 대기 중입니다." - else: - state = row["state"] - progress_percent = row["progress_percent"] - message = row["message"] or "" - model_count = int(row["model_count"]) - - if state == "FAILED": - status = "failed" - current_stage = "failed" - if not message: - message = "WF1 분석에 실패했습니다." - elif state == "COMPLETE": - status = "completed" - progress_percent = 100 - current_stage = "completed" - if not message: - message = "WF1 분석이 완료되었습니다." - elif state == "IN_PROGRESS": - status = "in_progress" - current_stage = "surface_analysis" - if not message: - message = "WF1 분석이 진행 중입니다." - # 진행률 파일이 있으면 실제 단계별 진행률로 대체 - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) - if progress: - progress_percent = int(progress.get("progress_percent", progress_percent)) - current_stage = str(progress.get("current_stage", current_stage)) - message = str(progress.get("message", message)) - except LookupError: - pass - else: - status = "pending" - progress_percent = 0 - current_stage = "pending" - if not message: - message = "WF1 분석 대기 중입니다." - - # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 - # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). - # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): - status = "in_progress" - current_stage = "initial_design" - message = "초기 설계를 계산하는 중입니다." - except (LookupError, OSError, ValueError): - pass - - return { - "project_id": str(project_id), - "status": status, - "model_count": model_count, - "progress_percent": progress_percent, - "current_stage": current_stage, - "message": message, - } - except Exception: - logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, - ) - - -@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) -async def get_surface_model_preview( - request: Request, - project_id: UUID, - model_id: int, - smooth: bool = False, -) -> Response | JSONResponse: - """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - async with connection.cursor() as cursor: - await cursor.execute( - """ - SELECT model_type, model_file_path - FROM surface_models - WHERE id = %s AND project_id = %s - """, - (model_id, str(project_id)), - ) - row = await cursor.fetchone() - if not row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, - ) - model_type, model_file_path = row[0], row[1] - project_root = Path(resolve_stored_project_path(stored_path)) - if not model_file_path: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "모델 파일 경로가 없습니다."}, - ) - model_path = project_root / model_file_path - models_dir = model_path.parent - stem = model_path.stem - - ext = "ply" if model_type == "meshfree" else "glb" - if smooth and model_type in ("dtm", "tin"): - preview_filename = f"{stem}_smooth_preview.glb" - else: - preview_filename = f"{stem}_preview.{ext}" - - preview_path = models_dir / preview_filename - if not preview_path.is_file(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", - }, - ) - - media_type = "application/octet-stream" - if ext == "glb": - media_type = "model/gltf-binary" - elif ext == "ply": - media_type = "application/ply" - - # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). - return cached_file_response(request, preview_path, media_type, preview_filename) - - except Exception: - logger.exception( - "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, - ) diff --git a/B04_PreProcess/B04_PreProcess_Router_Progress.py b/B04_PreProcess/B04_PreProcess_Router_Progress.py new file mode 100644 index 00000000..58adfd6d --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Progress.py @@ -0,0 +1,47 @@ +"""B04 지표면 분석 진행률 파일 입출력. + +`B04_PreProcess_Router` 가 700줄을 넘겨 분리한 조각이다(2026-09-04). 라우터 본체와 +상태 조회 라우터가 **같은 함수**를 써야 해서 여기에 둔다 — 순환 임포트를 피하려고 +양쪽이 이 모듈을 바라본다. 동작·경로·값은 옮기기 전 그대로다. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +logger = logging.getLogger(__name__) + +# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. +PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json") + + +def _progress_file_path(project_root: Path) -> Path: + return project_root.joinpath(*PROGRESS_FILE_RELATIVE) + + +def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: + """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" + try: + path = _progress_file_path(project_root) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json( + path, + {"progress_percent": percent, "current_stage": stage, "message": message}, + ) + except OSError: + logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) + + +def read_surface_progress(project_root: Path) -> dict[str, Any] | None: + """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" + path = _progress_file_path(project_root) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, ValueError): + return None diff --git a/B04_PreProcess/B04_PreProcess_Router_Status.py b/B04_PreProcess/B04_PreProcess_Router_Status.py new file mode 100644 index 00000000..b4461c28 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Status.py @@ -0,0 +1,223 @@ +"""B04 지표면 분석 **상태 조회·모델 프리뷰** 라우터. + +`B04_PreProcess_Router` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). 경로·응답·로직은 +옮기기 전 그대로이고, 본체가 `include_router` 로 이 라우터를 그대로 실어 붙인다. +""" + +import logging +from pathlib import Path +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress +from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_initial_snapshot import is_designing +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +# prefix 는 본체(`_Router`)가 `include_router` 할 때 붙는다 — 여기서 또 주면 +# 경로가 `/api/projects/api/projects/...` 로 겹친다. +router = APIRouter(tags=["B04 Surface Analysis"]) + + +@router.get("/{project_id}/surface/status") +async def get_wf1_analysis_status(project_id: UUID) -> dict: + """WF1 분석 상태를 조회한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT state, progress_percent, message, + (SELECT COUNT(*) FROM surface_models + WHERE project_id = %s) as model_count + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 + """, + (str(project_id), str(project_id)), + ) + row = await cursor.fetchone() + + # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) + if not row: + await cursor.execute( + """ + SELECT p.status as project_status, COUNT(sm.id) as model_count + FROM projects p + LEFT JOIN surface_models sm ON sm.project_id = p.id + WHERE p.id = %s AND p.deleted_at IS NULL + GROUP BY p.id, p.status + """, + (str(project_id),), + ) + fallback_row = await cursor.fetchone() + if not fallback_row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, + ) + model_count = int(fallback_row["model_count"]) + project_status = str(fallback_row.get("project_status") or "NEW") + + if project_status == "WF1_FAILED": + state = "FAILED" + progress_percent = 0 + message = "WF1 분석에 실패했습니다." + elif model_count > 0 or project_status == "WF1_COMPLETE": + state = "COMPLETE" + progress_percent = 100 + message = "WF1 분석이 완료되었습니다." + elif project_status == "WF1_ANALYZING": + state = "IN_PROGRESS" + progress_percent = 30 + message = "WF1 분석이 진행 중입니다." + else: + state = "NOT_STARTED" + progress_percent = 0 + message = "WF1 분석 대기 중입니다." + else: + state = row["state"] + progress_percent = row["progress_percent"] + message = row["message"] or "" + model_count = int(row["model_count"]) + + if state == "FAILED": + status = "failed" + current_stage = "failed" + if not message: + message = "WF1 분석에 실패했습니다." + elif state == "COMPLETE": + status = "completed" + progress_percent = 100 + current_stage = "completed" + if not message: + message = "WF1 분석이 완료되었습니다." + elif state == "IN_PROGRESS": + status = "in_progress" + current_stage = "surface_analysis" + if not message: + message = "WF1 분석이 진행 중입니다." + # 진행률 파일이 있으면 실제 단계별 진행률로 대체 + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) + if progress: + progress_percent = int(progress.get("progress_percent", progress_percent)) + current_stage = str(progress.get("current_stage", current_stage)) + message = str(progress.get("message", message)) + except LookupError: + pass + else: + status = "pending" + progress_percent = 0 + current_stage = "pending" + if not message: + message = "WF1 분석 대기 중입니다." + + # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 + # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). + # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): + status = "in_progress" + current_stage = "initial_design" + message = "초기 설계를 계산하는 중입니다." + except (LookupError, OSError, ValueError): + pass + + return { + "project_id": str(project_id), + "status": status, + "model_count": model_count, + "progress_percent": progress_percent, + "current_stage": current_stage, + "message": message, + } + except Exception: + logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, + ) + + +@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) +async def get_surface_model_preview( + request: Request, + project_id: UUID, + model_id: int, + smooth: bool = False, +) -> Response | JSONResponse: + """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT model_type, model_file_path + FROM surface_models + WHERE id = %s AND project_id = %s + """, + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, + ) + model_type, model_file_path = row[0], row[1] + project_root = Path(resolve_stored_project_path(stored_path)) + if not model_file_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "모델 파일 경로가 없습니다."}, + ) + model_path = project_root / model_file_path + models_dir = model_path.parent + stem = model_path.stem + + ext = "ply" if model_type == "meshfree" else "glb" + if smooth and model_type in ("dtm", "tin"): + preview_filename = f"{stem}_smooth_preview.glb" + else: + preview_filename = f"{stem}_preview.{ext}" + + preview_path = models_dir / preview_filename + if not preview_path.is_file(): + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", + }, + ) + + media_type = "application/octet-stream" + if ext == "glb": + media_type = "model/gltf-binary" + elif ext == "ply": + media_type = "application/ply" + + # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, preview_path, media_type, preview_filename) + + except Exception: + logger.exception( + "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, + ) diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index c6faa675..1ac02733 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -8,7 +8,6 @@ """ import asyncio -import base64 import json import logging import math @@ -16,33 +15,39 @@ from pathlib import Path from typing import Any from uuid import UUID -import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer -from shapely.geometry import Point, Polygon, box +from shapely.geometry import Point from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import ( drainage_dir, - write_grid_arrays, write_stage, ) from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( - AZIMUTH_INVALID, - AZIMUTH_SINK, - AZIMUTH_STEPS, mask_row_spans, ) +from B04_PreProcess.B04_PreProcess_Router_Watershed_Output import ( + _as_polygons, + _boundary_geometry, + _flow_payload, + _grid_bbox_lonlat, + _grid_bbox_polygon, + _line_lonlat, + _polygon_rings, + _write_road_routing, + _write_stage_arrays, +) from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_route_geometry import ( StructureCandidate, find_planned_route_file, load_design_route, read_planned_route, ) -from common_util.common_util_crs import resolve_project_crs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_wamis_rainfall import ( @@ -511,204 +516,3 @@ async def get_primary_region( # 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다. _save_response(prepared["stored_path"], payload) return payload - - -def _write_road_routing( - stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any -) -> None: - """B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다. - - B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은 - 빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는 - 유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시). - """ - routing = preview.routing - road = preview.road - if routing is None or road is None or road.count == 0: - return - write_grid_arrays( - stored_path, - "road_routing", - spec, - { - "road_slot": routing.road_slot, - "path_length": routing.path_length, - "strength": routing.strength, - "road_cell_index": road.cell_index, - "road_chainage": road.chainage, - "elevation": preview.terrain.elevation.reshape(-1), - }, - { - "road_cells": road.count, - "reached_cells": int((routing.road_slot >= 0).sum()), - "basin_area_m2": round(preview.basin_area_m2, 1), - "pipe_count": len(preview.pipes), - }, - ) - # B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다. - write_stage( - stored_path, - "road_routing", - { - "route": [route_line], - "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), - "pipe": [ - ( - Point(pipe.x, pipe.y), - {"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason}, - ) - for pipe in preview.pipes - ], - # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. - "flow_arrow": [ - ( - Point(x, y), - { - # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. - "x": round(x, 2), - "y": round(y, 2), - "azimuth_deg": round(math.degrees(angle), 1), - "reaches_road": reaches, - "cells": cells, - }, - ) - for x, y, angle, reaches, cells in preview.flow_arrows - ], - }, - { - "basin_area_m2": round(preview.basin_area_m2, 1), - "pipe_count": len(preview.pipes), - "route_length_m": round(route_line.length, 1), - "arrow_count": len(preview.flow_arrows), - "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, - }, - to_lonlat, - ) - - -def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: - """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" - if domain is not None: - write_grid_arrays( - stored_path, - "primary_region", - spec, - {"mask": domain}, - { - "cells": int(domain.sum()), - "bbox_cells": spec.size, - "expand_rounds": preview.expand_rounds, - "expand_closed": preview.expand_closed, - }, - ) - flow = preview.flow - if flow is None: - return - arrays = { - "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), - "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), - "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), - # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. - "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), - } - if flow.burned is not None: - arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) - if preview.descent is not None: - arrays["band_elevation"] = preview.descent.band_elevation - # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. - if preview.strength_profile: - curve = np.asarray(preview.strength_profile, dtype=np.float64) - arrays["strength_chainage_m"] = curve[:, 0] - arrays["strength_area_m2"] = curve[:, 1] - write_grid_arrays( - stored_path, - "flow_direction", - spec, - arrays, - { - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "analyzed": int(flow.analyzed.sum()), - "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), - "no_road": int((~flow.reaches_road & flow.analyzed).sum()), - "burned": 0 if flow.burned is None else int(flow.burned.sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "strength_points": len(preview.strength_profile), - "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), - }, - ) - - -def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: - """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. - - 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: - 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 - 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). - 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. - """ - flow = preview.flow - if flow is None or domain is None: - return None - order = np.flatnonzero(domain.reshape(-1)) - analyzed = flow.analyzed[order] - reaches = flow.reaches_road[order] - packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) - packed |= np.where(reaches, 0x80, 0).astype(np.uint8) - burned = flow.burned - return { - "encoding": "base64-uint8", - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "cells": int(order.size), - "reaches_road": int((reaches & analyzed).sum()), - "no_road": int((~reaches & analyzed).sum()), - # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. - "unanalyzed": int((~analyzed).sum()), - # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. - "burned": 0 if burned is None else int(burned[order].sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "data": base64.b64encode(packed.tobytes()).decode("ascii"), - } - - -def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: - """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" - return [Polygon(ring)] if len(ring) >= 4 else [] - - -def _as_polygons(geometry: Any) -> list[Any]: - if geometry is None or geometry.is_empty: - return [] - return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] - - -def _grid_bbox_polygon(spec: Any) -> Polygon: - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_min = spec.y_max - spec.n_rows * spec.cell_m - return box(spec.x_min, y_min, x_max, spec.y_max) - - -def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: - return [list(to_lonlat(x, y)) for x, y in line.coords] - - -def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: - """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" - if geometry is None or geometry.is_empty: - return [] - parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] - return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] - - -def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: - x_min = spec.x_min - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_max = spec.y_max - y_min = spec.y_max - spec.n_rows * spec.cell_m - corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) - return [list(to_lonlat(x, y)) for x, y in corners] diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py b/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py new file mode 100644 index 00000000..87affee5 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py @@ -0,0 +1,225 @@ +"""배수유역 분석 **산출물 기록·응답 기하** 헬퍼. + +`B04_PreProcess_Router_Watershed` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). +B05 가 읽을 `.npz`·스테이지 기하를 남기는 쓰기 함수와, 응답용 좌표 변환 헬퍼만 모았다. +함수 본문·값은 옮기기 전 그대로이고, 라우터가 이 모듈에서 가져다 쓴다. +""" + +import base64 +import math +from typing import Any + +import numpy as np +from shapely.geometry import Point, Polygon, box + +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import ( + write_grid_arrays, + write_stage, +) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, +) +from config.config_system import DRAINAGE_ARROW_SPACING_M + + +def _write_road_routing( + stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any +) -> None: + """B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다. + + B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은 + 빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는 + 유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시). + """ + routing = preview.routing + road = preview.road + if routing is None or road is None or road.count == 0: + return + write_grid_arrays( + stored_path, + "road_routing", + spec, + { + "road_slot": routing.road_slot, + "path_length": routing.path_length, + "strength": routing.strength, + "road_cell_index": road.cell_index, + "road_chainage": road.chainage, + "elevation": preview.terrain.elevation.reshape(-1), + }, + { + "road_cells": road.count, + "reached_cells": int((routing.road_slot >= 0).sum()), + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + }, + ) + # B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다. + write_stage( + stored_path, + "road_routing", + { + "route": [route_line], + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [ + ( + Point(pipe.x, pipe.y), + {"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason}, + ) + for pipe in preview.pipes + ], + # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. + "flow_arrow": [ + ( + Point(x, y), + { + # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. + "x": round(x, 2), + "y": round(y, 2), + "azimuth_deg": round(math.degrees(angle), 1), + "reaches_road": reaches, + "cells": cells, + }, + ) + for x, y, angle, reaches, cells in preview.flow_arrows + ], + }, + { + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + "route_length_m": round(route_line.length, 1), + "arrow_count": len(preview.flow_arrows), + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, + }, + to_lonlat, + ) + + +def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: + """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" + if domain is not None: + write_grid_arrays( + stored_path, + "primary_region", + spec, + {"mask": domain}, + { + "cells": int(domain.sum()), + "bbox_cells": spec.size, + "expand_rounds": preview.expand_rounds, + "expand_closed": preview.expand_closed, + }, + ) + flow = preview.flow + if flow is None: + return + arrays = { + "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), + "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), + "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), + # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. + "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), + } + if flow.burned is not None: + arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) + if preview.descent is not None: + arrays["band_elevation"] = preview.descent.band_elevation + # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. + if preview.strength_profile: + curve = np.asarray(preview.strength_profile, dtype=np.float64) + arrays["strength_chainage_m"] = curve[:, 0] + arrays["strength_area_m2"] = curve[:, 1] + write_grid_arrays( + stored_path, + "flow_direction", + spec, + arrays, + { + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "analyzed": int(flow.analyzed.sum()), + "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), + "no_road": int((~flow.reaches_road & flow.analyzed).sum()), + "burned": 0 if flow.burned is None else int(flow.burned.sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "strength_points": len(preview.strength_profile), + "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), + }, + ) + + +def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: + """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. + + 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: + 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 + 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). + 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. + """ + flow = preview.flow + if flow is None or domain is None: + return None + order = np.flatnonzero(domain.reshape(-1)) + analyzed = flow.analyzed[order] + reaches = flow.reaches_road[order] + packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) + packed |= np.where(reaches, 0x80, 0).astype(np.uint8) + burned = flow.burned + return { + "encoding": "base64-uint8", + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "cells": int(order.size), + "reaches_road": int((reaches & analyzed).sum()), + "no_road": int((~reaches & analyzed).sum()), + # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. + "unanalyzed": int((~analyzed).sum()), + # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. + "burned": 0 if burned is None else int(burned[order].sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "data": base64.b64encode(packed.tobytes()).decode("ascii"), + } + + +def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: + """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" + return [Polygon(ring)] if len(ring) >= 4 else [] + + +def _as_polygons(geometry: Any) -> list[Any]: + if geometry is None or geometry.is_empty: + return [] + return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] + + +def _grid_bbox_polygon(spec: Any) -> Polygon: + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_min = spec.y_max - spec.n_rows * spec.cell_m + return box(spec.x_min, y_min, x_max, spec.y_max) + + +def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: + return [list(to_lonlat(x, y)) for x, y in line.coords] + + +def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: + """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" + if geometry is None or geometry.is_empty: + return [] + parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] + return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] + + +def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: + x_min = spec.x_min + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_max = spec.y_max + y_min = spec.y_max - spec.n_rows * spec.cell_m + corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) + return [list(to_lonlat(x, y)) for x, y in corners] diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 300c8495..6b97dd5e 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -39,6 +39,8 @@ import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer"; import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer"; import { createSurfacePointCloudViewer } from "./B04_PreProcess_UI_Viewer"; import "./B04_PreProcess_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "./B04_PreProcess_UI_Style_Map.css"; // 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가 // 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정). diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 62e63d40..3ac47607 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -563,308 +563,3 @@ right: var(--spacing-16); bottom: var(--spacing-16); } - -/* --- 하단 2D 지도 --- */ -.b04-map { - --b04-map-vector: var(--color-accent); - display: flex; - 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; - align-items: center; -} - -.b04-map__header { - justify-content: space-between; - gap: var(--spacing-16); -} - -.b04-map__header h3 { - font-size: var(--text-body); - color: var(--color-text); -} - -.b04-map__controls { - gap: var(--spacing-12); - flex-wrap: wrap; -} - -.b04-map__control-group { - gap: var(--spacing-8); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b04-map__layer-buttons { - gap: var(--spacing-4); - 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); -} - -/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */ -.b04-map__context-menu { - position: absolute; - z-index: 6; - display: flex; - flex-direction: column; - min-width: 140px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b04-map__context-menu[hidden] { - display: none; -} - -.b04-map__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.b04-map__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -.b04-map__viewport { - position: relative; - width: 100%; - height: 560px; - overflow: hidden; - touch-action: none; - /* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만 - JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */ - cursor: default; - user-select: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-canvas); -} - -.b04-map__image, -.b04-map__canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.b04-map__image { - object-fit: contain; - transform-origin: center; - user-select: none; - pointer-events: none; -} - -.b04-map__canvas { - pointer-events: none; -} - -.b04-map__empty { - position: absolute; - z-index: 2; - inset: 50% auto auto 50%; - transform: translate(-50%, -50%); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -/* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시). - 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ -.b04-map__status-stack { - position: absolute; - z-index: 2; - top: var(--spacing-12); - left: var(--spacing-12); - display: flex; - max-width: min(62%, 900px); - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 배수유역 "분석 중…" 진행 문구 자리 — 우측 최상단(2026-08-01 사용자 지시). - 결과 문구는 좌상단에 그대로 남는다. */ -.b04-map__status-topright { - position: absolute; - z-index: 2; - top: var(--spacing-12); - right: var(--spacing-12); - display: flex; - max-width: min(40%, 360px); - flex-direction: column; - align-items: flex-end; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 객체 표시(지형지물 개수) 라벨 자리 — 우측 최하단(2026-08-01 사용자 지시). - 축척은 좌하단이라 서로 겹치지 않는다. */ -.b04-map__status-corner { - position: absolute; - z-index: 2; - right: var(--spacing-12); - bottom: var(--spacing-12); - display: flex; - max-width: min(50%, 480px); - flex-direction: column; - align-items: flex-end; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 - 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. - 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ -.b04-map__status, -.b04-map__watershed-status { - display: block; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent); - color: var(--color-text-body); - font-size: var(--text-caption); - line-height: 1.5; - word-break: keep-all; - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); -} - -.b04-map__watershed-status[hidden] { - display: none; -} - -@media (max-width: 760px) { - .b04-map__header { - align-items: flex-start; - flex-direction: column; - } -} - -@media (max-width: 1180px) { - .b04-surface__viewers { - grid-template-columns: 1fr; - } -} - -/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */ -.b04-surface__sheet-section { - margin: 0 var(--spacing-24) var(--spacing-16); - padding: var(--spacing-16); - box-sizing: border-box; -} - -.b04-surface__sheet-section > .terrain-model-group { - margin-top: var(--spacing-12); -} - -/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ -.b04-surface__sheet-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - 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; - 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; -} diff --git a/B04_PreProcess/B04_PreProcess_UI_Style_Map.css b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css new file mode 100644 index 00000000..b2f0bc28 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css @@ -0,0 +1,312 @@ +/* ============================================================================= + * B04_PreProcess_UI_Style_Map.css + * 1차 워크플로우 하단 **2D 지도**와 그 도구줄·시트 툴바. + * + * `B04_PreProcess_UI_Style.css` 가 700줄을 넘겨 잘라낸 조각이다(2026-09-04). + * 규칙·순서·값 그대로 옮겼고, 진입 TS 가 본체 다음에 불러 캐스케이드 순서도 같다. + * ========================================================================== */ + +/* --- 하단 2D 지도 --- */ +.b04-map { + --b04-map-vector: var(--color-accent); + display: flex; + 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; + align-items: center; +} + +.b04-map__header { + justify-content: space-between; + gap: var(--spacing-16); +} + +.b04-map__header h3 { + font-size: var(--text-body); + color: var(--color-text); +} + +.b04-map__controls { + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.b04-map__control-group { + gap: var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b04-map__layer-buttons { + gap: var(--spacing-4); + 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); +} + +/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */ +.b04-map__context-menu { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 140px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b04-map__context-menu[hidden] { + display: none; +} + +.b04-map__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.b04-map__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +.b04-map__viewport { + position: relative; + width: 100%; + height: 560px; + overflow: hidden; + touch-action: none; + /* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만 + JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */ + cursor: default; + user-select: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-canvas); +} + +.b04-map__image, +.b04-map__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b04-map__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b04-map__canvas { + pointer-events: none; +} + +.b04-map__empty { + position: absolute; + z-index: 2; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시). + 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ +.b04-map__status-stack { + position: absolute; + z-index: 2; + top: var(--spacing-12); + left: var(--spacing-12); + display: flex; + max-width: min(62%, 900px); + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 배수유역 "분석 중…" 진행 문구 자리 — 우측 최상단(2026-08-01 사용자 지시). + 결과 문구는 좌상단에 그대로 남는다. */ +.b04-map__status-topright { + position: absolute; + z-index: 2; + top: var(--spacing-12); + right: var(--spacing-12); + display: flex; + max-width: min(40%, 360px); + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 객체 표시(지형지물 개수) 라벨 자리 — 우측 최하단(2026-08-01 사용자 지시). + 축척은 좌하단이라 서로 겹치지 않는다. */ +.b04-map__status-corner { + position: absolute; + z-index: 2; + right: var(--spacing-12); + bottom: var(--spacing-12); + display: flex; + max-width: min(50%, 480px); + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 + 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. + 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ +.b04-map__status, +.b04-map__watershed-status { + display: block; + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent); + color: var(--color-text-body); + font-size: var(--text-caption); + line-height: 1.5; + word-break: keep-all; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.b04-map__watershed-status[hidden] { + display: none; +} + +@media (max-width: 760px) { + .b04-map__header { + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 1180px) { + .b04-surface__viewers { + grid-template-columns: 1fr; + } +} + +/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */ +.b04-surface__sheet-section { + margin: 0 var(--spacing-24) var(--spacing-16); + padding: var(--spacing-16); + box-sizing: border-box; +} + +.b04-surface__sheet-section > .terrain-model-group { + margin-top: var(--spacing-12); +} + +/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ +.b04-surface__sheet-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + 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; + 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; +} diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index eff21e17..95ba5839 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -48,6 +48,9 @@ import { type PageActionContext, } from "./B05_Profile_UI_Page_Actions"; import "./B05_Profile_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "./B05_Profile_UI_Style_Table.css"; +import "./B05_Profile_UI_Style_Drainage.css"; import "./B05_Profile_UI_Style_Structures.css"; import { DEFAULT_ROAD_WIDTHS, diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index dc67478c..a2f2b510 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -590,790 +590,3 @@ font-size: var(--text-caption); font-style: normal; } - -/* ─── 도면 테이블 (구배 ~ 곡선 9행) ─────────────────────────────────────── - 셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다. - 행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ -.b05-profile-table { - position: relative; - display: flex; - flex: 0 0 auto; - flex-direction: column; - min-height: 0; - border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); - color: var(--color-text-body); - /* 행 높이와 셀 폭에 맞춰 렌더러가 계산해 넣는다 (createProfileTable). */ - font-size: var(--b05-table-font, 11px); - line-height: 1.1; - user-select: none; -} - -/* 12개 행이 테이블 세로를 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */ -.b05-profile-table__row { - position: relative; - flex: 1 1 0; - min-height: 12px; - border-bottom: 1px solid var(--color-border); - white-space: nowrap; -} - -/* 구배 / 측점값 / 곡선 묶음의 시작 행은 굵은 선으로 그룹을 구분한다. */ -.b05-profile-table__row.is-group-start { - border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); -} - -.b05-profile-table__row--grade { - background: color-mix(in srgb, var(--color-surface) 55%, transparent); -} - -.b05-profile-table__row:last-child { - border-bottom: 0; -} - -/* 가로 스크롤 시 값들이 이름표 열을 뚫고 보이지 않도록 가장 위 층에 불투명하게 둔다. */ -.b05-profile-table__label { - position: sticky; - z-index: 5; - left: 0; - display: inline-flex; - align-items: center; - /* 행제목이 좌측 편집 버튼 층에 걸린다 — 글자를 우측으로 맞추고 좌측 여백을 더 민다 - (2026-08-05 사용자 지시). */ - justify-content: flex-end; - box-sizing: border-box; - width: var(--b05-table-label-width, 60px); - height: 100%; - padding-inline: var(--spacing-16) var(--spacing-4); - border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); - background: var(--color-surface-raised); - color: var(--color-text); - font-size: var(--b05-table-label-font, inherit); - font-weight: var(--font-weight-medium); - white-space: nowrap; -} - -.b05-profile-table__cell, -.b05-profile-table__segment, -.b05-profile-table__curve { - position: absolute; - top: 0; - display: flex; - align-items: center; - justify-content: center; - height: 100%; - overflow: hidden; -} - -/* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로 - 세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */ -.b05-profile-table__curve.is-rotated input { - writing-mode: vertical-rl; - width: 100%; - height: 100%; - padding: 0; - text-align: center; -} - -/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. - 셀 경계(= 이웃 측점과의 중간)에 세로 구분선을 둬 구배 행과 같은 격자를 만든다. */ -.b05-profile-table__cell, -.b05-profile-table__curve { - box-sizing: border-box; - width: var(--b05-table-cell-width, 56px); - border-left: 1px solid var(--color-border); - transform: translateX(-50%); -} - -.b05-profile-table__cell:last-child, -.b05-profile-table__curve:last-child { - border-right: 1px solid var(--color-border); -} - -/* 구조물 측점 빈 칸의 세로선은 이웃 규칙 측점의 값 한가운데를 지나고, 곡선 L/R 칸 - 경계와 x가 겹쳐 곡선 행 구분선이 위 행들까지 이어진 것처럼 보인다. 곡선 행에만 - 남기고 값 행에서는 지운다(2026-08-30 사용자 지시). */ -.b05-profile-table__cell.is-structure { - border-left-color: transparent; -} - -.b05-profile-table__row.is-cut .b05-profile-table__cell { - color: rgb(220 38 38); -} - -.b05-profile-table__row.is-fill .b05-profile-table__cell { - color: rgb(37 99 235); -} - -.b05-profile-table__row.is-plan .b05-profile-table__cell { - color: var(--color-royal-amethyst, rgb(109 40 217)); - font-weight: var(--font-weight-medium); -} - -/* 구간 블록: 변화점 사이를 빈틈없이 채운다. - 구분선은 변화점(= 생성된 R의 중심, 측점 수직선) 위에 놓이므로 왼쪽 테두리 하나면 - 충분하다. 양쪽에 다 주면 맞닿는 자리가 2px로 두꺼워진다. */ -.b05-profile-table__segment { - box-sizing: border-box; - border-left: 1px solid var(--color-border); -} - -.b05-profile-table__segment:last-child { - border-right: 1px solid var(--color-border); -} - -.b05-profile-table__segment.is-violation { - background: color-mix(in srgb, rgb(220 38 38) 12%, transparent); - color: rgb(180 83 9); -} - -/* 구배 블록 값. 가로로 안 들어가면 90도 회전해 좁은 블록에도 값을 표기한다. */ -.b05-profile-table__segment-value { - line-height: 1; - white-space: nowrap; -} - -.b05-profile-table__segment-value.is-rotated { - transform: rotate(-90deg); -} - -.b05-profile-table__curve { - z-index: 2; -} - -.b05-profile-table__curve.is-optional { - opacity: 0.75; -} - -.b05-profile-table__curve.is-omitted { - text-decoration: line-through; -} - -.b05-profile-table__radius { - box-sizing: border-box; - width: 100%; - height: 90%; - padding: 0 1px; - border: 1px solid var(--color-border); - border-radius: 2px; - background: var(--color-surface); - color: var(--color-text-body); - font-size: inherit; - text-align: center; -} - -/* 숫자 입력의 상·하 토글(스피너) 제거 — 항상 사용자가 값 직접 입력. */ -.b05-profile-table__no-spin::-webkit-outer-spin-button, -.b05-profile-table__no-spin::-webkit-inner-spin-button { - margin: 0; - -webkit-appearance: none; - appearance: none; -} - -.b05-profile-table__no-spin { - -moz-appearance: textfield; - appearance: textfield; -} - -/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ────────────── - 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ -/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */ - -.b05-profile-chart__context-menu { - position: absolute; - z-index: 7; - display: flex; - flex-direction: column; - min-width: 120px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b05-profile-chart__context-menu[hidden] { - display: none; -} - -.b05-profile-chart__context-menu-item { - padding: var(--spacing-4) var(--spacing-8); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 12px; - text-align: left; - cursor: pointer; -} - -.b05-profile-chart__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기. - 종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */ -.b05-profile-chart__context-menu, -.b05-drainage__context-menu { - max-height: 320px; - overflow-y: auto; -} - -.b05-profile-chart__context-menu-item.is-group, -.b05-drainage__context-menu-item.is-group { - font-weight: var(--font-weight-medium); -} - -.b05-profile-chart__context-menu-sub, -.b05-drainage__context-menu-sub { - display: flex; - flex-direction: column; -} - -.b05-profile-chart__context-menu-item.is-sub, -.b05-drainage__context-menu-item.is-sub { - padding-left: var(--spacing-24); -} - -.b05-profile-table__irregular-col { - position: absolute; - /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 - 행 제목이 가려지지 않는다. */ - z-index: 4; - top: 0; - bottom: 0; - display: flex; - flex-direction: column; - box-sizing: border-box; - border-inline: 1px solid var(--color-royal-amethyst, rgb(109 40 217)); - background: color-mix( - in srgb, - var(--color-royal-amethyst, rgb(109 40 217)) 14%, - var(--color-surface) - ); - transform: translateX(-50%); - /* 열 자체는 클릭을 통과시키고, 입력 셀만 pointer-events를 되살린다(작업6). */ - pointer-events: none; -} - -/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다. - 떠 있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */ -.b05-profile-table__irregular-col.is-floating { - border-inline-width: 2px; - box-shadow: - 0 0 0 1px var(--color-surface-raised), - 0 4px 12px rgb(0 0 0 / 28%); -} - -.b05-profile-table__irregular-col-cell { - display: flex; - flex: 1 1 0; - align-items: center; - justify-content: center; - min-height: 0; - overflow: hidden; - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); - color: var(--color-text); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -/* 변화점에서 좌/우로 갈린 값: 세로 구분선으로 반씩 나누고 각 반쪽은 폰트를 줄여 셀에 맞춘다. */ -.b05-profile-table__irregular-col-cell.is-split { - gap: 0; -} - -.b05-profile-table__irregular-col-half { - display: flex; - flex: 1 1 0; - align-items: center; - justify-content: center; - min-width: 0; - height: 100%; - overflow: hidden; - font-size: 0.72em; -} - -.b05-profile-table__irregular-col-half:first-child { - border-right: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); -} - -.b05-profile-table__irregular-col-cell:last-child { - border-bottom: 0; -} - -.b05-profile-table__irregular-col-cell.is-cut { - color: rgb(220 38 38); -} - -.b05-profile-table__irregular-col-cell.is-fill { - color: rgb(37 99 235); -} - -/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */ - -/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */ -.b05-profile-table__irregular-input { - box-sizing: border-box; - width: 92%; - height: 88%; - padding: 0; - /* 계획고만 테두리·색으로 따로 강조하지 않는다 — 같은 열의 다른 값과 같아 보여야 한다 - (2026-08-02 사용자 지시). 고칠 수 있다는 표시는 커서와 포커스 테두리로 충분하다. */ - border: 1px solid transparent; - border-radius: 2px; - background: transparent; - color: inherit; - font: inherit; - text-align: center; - pointer-events: auto; -} - -.b05-profile-table__irregular-input:focus, -.b05-profile-table__irregular-input:hover { - border-color: var(--color-royal-amethyst, rgb(109 40 217)); - background: var(--color-surface); -} - -/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ -/* [쉬프트]로 고른 직선 구간 강조 — 직선과 **양 끝 라운드**를 함께 빨갛게 덧그린다 - (2026-09-03 사용자 지시). 편집 버튼층(z-index 4)보다 아래에 깔아 버튼을 가리지 않는다. */ -.b05-profile-runmark { - position: absolute; - z-index: 3; - inset: 0; - pointer-events: none; -} - -.b05-profile-runmark__line { - fill: none; - stroke: var(--color-danger, #d64545); - stroke-width: 3; - stroke-linecap: round; - stroke-linejoin: round; - opacity: 0.85; -} - -/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */ -.b05-profile-runmark__edge { - stroke: var(--color-danger, #d64545); - stroke-width: 1; - stroke-dasharray: 3 3; - opacity: 0.5; -} - -.b05-profile-edit { - position: absolute; - z-index: 4; - inset: 0; - pointer-events: none; -} - -/* 상시 보이게 둔다(예전엔 패널 hover 시에만 노출). 크기는 ▲▼·↺·⬆⬇ 전부 21×17로 - 키웠다(2026-08-04 사용자 지시 — 18×15는 누르기도 읽기도 작았다). */ -.b05-profile-edit__btn { - position: absolute; - width: 21px; - height: 17px; - padding: 0; - font-size: 12px; - line-height: 1; - border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); - border-radius: 3px; - background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent); - color: var(--color-text); - font-size: 9px; - line-height: 1; - cursor: pointer; - opacity: 0.9; - pointer-events: auto; - transition: - opacity var(--transition-fast), - background var(--transition-fast); -} - -.b05-route-profile .b05-profile-edit__btn:hover, -.b05-route-profile .b05-profile-edit__btn:focus-visible { - border-color: var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - opacity: 1; -} - -.b05-profile-edit__btn.is-up { - top: 2px; -} - -.b05-profile-edit__btn.is-down { - bottom: 2px; -} - -/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만 - 렌더러가 좌우로 비켜 배치한다. 글리프(⇧⇩ vs ▲▼)로 구분한다. */ - -/* 방향별 색 — 올림은 빨강, 내림은 파랑(2026-08-04 사용자 지시). 무채색이던 버튼이 - 배경에 묻혀 잘 안 보였다. --color-danger는 테마별로 정의돼 있고(라이트 #c0392b / - 다크 #e8695a), 파랑 #5b8def(--color-chart-0)는 두 테마 모두에서 대비가 나온다. - is-segment(⇧⇩)에도 같은 방향색을 적용한다 — 구분은 글리프가 맡는다. */ -.b05-profile-edit__btn.is-up { - border-color: color-mix(in srgb, var(--color-danger) 65%, transparent); - color: var(--color-danger); -} - -/* 구간 이동(⬆⬇)은 속 찬 화살표가 잘 읽히도록 굵게(크기는 공통 21×17). */ -.b05-profile-edit__btn.is-segment { - font-weight: 700; -} - -.b05-profile-edit__btn.is-down { - border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent); - color: var(--color-chart-0, #5b8def); -} - -.b05-route-profile .b05-profile-edit__btn.is-up:hover, -.b05-route-profile .b05-profile-edit__btn.is-up:focus-visible { - border-color: var(--color-danger); - background: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface-raised)); - color: var(--color-danger); -} - -.b05-route-profile .b05-profile-edit__btn.is-down:hover, -.b05-route-profile .b05-profile-edit__btn.is-down:focus-visible { - border-color: var(--color-chart-0, #5b8def); - background: color-mix(in srgb, var(--color-chart-0, #5b8def) 14%, var(--color-surface-raised)); - color: var(--color-chart-0, #5b8def); -} - -.b05-profile-edit__btn.is-reset { - top: 22px; - border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); - background: var(--color-surface-raised); - color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); - opacity: 0.95; -} - -/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ -.b05-drainage { - position: relative; - display: flex; - /* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다. - 상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */ - width: var(--b05-drainage-width, 38%); - min-width: 320px; - max-width: 70%; - flex: 0 0 auto; - flex-direction: column; - min-height: 0; - border-left: 1px solid var(--color-border); - background: var(--color-surface-raised); - transition: width var(--transition-fast); -} - -/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ -.b05-drainage.is-collapsed { - width: 0; - min-width: 0; -} - -.b05-drainage.is-collapsed .b05-drainage__header, -.b05-drainage.is-collapsed .b05-drainage__viewport { - display: none; -} - -/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. - 유토곡선 오버레이(z-index 5)가 그 위를 덮으면 안 보인다(2026-08-04 사용자 보고) — 위로. */ -.b05-drainage .ui-workflow-overlay__handle--side { - z-index: 6; - right: auto; - left: calc(-1 * var(--spacing-24)); - border-right: 0; - border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); -} - -.b05-drainage__header { - display: flex; - flex: 0 0 auto; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); - border-bottom: 1px solid var(--color-border); -} - -.b05-drainage__header h3 { - margin: 0; - color: var(--color-text); - font-size: var(--text-body-sm); -} - -/* 지도 좌상단 오버레이 — 표시 필터 버튼 줄 + 상태 문구(2026-08-17 사용자 지시로 - 헤더에서 옮겼다). 버튼 자체 여백과 별개로 지도 모서리에서 조금 띄운다. - 우측 폭을 묶는 이유: 화면 오른쪽 진행단계 오버레이(z-index 200)가 지도 위를 - 덮어, 여기까지 늘어난 버튼은 눌리지 않는다. 그 폭(패널 절반 + 우측 여백)을 뺀다. */ -.b05-drainage__map-overlay { - position: absolute; - z-index: 2; - top: var(--spacing-8); - left: var(--spacing-8); - display: flex; - flex-direction: column; - gap: var(--spacing-4); - /* 오버레이 빈 자리로는 지도 조작(팬·유역 고르기)이 그대로 통해야 한다. */ - pointer-events: none; -} - -/* 필터 버튼은 한 줄로 둔다 — 오른쪽 진행단계 오버레이에 끝이 가려도 창을 넓히거나 - 그 오버레이를 접으면 드러난다(2026-08-17 사용자 확정). 두 줄로 접는 쪽이 지도를 - 더 많이 가린다. */ -.b05-drainage__layers { - display: flex; - flex-wrap: nowrap; - gap: var(--spacing-4); - pointer-events: auto; -} - -/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다. - 크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */ -.b05-drainage__layer-button { - /* 한 줄 유지 — 좁아져도 글자가 접히거나 쭈그러들지 않게 크기를 고정한다. */ - flex: 0 0 auto; - white-space: nowrap; - padding: 2px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: var(--text-caption); - opacity: 0.55; - cursor: pointer; -} - -/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */ -.b05-drainage__layer-button.is-active { - border-color: var(--b05-layer-color, var(--color-border)); - box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent); - color: var(--b05-layer-color, var(--color-text-body)); - opacity: 1; -} - -/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */ -.b05-drainage__context-menu { - position: absolute; - z-index: 6; - display: flex; - flex-direction: column; - min-width: 130px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b05-drainage__context-menu[hidden] { - display: none; -} - -.b05-drainage__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.b05-drainage__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -.b05-drainage__viewport { - position: relative; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; - background: var(--color-surface); - /* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가 - grabbing으로 바꾼다(2026-08-01 사용자 지시). */ - cursor: default; - user-select: none; - touch-action: none; -} - -.b05-drainage__image, -.b05-drainage__canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.b05-drainage__image { - object-fit: contain; - transform-origin: center; - user-select: none; - pointer-events: none; -} - -.b05-drainage__canvas { - pointer-events: none; -} - -/* 상태 문구는 좌상단 오버레이가 세로로 쌓아 준다 — 필터 버튼 줄 바로 아래. */ -.b05-drainage__status { - color: var(--color-text-secondary); - font-size: var(--text-caption); - pointer-events: none; -} - -.b05-drainage__analyze { - margin-left: auto; - padding: 2px var(--spacing-8); - border: 1px solid - color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-body); - font-size: var(--text-caption); - cursor: pointer; -} - -.b05-drainage__analyze:disabled { - opacity: 0.45; - cursor: default; -} - -/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ -.b05-drainage__tool { - margin-left: var(--spacing-4, 4px); -} - -/* 배관 편집 토글 활성 상태. */ -.b05-drainage__analyze.is-active { - background: color-mix( - in srgb, - var(--color-royal-amethyst, rgb(109 40 217)) 18%, - var(--color-surface) - ); - border-color: var(--color-royal-amethyst, rgb(109 40 217)); -} - -/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ -/* 유역 목록은 3행까지만 보이고 나머지는 스크롤한다(2026-08-01 사용자 지시). - 높이를 비율로 잡으면 유역 수와 무관하게 잘려 몇 개인지 가늠이 안 된다. */ -.b05-drainage__basins { - --b05-basin-row: 34px; - - display: flex; - max-height: calc(3 * var(--b05-basin-row) + 2 * 2px + 2 * var(--spacing-8)); - flex: 0 0 auto; - flex-direction: column; - gap: 2px; - overflow-y: auto; - padding: var(--spacing-8); - border-top: 1px solid var(--color-border); -} - -/* 관 개수·세부유역 수·종단 Z 출처 한 줄. */ -.b05-drainage__summary { - flex: 0 0 auto; - padding: var(--spacing-4) var(--spacing-8); - border-top: 1px solid var(--color-border); - color: var(--color-text-muted, var(--color-text-body)); - font-size: 12px; -} - -.b05-drainage__summary[hidden] { - display: none; -} - -.b05-drainage__basin { - display: flex; - min-height: var(--b05-basin-row); - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid transparent; - border-radius: var(--radius-inputs); - background: none; - color: var(--color-text-body); - font-size: var(--text-caption); - text-align: left; - cursor: pointer; -} - -.b05-drainage__basin:hover, -.b05-drainage__basin.is-selected { - border-color: var(--color-border); - background: var(--color-surface); -} - -/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */ -.b05-drainage__basin-index { - display: inline-flex; - width: 20px; - height: 20px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border); - border-radius: 50%; - color: #1f2937; - font-size: 11px; - font-weight: 600; -} - -.b05-drainage__basin-metrics { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */ -.b05-profile-table__segment.is-structure { - opacity: 0.55; -} - -.b05-profile-table__segment.is-structure.is-highlight { - opacity: 1; - background: color-mix(in srgb, var(--color-primary) 14%, transparent); - outline: 1px solid var(--color-primary); -} - -/* ── 종단도 줌·Y레인지 조작구 (2026-09-04) ───────────────────────────── - * 요약줄 오른쪽 끝(= 그래프 우측 상단)에 붙는다. 양식은 횡단도 줌 버튼세트 - * (.b06-cross-card__zoom-btn)와 같게 맞췄다 — 그쪽 CSS는 B06 페이지 전용이라 - * 여기서 같은 값으로 다시 적는다. */ -.b05-profile__zoom { - display: flex; - flex: 0 0 auto; - margin-left: auto; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); -} - -.b05-profile__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; -} - -.b05-profile__zoom-btn:first-child { - border-left: none; -} - -.b05-profile__zoom-btn:hover { - color: var(--color-text); - background: var(--color-surface); -} diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css new file mode 100644 index 00000000..cf5e4867 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -0,0 +1,289 @@ +/* ============================================================================= + * B05_Profile_UI_Style_Drainage.css + * 하단 종단 패널 안쪽 **우측 배수유역도** 2단 사이드 패널. + * + * `B05_Profile_UI_Style.css` 가 700줄을 넘겨 잘라낸 조각이다(2026-09-04). + * 규칙·순서·값 그대로 옮겼다. + * ========================================================================== */ + +/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ +.b05-drainage { + position: relative; + display: flex; + /* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다. + 상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */ + width: var(--b05-drainage-width, 38%); + min-width: 320px; + max-width: 70%; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--color-border); + background: var(--color-surface-raised); + transition: width var(--transition-fast); +} + +/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ +.b05-drainage.is-collapsed { + width: 0; + min-width: 0; +} + +.b05-drainage.is-collapsed .b05-drainage__header, +.b05-drainage.is-collapsed .b05-drainage__viewport { + display: none; +} + +/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. + 유토곡선 오버레이(z-index 5)가 그 위를 덮으면 안 보인다(2026-08-04 사용자 보고) — 위로. */ +.b05-drainage .ui-workflow-overlay__handle--side { + z-index: 6; + right: auto; + left: calc(-1 * var(--spacing-24)); + border-right: 0; + border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); +} + +.b05-drainage__header { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); + border-bottom: 1px solid var(--color-border); +} + +.b05-drainage__header h3 { + margin: 0; + color: var(--color-text); + font-size: var(--text-body-sm); +} + +/* 지도 좌상단 오버레이 — 표시 필터 버튼 줄 + 상태 문구(2026-08-17 사용자 지시로 + 헤더에서 옮겼다). 버튼 자체 여백과 별개로 지도 모서리에서 조금 띄운다. + 우측 폭을 묶는 이유: 화면 오른쪽 진행단계 오버레이(z-index 200)가 지도 위를 + 덮어, 여기까지 늘어난 버튼은 눌리지 않는다. 그 폭(패널 절반 + 우측 여백)을 뺀다. */ +.b05-drainage__map-overlay { + position: absolute; + z-index: 2; + top: var(--spacing-8); + left: var(--spacing-8); + display: flex; + flex-direction: column; + gap: var(--spacing-4); + /* 오버레이 빈 자리로는 지도 조작(팬·유역 고르기)이 그대로 통해야 한다. */ + pointer-events: none; +} + +/* 필터 버튼은 한 줄로 둔다 — 오른쪽 진행단계 오버레이에 끝이 가려도 창을 넓히거나 + 그 오버레이를 접으면 드러난다(2026-08-17 사용자 확정). 두 줄로 접는 쪽이 지도를 + 더 많이 가린다. */ +.b05-drainage__layers { + display: flex; + flex-wrap: nowrap; + gap: var(--spacing-4); + pointer-events: auto; +} + +/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다. + 크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */ +.b05-drainage__layer-button { + /* 한 줄 유지 — 좁아져도 글자가 접히거나 쭈그러들지 않게 크기를 고정한다. */ + flex: 0 0 auto; + white-space: nowrap; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-caption); + opacity: 0.55; + cursor: pointer; +} + +/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */ +.b05-drainage__layer-button.is-active { + border-color: var(--b05-layer-color, var(--color-border)); + box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent); + color: var(--b05-layer-color, var(--color-text-body)); + opacity: 1; +} + +/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */ +.b05-drainage__context-menu { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 130px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b05-drainage__context-menu[hidden] { + display: none; +} + +.b05-drainage__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.b05-drainage__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +.b05-drainage__viewport { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + background: var(--color-surface); + /* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가 + grabbing으로 바꾼다(2026-08-01 사용자 지시). */ + cursor: default; + user-select: none; + touch-action: none; +} + +.b05-drainage__image, +.b05-drainage__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b05-drainage__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b05-drainage__canvas { + pointer-events: none; +} + +/* 상태 문구는 좌상단 오버레이가 세로로 쌓아 준다 — 필터 버튼 줄 바로 아래. */ +.b05-drainage__status { + color: var(--color-text-secondary); + font-size: var(--text-caption); + pointer-events: none; +} + +.b05-drainage__analyze { + margin-left: auto; + padding: 2px var(--spacing-8); + border: 1px solid + color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-caption); + cursor: pointer; +} + +.b05-drainage__analyze:disabled { + opacity: 0.45; + cursor: default; +} + +/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ +.b05-drainage__tool { + margin-left: var(--spacing-4, 4px); +} + +/* 배관 편집 토글 활성 상태. */ +.b05-drainage__analyze.is-active { + background: color-mix( + in srgb, + var(--color-royal-amethyst, rgb(109 40 217)) 18%, + var(--color-surface) + ); + border-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + +/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ +/* 유역 목록은 3행까지만 보이고 나머지는 스크롤한다(2026-08-01 사용자 지시). + 높이를 비율로 잡으면 유역 수와 무관하게 잘려 몇 개인지 가늠이 안 된다. */ +.b05-drainage__basins { + --b05-basin-row: 34px; + + display: flex; + max-height: calc(3 * var(--b05-basin-row) + 2 * 2px + 2 * var(--spacing-8)); + flex: 0 0 auto; + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +/* 관 개수·세부유역 수·종단 Z 출처 한 줄. */ +.b05-drainage__summary { + flex: 0 0 auto; + padding: var(--spacing-4) var(--spacing-8); + border-top: 1px solid var(--color-border); + color: var(--color-text-muted, var(--color-text-body)); + font-size: 12px; +} + +.b05-drainage__summary[hidden] { + display: none; +} + +.b05-drainage__basin { + display: flex; + min-height: var(--b05-basin-row); + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-inputs); + background: none; + color: var(--color-text-body); + font-size: var(--text-caption); + text-align: left; + cursor: pointer; +} + +.b05-drainage__basin:hover, +.b05-drainage__basin.is-selected { + border-color: var(--color-border); + background: var(--color-surface); +} + +/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */ +.b05-drainage__basin-index { + display: inline-flex; + width: 20px; + height: 20px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 50%; + color: #1f2937; + font-size: 11px; + font-weight: 600; +} + +.b05-drainage__basin-metrics { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */ diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css new file mode 100644 index 00000000..85044eca --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -0,0 +1,515 @@ +/* ============================================================================= + * B05_Profile_UI_Style_Table.css + * 종단면도 하단 **도면 테이블**과 그 위에 얹히는 것들 — + * 표(구배~곡선) · 선택 측점 값 열 오버레이 · 그래프 우클릭 메뉴 · + * 계획고 편집 버튼 · 줌/Y레인지 조작구. + * + * `B05_Profile_UI_Style.css` 가 700줄을 넘겨 **잘라낸 조각**이다(2026-09-04). + * 규칙은 원본 순서 그대로 옮겼고 값은 하나도 바꾸지 않았다 — 진입 TS 가 + * 본체 다음에 이 파일을 불러오므로 캐스케이드 순서도 같다. + * ========================================================================== */ + +/* ─── 도면 테이블 (구배 ~ 곡선 9행) ─────────────────────────────────────── + 셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다. + 행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ +.b05-profile-table { + position: relative; + display: flex; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); + color: var(--color-text-body); + /* 행 높이와 셀 폭에 맞춰 렌더러가 계산해 넣는다 (createProfileTable). */ + font-size: var(--b05-table-font, 11px); + line-height: 1.1; + user-select: none; +} + +/* 12개 행이 테이블 세로를 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */ +.b05-profile-table__row { + position: relative; + flex: 1 1 0; + min-height: 12px; + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +/* 구배 / 측점값 / 곡선 묶음의 시작 행은 굵은 선으로 그룹을 구분한다. */ +.b05-profile-table__row.is-group-start { + border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); +} + +.b05-profile-table__row--grade { + background: color-mix(in srgb, var(--color-surface) 55%, transparent); +} + +.b05-profile-table__row:last-child { + border-bottom: 0; +} + +/* 가로 스크롤 시 값들이 이름표 열을 뚫고 보이지 않도록 가장 위 층에 불투명하게 둔다. */ +.b05-profile-table__label { + position: sticky; + z-index: 5; + left: 0; + display: inline-flex; + align-items: center; + /* 행제목이 좌측 편집 버튼 층에 걸린다 — 글자를 우측으로 맞추고 좌측 여백을 더 민다 + (2026-08-05 사용자 지시). */ + justify-content: flex-end; + box-sizing: border-box; + width: var(--b05-table-label-width, 60px); + height: 100%; + padding-inline: var(--spacing-16) var(--spacing-4); + border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); + background: var(--color-surface-raised); + color: var(--color-text); + font-size: var(--b05-table-label-font, inherit); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.b05-profile-table__cell, +.b05-profile-table__segment, +.b05-profile-table__curve { + position: absolute; + top: 0; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + overflow: hidden; +} + +/* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로 + 세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */ +.b05-profile-table__curve.is-rotated input { + writing-mode: vertical-rl; + width: 100%; + height: 100%; + padding: 0; + text-align: center; +} + +/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. + 셀 경계(= 이웃 측점과의 중간)에 세로 구분선을 둬 구배 행과 같은 격자를 만든다. */ +.b05-profile-table__cell, +.b05-profile-table__curve { + box-sizing: border-box; + width: var(--b05-table-cell-width, 56px); + border-left: 1px solid var(--color-border); + transform: translateX(-50%); +} + +.b05-profile-table__cell:last-child, +.b05-profile-table__curve:last-child { + border-right: 1px solid var(--color-border); +} + +/* 구조물 측점 빈 칸의 세로선은 이웃 규칙 측점의 값 한가운데를 지나고, 곡선 L/R 칸 + 경계와 x가 겹쳐 곡선 행 구분선이 위 행들까지 이어진 것처럼 보인다. 곡선 행에만 + 남기고 값 행에서는 지운다(2026-08-30 사용자 지시). */ +.b05-profile-table__cell.is-structure { + border-left-color: transparent; +} + +.b05-profile-table__row.is-cut .b05-profile-table__cell { + color: rgb(220 38 38); +} + +.b05-profile-table__row.is-fill .b05-profile-table__cell { + color: rgb(37 99 235); +} + +.b05-profile-table__row.is-plan .b05-profile-table__cell { + color: var(--color-royal-amethyst, rgb(109 40 217)); + font-weight: var(--font-weight-medium); +} + +/* 구간 블록: 변화점 사이를 빈틈없이 채운다. + 구분선은 변화점(= 생성된 R의 중심, 측점 수직선) 위에 놓이므로 왼쪽 테두리 하나면 + 충분하다. 양쪽에 다 주면 맞닿는 자리가 2px로 두꺼워진다. */ +.b05-profile-table__segment { + box-sizing: border-box; + border-left: 1px solid var(--color-border); +} + +.b05-profile-table__segment:last-child { + border-right: 1px solid var(--color-border); +} + +.b05-profile-table__segment.is-violation { + background: color-mix(in srgb, rgb(220 38 38) 12%, transparent); + color: rgb(180 83 9); +} + +/* 구배 블록 값. 가로로 안 들어가면 90도 회전해 좁은 블록에도 값을 표기한다. */ +.b05-profile-table__segment-value { + line-height: 1; + white-space: nowrap; +} + +.b05-profile-table__segment-value.is-rotated { + transform: rotate(-90deg); +} + +.b05-profile-table__curve { + z-index: 2; +} + +.b05-profile-table__curve.is-optional { + opacity: 0.75; +} + +.b05-profile-table__curve.is-omitted { + text-decoration: line-through; +} + +.b05-profile-table__radius { + box-sizing: border-box; + width: 100%; + height: 90%; + padding: 0 1px; + border: 1px solid var(--color-border); + border-radius: 2px; + background: var(--color-surface); + color: var(--color-text-body); + font-size: inherit; + text-align: center; +} + +/* 숫자 입력의 상·하 토글(스피너) 제거 — 항상 사용자가 값 직접 입력. */ +.b05-profile-table__no-spin::-webkit-outer-spin-button, +.b05-profile-table__no-spin::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.b05-profile-table__no-spin { + -moz-appearance: textfield; + appearance: textfield; +} + +/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ────────────── + 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ +/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */ + +.b05-profile-chart__context-menu { + position: absolute; + z-index: 7; + display: flex; + flex-direction: column; + min-width: 120px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b05-profile-chart__context-menu[hidden] { + display: none; +} + +.b05-profile-chart__context-menu-item { + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.b05-profile-chart__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기. + 종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */ +.b05-profile-chart__context-menu, +.b05-drainage__context-menu { + max-height: 320px; + overflow-y: auto; +} + +.b05-profile-chart__context-menu-item.is-group, +.b05-drainage__context-menu-item.is-group { + font-weight: var(--font-weight-medium); +} + +.b05-profile-chart__context-menu-sub, +.b05-drainage__context-menu-sub { + display: flex; + flex-direction: column; +} + +.b05-profile-chart__context-menu-item.is-sub, +.b05-drainage__context-menu-item.is-sub { + padding-left: var(--spacing-24); +} + +.b05-profile-table__irregular-col { + position: absolute; + /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 + 행 제목이 가려지지 않는다. */ + z-index: 4; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + box-sizing: border-box; + border-inline: 1px solid var(--color-royal-amethyst, rgb(109 40 217)); + background: color-mix( + in srgb, + var(--color-royal-amethyst, rgb(109 40 217)) 14%, + var(--color-surface) + ); + transform: translateX(-50%); + /* 열 자체는 클릭을 통과시키고, 입력 셀만 pointer-events를 되살린다(작업6). */ + pointer-events: none; +} + +/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다. + 떠 있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */ +.b05-profile-table__irregular-col.is-floating { + border-inline-width: 2px; + box-shadow: + 0 0 0 1px var(--color-surface-raised), + 0 4px 12px rgb(0 0 0 / 28%); +} + +.b05-profile-table__irregular-col-cell { + display: flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-height: 0; + overflow: hidden; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); + color: var(--color-text); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* 변화점에서 좌/우로 갈린 값: 세로 구분선으로 반씩 나누고 각 반쪽은 폰트를 줄여 셀에 맞춘다. */ +.b05-profile-table__irregular-col-cell.is-split { + gap: 0; +} + +.b05-profile-table__irregular-col-half { + display: flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-width: 0; + height: 100%; + overflow: hidden; + font-size: 0.72em; +} + +.b05-profile-table__irregular-col-half:first-child { + border-right: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); +} + +.b05-profile-table__irregular-col-cell:last-child { + border-bottom: 0; +} + +.b05-profile-table__irregular-col-cell.is-cut { + color: rgb(220 38 38); +} + +.b05-profile-table__irregular-col-cell.is-fill { + color: rgb(37 99 235); +} + +/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */ + +/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */ +.b05-profile-table__irregular-input { + box-sizing: border-box; + width: 92%; + height: 88%; + padding: 0; + /* 계획고만 테두리·색으로 따로 강조하지 않는다 — 같은 열의 다른 값과 같아 보여야 한다 + (2026-08-02 사용자 지시). 고칠 수 있다는 표시는 커서와 포커스 테두리로 충분하다. */ + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + color: inherit; + font: inherit; + text-align: center; + pointer-events: auto; +} + +.b05-profile-table__irregular-input:focus, +.b05-profile-table__irregular-input:hover { + border-color: var(--color-royal-amethyst, rgb(109 40 217)); + background: var(--color-surface); +} + +/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ +/* [쉬프트]로 고른 직선 구간 강조 — 직선과 **양 끝 라운드**를 함께 빨갛게 덧그린다 + (2026-09-03 사용자 지시). 편집 버튼층(z-index 4)보다 아래에 깔아 버튼을 가리지 않는다. */ +.b05-profile-runmark { + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; +} + +.b05-profile-runmark__line { + fill: none; + stroke: var(--color-danger, #d64545); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.85; +} + +/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */ +.b05-profile-runmark__edge { + stroke: var(--color-danger, #d64545); + stroke-width: 1; + stroke-dasharray: 3 3; + opacity: 0.5; +} + +.b05-profile-edit { + position: absolute; + z-index: 4; + inset: 0; + pointer-events: none; +} + +/* 상시 보이게 둔다(예전엔 패널 hover 시에만 노출). 크기는 ▲▼·↺·⬆⬇ 전부 21×17로 + 키웠다(2026-08-04 사용자 지시 — 18×15는 누르기도 읽기도 작았다). */ +.b05-profile-edit__btn { + position: absolute; + width: 21px; + height: 17px; + padding: 0; + font-size: 12px; + line-height: 1; + border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); + border-radius: 3px; + background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent); + color: var(--color-text); + font-size: 9px; + line-height: 1; + cursor: pointer; + opacity: 0.9; + pointer-events: auto; + transition: + opacity var(--transition-fast), + background var(--transition-fast); +} + +.b05-route-profile .b05-profile-edit__btn:hover, +.b05-route-profile .b05-profile-edit__btn:focus-visible { + border-color: var(--color-border); + background: var(--color-surface-raised); + color: var(--color-text); + opacity: 1; +} + +.b05-profile-edit__btn.is-up { + top: 2px; +} + +.b05-profile-edit__btn.is-down { + bottom: 2px; +} + +/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만 + 렌더러가 좌우로 비켜 배치한다. 글리프(⇧⇩ vs ▲▼)로 구분한다. */ + +/* 방향별 색 — 올림은 빨강, 내림은 파랑(2026-08-04 사용자 지시). 무채색이던 버튼이 + 배경에 묻혀 잘 안 보였다. --color-danger는 테마별로 정의돼 있고(라이트 #c0392b / + 다크 #e8695a), 파랑 #5b8def(--color-chart-0)는 두 테마 모두에서 대비가 나온다. + is-segment(⇧⇩)에도 같은 방향색을 적용한다 — 구분은 글리프가 맡는다. */ +.b05-profile-edit__btn.is-up { + border-color: color-mix(in srgb, var(--color-danger) 65%, transparent); + color: var(--color-danger); +} + +/* 구간 이동(⬆⬇)은 속 찬 화살표가 잘 읽히도록 굵게(크기는 공통 21×17). */ +.b05-profile-edit__btn.is-segment { + font-weight: 700; +} + +.b05-profile-edit__btn.is-down { + border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent); + color: var(--color-chart-0, #5b8def); +} + +.b05-route-profile .b05-profile-edit__btn.is-up:hover, +.b05-route-profile .b05-profile-edit__btn.is-up:focus-visible { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface-raised)); + color: var(--color-danger); +} + +.b05-route-profile .b05-profile-edit__btn.is-down:hover, +.b05-route-profile .b05-profile-edit__btn.is-down:focus-visible { + border-color: var(--color-chart-0, #5b8def); + background: color-mix(in srgb, var(--color-chart-0, #5b8def) 14%, var(--color-surface-raised)); + color: var(--color-chart-0, #5b8def); +} + +.b05-profile-edit__btn.is-reset { + top: 22px; + border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + background: var(--color-surface-raised); + color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); + opacity: 0.95; +} +.b05-profile-table__segment.is-structure { + opacity: 0.55; +} + +.b05-profile-table__segment.is-structure.is-highlight { + opacity: 1; + background: color-mix(in srgb, var(--color-primary) 14%, transparent); + outline: 1px solid var(--color-primary); +} + +/* ── 종단도 줌·Y레인지 조작구 (2026-09-04) ───────────────────────────── + * 요약줄 오른쪽 끝(= 그래프 우측 상단)에 붙는다. 양식은 횡단도 줌 버튼세트 + * (.b06-cross-card__zoom-btn)와 같게 맞췄다 — 그쪽 CSS는 B06 페이지 전용이라 + * 여기서 같은 값으로 다시 적는다. */ +.b05-profile__zoom { + display: flex; + flex: 0 0 auto; + margin-left: auto; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); +} + +.b05-profile__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; +} + +.b05-profile__zoom-btn:first-child { + border-left: none; +} + +.b05-profile__zoom-btn:hover { + color: var(--color-text); + background: var(--color-surface); +}