Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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": "프리뷰 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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 사용자 확정).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user