Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-09-04 10:34:30 +09:00
13 changed files with 1641 additions and 1535 deletions
+3
View File
@@ -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 {
+9 -234
View File
@@ -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": "프리뷰 파일 조회 중 오류가 발생했습니다."},
)
@@ -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
@@ -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": "프리뷰 파일 조회 중 오류가 발생했습니다."},
)
+13 -209
View File
@@ -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]
@@ -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]
+2
View File
@@ -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 사용자 확정).
-305
View File
@@ -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;
}
@@ -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;
}
+3
View File
@@ -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,
-787
View File
@@ -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);
}
@@ -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;
}
/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */
+515
View File
@@ -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);
}