feat(532-3): E02 지반 조건 — 기본값 정본 · 물성 표(지층 추가 · 지움) · 구간 덮어쓰기 · KDS 기준안전율 · 노면 하중 · 지하수 · 지진 · 저장 · 초기화
- 기본값 정본 resources/master_template/stability/ground_defaults.json — 지층 여섯 · 값마다 출처(07 표 · 절) · KDS 11 70 05 표 4.3-1~3 · 노면 10 kPa · 지하수 규칙 · 지진 끔 · 범위
- 07 원문 대조 — 성토 φ 25 → 28(07 §4 종합 28~30 아래값) · 나머지 범위 안
- API GET · PUT /api/projects/{id}/stab/ground · POST …/reset — 작업본 E02_StabGround/ground.json · 초기값 initial_snapshot/E02_StabGround/ground.json(E 생성 직후 기본값 + B06 측점 암 경계) · 측점은 서버 값
- 검사 · 측점 적용 = 파이썬 · TS 짝(E02_StabGround_Model.py ↔ logic/E02_StabGround_Check.ts) · 거울 시험
- 화면 — 카드 넷 · 칸 위 근거 · 기본값 툴팁 · 틀린 칸 표시 · sessionStorage 캐시 · 자동저장 없음
- test_528 E01 시험 — 생성 뒤 E02 초기값 부름 확인 더함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from pydantic import BaseModel, Field
|
||||
from common_util.common_util_auth import require_company
|
||||
from common_util.common_util_project_kind import create_kind_project
|
||||
from config.config_db import get_db_pool
|
||||
from E02_StabGround.E02_StabGround_Router import seed_initial
|
||||
|
||||
router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"])
|
||||
|
||||
@@ -84,4 +85,5 @@ async def post_stab_project(
|
||||
project_year=source["project_year"],
|
||||
source_project_id=source["project_id"],
|
||||
)
|
||||
await seed_initial(project_id) # E02 초기값(기본값 + B06 측점)
|
||||
return {"project_id": project_id}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""E02 지반 조건 정본 꼴 · 검사 · 측점 적용(요구 347-2 · 3 · 4 · PLAN 532-3).
|
||||
|
||||
정본 = `E02_StabGround/ground.json` · 초기값 = `initial_snapshot/E02_StabGround/ground.json`
|
||||
(E 생성 직후 = 마스터 기본값 + B06 측점 암 경계) · 기본값 = `resources/master_template/stability/
|
||||
ground_defaults.json`(M02 마스터 층). 물성 한 줄 = `E03_StabSection_Types.Material` 꼴 + `water`.
|
||||
|
||||
⚠ 파이썬·TS 짝 — `check_ground` ↔ `logic/E02_StabGround_Check.ts` `checkGround` ·
|
||||
`resolve_stations` ↔ `resolveStations`. 한쪽을 고치면 다른 쪽도 · 거울 시험
|
||||
`resources/tester/test_532_3_e02_ground.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULTS_PATH = (
|
||||
PROJECT_ROOT / "resources" / "master_template" / "stability" / "ground_defaults.json"
|
||||
)
|
||||
STAGE_DIR = "E02_StabGround"
|
||||
GROUND_NAME = "ground.json"
|
||||
|
||||
MATERIAL_FIELDS = ("gamma_t", "gamma_sat", "c", "phi")
|
||||
WATER_GROUPS = ("soil", "rock")
|
||||
LABELS = ("soil", "ripping_rock", "blasting_rock", "fill")
|
||||
ROCK_LABELS = ("ripping_rock", "blasting_rock")
|
||||
CASES = ("dry", "wet", "seismic", "short_term")
|
||||
SLOPES = ("cut", "fill", "soft")
|
||||
WATER_KEYS = ("dry", "wet_soil", "wet_rock")
|
||||
WATER_RULES = ("none", "surface", "half_height")
|
||||
# 정본에 남기는 칸 — 그 밖(limits · sources · note)은 기본값 파일에만
|
||||
GROUND_KEYS = (
|
||||
"version",
|
||||
"materials",
|
||||
"label_map",
|
||||
"stations",
|
||||
"zones",
|
||||
"safety_factors",
|
||||
"road_surcharge_kpa",
|
||||
"groundwater",
|
||||
"seismic",
|
||||
)
|
||||
|
||||
|
||||
def load_defaults() -> dict[str, Any]:
|
||||
return json.loads(DEFAULTS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def ground_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / STAGE_DIR / GROUND_NAME
|
||||
|
||||
|
||||
def initial_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / "initial_snapshot" / STAGE_DIR / GROUND_NAME
|
||||
|
||||
|
||||
def pick(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""정본 칸만 떠 옴(깊은 복사)."""
|
||||
return copy.deepcopy({key: doc[key] for key in GROUND_KEYS if key in doc})
|
||||
|
||||
|
||||
def build_initial(defaults: dict[str, Any], stations: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""초기값 = 기본값 정본 칸 + B06 측점(암 경계)."""
|
||||
ground = pick(defaults)
|
||||
ground["stations"] = stations
|
||||
return ground
|
||||
|
||||
|
||||
def _num(value: Any) -> bool:
|
||||
return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def _in(value: Any, bound: list[float]) -> bool:
|
||||
return _num(value) and bound[0] <= value <= bound[1]
|
||||
|
||||
|
||||
def _list(value: Any) -> list:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _dict(value: Any) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def check_ground(ground: dict[str, Any], limits: dict[str, list[float]]) -> list[dict[str, str]]:
|
||||
"""틀린 칸 목록 [{path, code}] — 빈 목록 = 통과. code = number · range · order · sat_lt_t ·
|
||||
name · name_dup · id_dup · enum · ref · empty · type."""
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
def bad(path: str, code: str) -> None:
|
||||
errors.append({"path": path, "code": code})
|
||||
|
||||
def ranged(path: str, value: Any, key: str) -> None:
|
||||
if not _num(value):
|
||||
bad(path, "number")
|
||||
elif not _in(value, limits[key]):
|
||||
bad(path, "range")
|
||||
|
||||
materials = _list(ground.get("materials"))
|
||||
if not materials:
|
||||
bad("materials", "empty")
|
||||
ids: list[str] = []
|
||||
names: list[str] = []
|
||||
for i, item in enumerate(materials):
|
||||
base = f"materials.{i}"
|
||||
if not isinstance(item, dict):
|
||||
bad(base, "type")
|
||||
continue
|
||||
mid = item.get("id")
|
||||
if not isinstance(mid, str) or not mid or mid in ids:
|
||||
bad(f"{base}.id", "id_dup")
|
||||
ids.append(mid if isinstance(mid, str) else "")
|
||||
name = item["name"].strip() if isinstance(item.get("name"), str) else ""
|
||||
low, high = limits["name_length"]
|
||||
if not low <= len(name) <= high:
|
||||
bad(f"{base}.name", "name")
|
||||
elif name in names:
|
||||
bad(f"{base}.name", "name_dup")
|
||||
names.append(name)
|
||||
for field in MATERIAL_FIELDS:
|
||||
ranged(f"{base}.{field}", item.get(field), field)
|
||||
if _num(item.get("gamma_t")) and _num(item.get("gamma_sat")):
|
||||
if item["gamma_sat"] < item["gamma_t"]:
|
||||
bad(f"{base}.gamma_sat", "sat_lt_t")
|
||||
if item.get("water") not in WATER_GROUPS:
|
||||
bad(f"{base}.water", "enum")
|
||||
|
||||
label_map = _dict(ground.get("label_map"))
|
||||
for label in LABELS:
|
||||
if not label_map.get(label) or label_map.get(label) not in ids:
|
||||
bad(f"label_map.{label}", "ref")
|
||||
|
||||
for i, zone in enumerate(_list(ground.get("zones"))):
|
||||
base = f"zones.{i}"
|
||||
if not isinstance(zone, dict):
|
||||
bad(base, "type")
|
||||
continue
|
||||
ranged(f"{base}.from_m", zone.get("from_m"), "chainage_m")
|
||||
ranged(f"{base}.to_m", zone.get("to_m"), "chainage_m")
|
||||
if _num(zone.get("from_m")) and _num(zone.get("to_m")):
|
||||
if zone["from_m"] >= zone["to_m"]:
|
||||
bad(f"{base}.to_m", "order")
|
||||
for layer in ("soil", "rock"):
|
||||
if zone.get(layer) is not None and zone.get(layer) not in ids:
|
||||
bad(f"{base}.{layer}", "ref")
|
||||
if zone.get("soil_thickness_m") is not None:
|
||||
ranged(f"{base}.soil_thickness_m", zone.get("soil_thickness_m"), "soil_thickness_m")
|
||||
|
||||
factors = _dict(ground.get("safety_factors"))
|
||||
for case in CASES:
|
||||
for slope in SLOPES:
|
||||
value = _dict(factors.get(case)).get(slope)
|
||||
ranged(f"safety_factors.{case}.{slope}", value, "safety_factor")
|
||||
|
||||
ranged("road_surcharge_kpa", ground.get("road_surcharge_kpa"), "road_surcharge_kpa")
|
||||
water = _dict(ground.get("groundwater"))
|
||||
for key in WATER_KEYS:
|
||||
if water.get(key) not in WATER_RULES:
|
||||
bad(f"groundwater.{key}", "enum")
|
||||
seismic = _dict(ground.get("seismic"))
|
||||
if not isinstance(seismic.get("enabled"), bool):
|
||||
bad("seismic.enabled", "enum")
|
||||
ranged("seismic.kh", seismic.get("kh"), "kh")
|
||||
return errors
|
||||
|
||||
|
||||
def clean(ground: dict[str, Any]) -> dict[str, Any]:
|
||||
"""검사를 지난 정본에서 알려진 칸만 남김 · 이름 앞뒤 빈칸 걷음."""
|
||||
out = pick(ground)
|
||||
out["materials"] = [
|
||||
{
|
||||
"id": m["id"],
|
||||
"name": m["name"].strip(),
|
||||
**{field: m[field] for field in MATERIAL_FIELDS},
|
||||
"water": m["water"],
|
||||
}
|
||||
for m in out["materials"]
|
||||
]
|
||||
out["label_map"] = {label: out["label_map"][label] for label in LABELS}
|
||||
out["zones"] = [
|
||||
{key: z.get(key) for key in ("from_m", "to_m", "soil", "rock", "soil_thickness_m")}
|
||||
for z in _list(out.get("zones"))
|
||||
]
|
||||
factors = out["safety_factors"]
|
||||
out["safety_factors"] = {c: {s: factors[c][s] for s in SLOPES} for c in CASES}
|
||||
out["groundwater"] = {key: out["groundwater"][key] for key in WATER_KEYS}
|
||||
out["seismic"] = {"enabled": out["seismic"]["enabled"], "kh": out["seismic"]["kh"]}
|
||||
return out
|
||||
|
||||
|
||||
def resolve_stations(ground: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""측점마다 적용 지층 — 위 = 토사 연결 · 아래 = 암 측점의 암 연결 · 두께 = B06 · 구간이
|
||||
덮어씀(뒤 구간이 이김 · 양 끝 포함 · 빈 칸은 안 바꿈) · 두께가 없으면 아래 지층 없음.
|
||||
성토체는 측점과 상관없이 fill 연결."""
|
||||
label_map = ground.get("label_map") or {}
|
||||
out: list[dict[str, Any]] = []
|
||||
for station in ground.get("stations") or []:
|
||||
chainage = station["chainage_m"]
|
||||
ground_type = station.get("ground_type")
|
||||
upper = label_map.get("soil")
|
||||
lower = label_map.get(ground_type) if ground_type in ROCK_LABELS else None
|
||||
thickness = station.get("soil_thickness_m")
|
||||
zoned = False
|
||||
for zone in ground.get("zones") or []:
|
||||
if not zone["from_m"] <= chainage <= zone["to_m"]:
|
||||
continue
|
||||
zoned = True
|
||||
if zone.get("soil") is not None:
|
||||
upper = zone["soil"]
|
||||
if zone.get("rock") is not None:
|
||||
lower = zone["rock"]
|
||||
if zone.get("soil_thickness_m") is not None:
|
||||
thickness = zone["soil_thickness_m"]
|
||||
if thickness is None: # 경계 없음 = 전부 위 지층
|
||||
lower = None
|
||||
out.append(
|
||||
{
|
||||
"chainage_m": chainage,
|
||||
"ground_type": ground_type,
|
||||
"upper": upper,
|
||||
"lower": lower,
|
||||
"soil_thickness_m": thickness if lower is not None else None,
|
||||
"zoned": zoned,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,156 @@
|
||||
"""E02 지반 조건 API (요구 347-2 · 3 · 4 · PLAN 532-3).
|
||||
|
||||
- `GET /api/projects/{id}/stab/ground` — 작업본(없으면 초기값) + 기본값 + 고칠 수 있는지
|
||||
- `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json`
|
||||
· 측점(B06 암 경계)은 서버 값
|
||||
- `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님)
|
||||
|
||||
초기값 = E 생성 직후 마스터 기본값 + B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음.
|
||||
공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
from E02_StabGround import E02_StabGround_Model as model
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"])
|
||||
|
||||
|
||||
class GroundRequest(BaseModel):
|
||||
ground: dict[str, Any]
|
||||
|
||||
|
||||
async def _b06_stations(connection: aiomysql.Connection, project_ids: list[str]) -> list[dict]:
|
||||
"""측점 · 지반 라벨 · 토층 두께(암 측점 = 암 경계 깊이 · 토사 = 없음).
|
||||
|
||||
자기 복사본(532-1)이 먼저 · 없으면 원본 임도."""
|
||||
for project_id in project_ids:
|
||||
route = await get_latest_route(connection, UUID(project_id))
|
||||
if not route:
|
||||
continue
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT chainage_m, data FROM cross_sections WHERE route_id = %s"
|
||||
" ORDER BY chainage_m",
|
||||
(route["id"],),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
stations = []
|
||||
for chainage, data in rows:
|
||||
data = json.loads(data) if isinstance(data, str) else data or {}
|
||||
design = data.get("design") or {}
|
||||
ground_type = design.get("ground_type") or "soil"
|
||||
offset = design.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M)
|
||||
rock = ground_type in model.ROCK_LABELS and offset is not None
|
||||
stations.append(
|
||||
{
|
||||
"chainage_m": round(float(chainage), 3),
|
||||
"ground_type": ground_type,
|
||||
"soil_thickness_m": round(abs(float(offset)), 3) if rock else None,
|
||||
}
|
||||
)
|
||||
if stations:
|
||||
return stations
|
||||
return []
|
||||
|
||||
|
||||
async def _project(project_id: UUID) -> tuple[Path, dict[str, Any]]:
|
||||
async with get_db_pool().acquire() as connection:
|
||||
try:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") from error
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT company_id, project_kind, source_project_id FROM projects WHERE id = %s",
|
||||
(str(project_id),),
|
||||
)
|
||||
project = await cursor.fetchone()
|
||||
if not project or project["project_kind"] != "STABILITY":
|
||||
raise HTTPException(status_code=400, detail="안정성평가 프로젝트가 아닙니다.")
|
||||
return Path(resolve_stored_project_path(stored)), project
|
||||
|
||||
|
||||
async def ensure_initial(project_root: Path, project_id: str, source_id: str | None) -> dict:
|
||||
"""초기값이 없을 때만 한 번 — 기본값 + B06 측점(E 생성 직후 · 옛 E 프로젝트는 첫 GET)."""
|
||||
path = model.initial_path(project_root)
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
async with get_db_pool().acquire() as connection:
|
||||
ids = [project_id] + ([source_id] if source_id else [])
|
||||
stations = await _b06_stations(connection, ids)
|
||||
ground = model.build_initial(model.load_defaults(), stations)
|
||||
atomic_write_json(path, ground)
|
||||
return ground
|
||||
|
||||
|
||||
async def seed_initial(project_id: str) -> None:
|
||||
"""E01 생성 직후 부름 — 초기값 한 벌."""
|
||||
root, project = await _project(UUID(project_id))
|
||||
await ensure_initial(root, project_id, project["source_project_id"])
|
||||
|
||||
|
||||
def _read_working(project_root: Path) -> dict[str, Any] | None:
|
||||
path = model.ground_path(project_root)
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
|
||||
|
||||
|
||||
def _editable(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||
return session["role"] == "SYSTEM_ADMIN" or session.get("company_id") == project["company_id"]
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/ground")
|
||||
async def get_ground(
|
||||
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
root, project = await _project(project_id)
|
||||
initial = await ensure_initial(root, str(project_id), project["source_project_id"])
|
||||
working = _read_working(root)
|
||||
return {
|
||||
"ground": working or initial,
|
||||
"saved": working is not None,
|
||||
"defaults": model.load_defaults(),
|
||||
"editable": _editable(session, project),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{project_id}/stab/ground")
|
||||
async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]:
|
||||
root, project = await _project(project_id)
|
||||
defaults = model.load_defaults()
|
||||
errors = model.check_ground(payload.ground, defaults["limits"])
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail={"errors": errors})
|
||||
ground = model.clean(payload.ground)
|
||||
# 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음
|
||||
base = _read_working(root) or await ensure_initial(
|
||||
root, str(project_id), project["source_project_id"]
|
||||
)
|
||||
ground["stations"] = base.get("stations", [])
|
||||
ground["version"] = defaults["version"]
|
||||
atomic_write_json(model.ground_path(root), ground)
|
||||
return {"ground": ground}
|
||||
|
||||
|
||||
@router.post("/{project_id}/stab/ground/reset")
|
||||
async def reset_ground(project_id: UUID) -> dict[str, Any]:
|
||||
root, project = await _project(project_id)
|
||||
initial = await ensure_initial(root, str(project_id), project["source_project_id"])
|
||||
atomic_write_json(model.ground_path(root), initial)
|
||||
return {"ground": initial}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* =============================================================================
|
||||
* E02_StabGround_UI_Page.ts
|
||||
* E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow).
|
||||
* GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지.
|
||||
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) ·
|
||||
* [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝).
|
||||
* 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./E02_StabGround_UI_Style.css";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import { buildCards, type E02View } from "./E02_StabGround_UI_Tables";
|
||||
import {
|
||||
checkGround,
|
||||
type CheckError,
|
||||
type Ground,
|
||||
type GroundDefaults,
|
||||
} from "./logic/E02_StabGround_Check";
|
||||
|
||||
interface GroundResponse {
|
||||
ground: Ground;
|
||||
saved?: boolean;
|
||||
defaults?: GroundDefaults;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`;
|
||||
|
||||
/** 검사 열쇠 → 범위 열쇠(limits) */
|
||||
function limitKey(path: string): string {
|
||||
const last = path.split(".").pop() ?? "";
|
||||
if (path.startsWith("safety_factors.")) return "safety_factor";
|
||||
if (last === "from_m" || last === "to_m") return "chainage_m";
|
||||
return last;
|
||||
}
|
||||
|
||||
function errorText(error: CheckError, defaults: GroundDefaults): string {
|
||||
const text = t(`E02_Err_${error.code}` as LocaleKey);
|
||||
const bound = defaults.limits[limitKey(error.path)];
|
||||
return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text;
|
||||
}
|
||||
|
||||
async function request(path: string, init?: RequestInit): Promise<GroundResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown };
|
||||
if (!response.ok) {
|
||||
const detail = body.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
const errors = (detail as { errors?: CheckError[] } | undefined)?.errors;
|
||||
throw new Error(errors ? `${t("E02_Invalid")} ${errors.length}` : `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export function renderE02StabGround(root: HTMLElement): void {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const main = el("div", { className: "e02-page" });
|
||||
const status = el("p", { className: "e02-status" });
|
||||
const saveButton = createButton({
|
||||
label: t("Common_Btn_Save"),
|
||||
variant: "filled",
|
||||
onClick: () => void save(),
|
||||
});
|
||||
const resetButton = createButton({
|
||||
label: t("Common_Btn_Reset"),
|
||||
variant: "danger-ghost",
|
||||
onClick: () => void reset(),
|
||||
});
|
||||
saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(resetButton, saveButton);
|
||||
const panel = el("div", { className: "ui-sidebar-panel e02-panel" });
|
||||
panel.append(el("p", { className: "e02-hint", text: t("E02_Note") }), status, actions);
|
||||
mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel });
|
||||
if (!projectId)
|
||||
return void main.append(el("p", { className: "e02-empty", text: t("E02_NoProject") }));
|
||||
|
||||
let view: E02View | null = null;
|
||||
let refreshStations = (): void => {};
|
||||
let dirty = false;
|
||||
|
||||
const paintStatus = (errors: CheckError[]): void => {
|
||||
const lines: string[] = [];
|
||||
if (view && !view.editable) lines.push(t("E02_ReadOnly"));
|
||||
if (dirty) lines.push(t("E02_Unsaved"));
|
||||
if (errors.length) lines.push(`${t("E02_Invalid")} ${errors.length}`);
|
||||
status.textContent = lines.join("\n");
|
||||
status.classList.toggle("is-error", errors.length > 0);
|
||||
};
|
||||
|
||||
/** 틀린 칸 표시 — 칸마다 is-invalid + data-error(마우스 글) */
|
||||
const mark = (): CheckError[] => {
|
||||
if (!view) return [];
|
||||
const errors = checkGround(view.ground, view.defaults.limits);
|
||||
for (const node of main.querySelectorAll<HTMLElement>(".is-invalid")) {
|
||||
node.classList.remove("is-invalid");
|
||||
delete node.dataset.error;
|
||||
}
|
||||
for (const error of errors) {
|
||||
const node = main.querySelector<HTMLElement>(`[data-path="${error.path}"]`);
|
||||
if (!node) continue;
|
||||
node.classList.add("is-invalid");
|
||||
node.dataset.error = errorText(error, view.defaults);
|
||||
}
|
||||
paintStatus(errors);
|
||||
return errors;
|
||||
};
|
||||
|
||||
const render = (): void => {
|
||||
if (!view) return;
|
||||
const cards = buildCards(view);
|
||||
refreshStations = cards.refreshStations;
|
||||
main.replaceChildren(cards.root);
|
||||
mark();
|
||||
};
|
||||
|
||||
const remember = (): void => {
|
||||
if (!view) return;
|
||||
dirty = true;
|
||||
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.ground));
|
||||
};
|
||||
|
||||
const adopt = (body: GroundResponse, keepCache: boolean): void => {
|
||||
if (!view || !body.defaults) return;
|
||||
let ground = body.ground;
|
||||
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
|
||||
if (cached) ground = JSON.parse(cached) as Ground;
|
||||
else sessionStorage.removeItem(cacheKey(projectId));
|
||||
dirty = Boolean(cached);
|
||||
view.ground = ground;
|
||||
view.defaults = body.defaults;
|
||||
view.editable = body.editable ?? false;
|
||||
saveButton.disabled = resetButton.disabled = !view.editable;
|
||||
render();
|
||||
};
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!view?.editable) return;
|
||||
const errors = mark();
|
||||
if (errors.length) {
|
||||
showToast(
|
||||
`${t("E02_Invalid")} ${errors.length} — ${errorText(errors[0], view.defaults)}`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = await request(`/projects/${projectId}/stab/ground`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ground: view.ground }),
|
||||
});
|
||||
adopt({ ...body, defaults: view.defaults, editable: view.editable }, false);
|
||||
showToast(t("E02_Saved"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
if (!view?.editable) return;
|
||||
if (
|
||||
!(await showConfirmDialog(
|
||||
t("E02_ResetConfirm"),
|
||||
t("Common_Btn_Reset"),
|
||||
t("Common_Btn_Cancel"),
|
||||
))
|
||||
)
|
||||
return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = await request(`/projects/${projectId}/stab/ground/reset`, { method: "POST" });
|
||||
adopt({ ...body, defaults: view.defaults, editable: view.editable }, false);
|
||||
showToast(t("E02_ResetDone"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
attachHoverInfo(
|
||||
main,
|
||||
(target) => {
|
||||
if (!view) return null;
|
||||
const src = target.dataset.src;
|
||||
const source = src ? view.defaults.sources[src] : undefined;
|
||||
const userLayer = src?.startsWith("materials.") && !source;
|
||||
return [
|
||||
target.dataset.error,
|
||||
target.dataset.def !== undefined ? `${t("E02_DefaultIs")} ${target.dataset.def}` : "",
|
||||
source ?? (userLayer ? t("E02_UserLayer") : ""),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
},
|
||||
"[data-path]",
|
||||
);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const body = await request(`/projects/${projectId}/stab/ground`);
|
||||
view = {
|
||||
ground: body.ground,
|
||||
defaults: body.defaults as GroundDefaults,
|
||||
editable: false,
|
||||
change: () => {
|
||||
remember();
|
||||
refreshStations();
|
||||
mark();
|
||||
},
|
||||
rebuild: () => {
|
||||
remember();
|
||||
render();
|
||||
},
|
||||
};
|
||||
adopt(body, true);
|
||||
} catch (error) {
|
||||
main.replaceChildren(
|
||||
el("p", {
|
||||
className: "e02-empty",
|
||||
text: error instanceof Error ? error.message : t("Common_Status_Error"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/* =============================================================================
|
||||
* E02_StabGround_UI_Style.css
|
||||
* E02 지반 조건 본문 카드 · 표 · 입력 칸 — 색은 테마 토큰만(밝음 · 어두움 같이)
|
||||
* ========================================================================== */
|
||||
|
||||
.e02-page {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-16);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.e02-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(560px, 100%), 1fr));
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.e02-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.e02-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.e02-card__title,
|
||||
.e02-card__sub {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.e02-card__sub {
|
||||
font-size: var(--text-body-sm);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.e02-card__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.e02-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.e02-table th {
|
||||
padding: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e02-table td {
|
||||
padding: 2px var(--spacing-4);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.e02-table--read td {
|
||||
padding: 2px var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e02-table--read tr.is-zoned td {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e02-stations {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.e02-input {
|
||||
width: 100%;
|
||||
min-width: 64px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.e02-input--num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.e02-input--name {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.e02-input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 1px var(--color-danger);
|
||||
}
|
||||
|
||||
.e02-rowhead {
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e02-map,
|
||||
.e02-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.e02-map__title {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.e02-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e02-hint,
|
||||
.e02-empty {
|
||||
margin: 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e02-status {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
white-space: pre-line;
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.e02-status.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/* =============================================================================
|
||||
* E02_StabGround_UI_Tables.ts
|
||||
* E02 지반 조건 본문 카드 넷(요구 347-2 · 3 · 4) — 물성 표(지층 추가 · 지움 · B06 지반 연결) ·
|
||||
* 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지하수 · 지진. 카드마다 [기본값].
|
||||
* 칸 = data-path(검사 열쇠 · 파이썬과 같음) · data-src(근거 열쇠) · data-def(기본값 글).
|
||||
* 값 바꿈 = view.change()(캐시 · 검사) · 줄 더함 · 지움 · [기본값] = view.rebuild().
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
|
||||
import {
|
||||
CASES,
|
||||
LABELS,
|
||||
MATERIAL_FIELDS,
|
||||
SLOPES,
|
||||
WATER_GROUPS,
|
||||
WATER_KEYS,
|
||||
WATER_RULES,
|
||||
resolveStations,
|
||||
type Ground,
|
||||
type GroundDefaults,
|
||||
type Material,
|
||||
} from "./logic/E02_StabGround_Check";
|
||||
|
||||
export interface E02View {
|
||||
ground: Ground;
|
||||
defaults: GroundDefaults;
|
||||
editable: boolean;
|
||||
change: () => void;
|
||||
rebuild: () => void;
|
||||
}
|
||||
|
||||
const L = (key: string): string => t(key as LocaleKey);
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = {
|
||||
gamma_t: "E02_Col_GammaT",
|
||||
gamma_sat: "E02_Col_GammaSat",
|
||||
c: "E02_Col_C",
|
||||
phi: "E02_Col_Phi",
|
||||
};
|
||||
|
||||
/** 숫자 칸 — 빈 칸 = null(nullable) · 아니면 숫자 아님으로 검사에 걸림 */
|
||||
function numInput(
|
||||
view: E02View,
|
||||
path: string,
|
||||
value: number | null,
|
||||
set: (v: number | null) => void,
|
||||
meta: { src?: string; def?: number | null; nullable?: boolean } = {},
|
||||
): HTMLInputElement {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm e02-input e02-input--num",
|
||||
}) as HTMLInputElement;
|
||||
input.type = "number";
|
||||
input.step = "any";
|
||||
input.value = value === null || value === undefined ? "" : String(value);
|
||||
input.dataset.path = path;
|
||||
if (meta.src) input.dataset.src = meta.src;
|
||||
if (meta.def !== undefined && meta.def !== null) input.dataset.def = String(meta.def);
|
||||
if (meta.nullable) input.placeholder = t("E02_Zone_Keep");
|
||||
input.disabled = !view.editable;
|
||||
input.addEventListener("input", () => {
|
||||
const raw = input.value.trim();
|
||||
set(raw === "" ? (meta.nullable ? null : Number.NaN) : Number(raw));
|
||||
view.change();
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
function selectInput(
|
||||
view: E02View,
|
||||
path: string,
|
||||
options: { value: string; text: string }[],
|
||||
value: string | null,
|
||||
set: (v: string) => void,
|
||||
meta: { src?: string; def?: string } = {},
|
||||
): HTMLSelectElement {
|
||||
const select = el("select", {
|
||||
className: "ui-select ui-select--sm e02-input",
|
||||
}) as HTMLSelectElement;
|
||||
for (const option of options) select.append(new Option(option.text, option.value));
|
||||
select.value = value ?? "";
|
||||
select.dataset.path = path;
|
||||
if (meta.src) select.dataset.src = meta.src;
|
||||
if (meta.def) select.dataset.def = meta.def;
|
||||
select.disabled = !view.editable;
|
||||
select.addEventListener("change", () => {
|
||||
set(select.value);
|
||||
view.change();
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
function card(
|
||||
title: LocaleKey,
|
||||
...body: HTMLElement[]
|
||||
): { section: HTMLElement; head: HTMLElement } {
|
||||
const head = el("div", { className: "e02-card__head" });
|
||||
head.append(el("h3", { className: "e02-card__title", text: t(title) }));
|
||||
const section = el("section", { className: "e02-card ui-sidebar-section" });
|
||||
section.append(head, ...body);
|
||||
return { section, head };
|
||||
}
|
||||
|
||||
function headButtons(view: E02View, head: HTMLElement, ...buttons: HTMLButtonElement[]): void {
|
||||
if (!view.editable) return;
|
||||
const row = el("div", { className: "e02-card__actions" });
|
||||
row.append(...buttons);
|
||||
head.append(row);
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement {
|
||||
const node = el("table", { className: "e02-table" }) as HTMLTableElement;
|
||||
const tr = node.createTHead().insertRow();
|
||||
for (const text of headers) tr.append(el("th", { text }));
|
||||
const body = node.createTBody();
|
||||
for (const cells of rows) {
|
||||
const row = body.insertRow();
|
||||
for (const cell of cells) row.insertCell().append(cell);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function defaultMaterial(view: E02View, id: string): Material | undefined {
|
||||
return view.defaults.materials.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
function layerOptions(ground: Ground, blank?: string): { value: string; text: string }[] {
|
||||
const list = ground.materials.map((m) => ({ value: m.id, text: m.name || m.id }));
|
||||
return blank === undefined ? list : [{ value: "", text: blank }, ...list];
|
||||
}
|
||||
|
||||
/* --- 1. 지반물성치 --- */
|
||||
function materialsCard(view: E02View): HTMLElement {
|
||||
const g = view.ground;
|
||||
const used = (id: string): boolean =>
|
||||
Object.values(g.label_map).includes(id) || g.zones.some((z) => z.soil === id || z.rock === id);
|
||||
const rows = g.materials.map((m, i) => {
|
||||
const base = `materials.${i}`;
|
||||
const name = el("input", {
|
||||
className: "ui-input ui-input--sm e02-input e02-input--name",
|
||||
}) as HTMLInputElement;
|
||||
name.value = m.name;
|
||||
name.dataset.path = `${base}.name`;
|
||||
name.disabled = !view.editable;
|
||||
name.addEventListener("input", () => {
|
||||
m.name = name.value;
|
||||
view.change();
|
||||
});
|
||||
const cells: HTMLElement[] = [name];
|
||||
for (const field of MATERIAL_FIELDS)
|
||||
cells.push(
|
||||
numInput(view, `${base}.${field}`, m[field], (v) => (m[field] = v as number), {
|
||||
src: `materials.${m.id}.${field}`,
|
||||
def: defaultMaterial(view, m.id)?.[field] ?? null,
|
||||
}),
|
||||
);
|
||||
cells.push(
|
||||
selectInput(
|
||||
view,
|
||||
`${base}.water`,
|
||||
WATER_GROUPS.map((w) => ({
|
||||
value: w,
|
||||
text: L(w === "soil" ? "E02_Water_Soil" : "E02_Water_Rock"),
|
||||
})),
|
||||
m.water,
|
||||
(v) => (m.water = v as Material["water"]),
|
||||
),
|
||||
);
|
||||
const remove = createButton({
|
||||
label: t("E02_Btn_Remove"),
|
||||
variant: "danger-ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
if (used(m.id)) return void showToast(t("E02_InUse"), "warning");
|
||||
g.materials.splice(i, 1);
|
||||
view.rebuild();
|
||||
},
|
||||
});
|
||||
remove.hidden = !view.editable;
|
||||
cells.push(remove);
|
||||
return cells;
|
||||
});
|
||||
const headers = [
|
||||
t("E02_Col_Name"),
|
||||
...MATERIAL_FIELDS.map((f) => t(FIELD_LABEL[f])),
|
||||
t("E02_Col_Water"),
|
||||
"",
|
||||
];
|
||||
|
||||
const mapRow = el("div", { className: "e02-map" });
|
||||
mapRow.append(el("span", { className: "e02-map__title", text: t("E02_LabelMap") }));
|
||||
for (const label of LABELS) {
|
||||
const field = el("label", { className: "e02-field" });
|
||||
field.append(
|
||||
el("span", { text: L(`E02_Label_${label}`) }),
|
||||
selectInput(view, `label_map.${label}`, layerOptions(g), g.label_map[label], (v) => {
|
||||
g.label_map[label] = v;
|
||||
view.rebuild();
|
||||
}),
|
||||
);
|
||||
mapRow.append(field);
|
||||
}
|
||||
|
||||
const { section, head } = card("E02_Card_Materials", table(headers, rows), mapRow);
|
||||
headButtons(
|
||||
view,
|
||||
head,
|
||||
createButton({
|
||||
label: t("E02_Btn_AddLayer"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
let n = 1;
|
||||
while (g.materials.some((m) => m.name === `${t("E02_NewLayer")} ${n}`)) n += 1;
|
||||
let id = `user_${n}`;
|
||||
for (let k = n; g.materials.some((m) => m.id === id); k += 1) id = `user_${k}`;
|
||||
const seed = view.defaults.materials[0];
|
||||
g.materials.push({ ...seed, id, name: `${t("E02_NewLayer")} ${n}` });
|
||||
view.rebuild();
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: t("E02_Btn_Defaults"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
g.materials = clone(view.defaults.materials);
|
||||
g.label_map = clone(view.defaults.label_map);
|
||||
const ids = new Set(g.materials.map((m) => m.id));
|
||||
for (const zone of g.zones) {
|
||||
if (zone.soil !== null && !ids.has(zone.soil)) zone.soil = null;
|
||||
if (zone.rock !== null && !ids.has(zone.rock)) zone.rock = null;
|
||||
}
|
||||
view.rebuild();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
/* --- 2. 층 두께 · 구간 덮어쓰기 + 측점별 적용 --- */
|
||||
function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void } {
|
||||
const g = view.ground;
|
||||
const rows = g.zones.map((z, i) => {
|
||||
const base = `zones.${i}`;
|
||||
const remove = createButton({
|
||||
label: t("E02_Btn_Remove"),
|
||||
variant: "danger-ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
g.zones.splice(i, 1);
|
||||
view.rebuild();
|
||||
},
|
||||
});
|
||||
remove.hidden = !view.editable;
|
||||
const layer = (key: "soil" | "rock"): HTMLSelectElement =>
|
||||
selectInput(view, `${base}.${key}`, layerOptions(g, t("E02_Zone_Keep")), z[key], (v) => {
|
||||
z[key] = v || null;
|
||||
});
|
||||
return [
|
||||
numInput(view, `${base}.from_m`, z.from_m, (v) => (z.from_m = v as number)),
|
||||
numInput(view, `${base}.to_m`, z.to_m, (v) => (z.to_m = v as number)),
|
||||
layer("soil"),
|
||||
layer("rock"),
|
||||
numInput(
|
||||
view,
|
||||
`${base}.soil_thickness_m`,
|
||||
z.soil_thickness_m,
|
||||
(v) => (z.soil_thickness_m = v),
|
||||
{
|
||||
nullable: true,
|
||||
src: "stations",
|
||||
},
|
||||
),
|
||||
remove,
|
||||
];
|
||||
});
|
||||
const zoneBody = rows.length
|
||||
? table(
|
||||
[
|
||||
t("E02_Col_From"),
|
||||
t("E02_Col_To"),
|
||||
t("E02_Col_Upper"),
|
||||
t("E02_Col_Lower"),
|
||||
t("E02_Col_Thickness"),
|
||||
"",
|
||||
],
|
||||
rows,
|
||||
)
|
||||
: el("p", { className: "e02-empty", text: t("E02_Zone_Empty") });
|
||||
|
||||
const stationsHost = el("div", { className: "e02-stations" });
|
||||
const refresh = (): void => {
|
||||
stationsHost.replaceChildren();
|
||||
if (!g.stations.length)
|
||||
return void stationsHost.append(
|
||||
el("p", { className: "e02-empty", text: t("E02_Stations_Empty") }),
|
||||
);
|
||||
const names = new Map(g.materials.map((m) => [m.id, m.name]));
|
||||
const interval = inferStationInterval(g.stations);
|
||||
const fmt = (v: number | null): string => (v === null ? "-" : v.toFixed(2));
|
||||
const resolved = resolveStations(g);
|
||||
const node = table(
|
||||
[
|
||||
t("E02_Col_Station"),
|
||||
t("E02_Col_GroundType"),
|
||||
t("E02_Col_B06Thickness"),
|
||||
t("E02_Col_Upper"),
|
||||
t("E02_Col_Lower"),
|
||||
t("E02_Col_Thickness"),
|
||||
],
|
||||
resolved.map((r, i) => [
|
||||
el("span", { text: stationLabel(r.chainage_m, interval) }),
|
||||
el("span", { text: L(`E02_Ground_${r.ground_type}`) }),
|
||||
el("span", { text: fmt(g.stations[i].soil_thickness_m) }),
|
||||
el("span", { text: (r.upper && names.get(r.upper)) || "-" }),
|
||||
el("span", { text: r.lower ? (names.get(r.lower) ?? "-") : t("E02_AllUpper") }),
|
||||
el("span", { text: fmt(r.soil_thickness_m) }),
|
||||
]),
|
||||
);
|
||||
resolved.forEach((r, i) => node.tBodies[0].rows[i].classList.toggle("is-zoned", r.zoned));
|
||||
node.classList.add("e02-table--read");
|
||||
stationsHost.append(node);
|
||||
};
|
||||
refresh();
|
||||
|
||||
const { section, head } = card(
|
||||
"E02_Card_Zones",
|
||||
el("p", { className: "e02-hint", text: t("E02_Zone_Hint") }),
|
||||
zoneBody,
|
||||
el("h4", { className: "e02-card__sub", text: t("E02_Card_Stations") }),
|
||||
stationsHost,
|
||||
);
|
||||
const first = g.stations[0]?.chainage_m ?? 0;
|
||||
const last = g.stations[g.stations.length - 1]?.chainage_m ?? 100;
|
||||
headButtons(
|
||||
view,
|
||||
head,
|
||||
createButton({
|
||||
label: t("E02_Btn_AddZone"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
g.zones.push({
|
||||
from_m: first,
|
||||
to_m: last > first ? last : first + 20,
|
||||
soil: null,
|
||||
rock: null,
|
||||
soil_thickness_m: null,
|
||||
});
|
||||
view.rebuild();
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: t("E02_Btn_Defaults"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
g.zones = clone(view.defaults.zones);
|
||||
view.rebuild();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { section, refresh };
|
||||
}
|
||||
|
||||
/* --- 3. 기준안전율 --- */
|
||||
function safetyCard(view: E02View): HTMLElement {
|
||||
const g = view.ground;
|
||||
const rows = CASES.map((c) => [
|
||||
el("span", { className: "e02-rowhead", text: L(`E02_Case_${c}`) }),
|
||||
...SLOPES.map((s) =>
|
||||
numInput(
|
||||
view,
|
||||
`safety_factors.${c}.${s}`,
|
||||
g.safety_factors[c][s],
|
||||
(v) => (g.safety_factors[c][s] = v as number),
|
||||
{
|
||||
src: `safety_factors.${c}`,
|
||||
def: view.defaults.safety_factors[c][s],
|
||||
},
|
||||
),
|
||||
),
|
||||
]);
|
||||
const { section, head } = card(
|
||||
"E02_Card_Safety",
|
||||
table(["", ...SLOPES.map((s) => L(`E02_Slope_${s}`))], rows),
|
||||
);
|
||||
headButtons(
|
||||
view,
|
||||
head,
|
||||
createButton({
|
||||
label: t("E02_Btn_Defaults"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
g.safety_factors = clone(view.defaults.safety_factors);
|
||||
view.rebuild();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
/* --- 4. 노면 하중 · 지하수 · 지진 --- */
|
||||
function loadsCard(view: E02View): HTMLElement {
|
||||
const g = view.ground;
|
||||
const d = view.defaults;
|
||||
const field = (label: string, input: HTMLElement): HTMLElement => {
|
||||
const node = el("label", { className: "e02-field" });
|
||||
node.append(el("span", { text: label }), input);
|
||||
return node;
|
||||
};
|
||||
const kh = numInput(view, "seismic.kh", g.seismic.kh, (v) => (g.seismic.kh = v as number), {
|
||||
src: "seismic.kh",
|
||||
def: d.seismic.kh,
|
||||
});
|
||||
kh.disabled = !view.editable || !g.seismic.enabled;
|
||||
const seismic = createCheckbox(t("E02_Seismic"), { checked: g.seismic.enabled });
|
||||
seismic.input.dataset.path = "seismic.enabled";
|
||||
seismic.input.dataset.src = "seismic.enabled";
|
||||
seismic.input.dataset.def = d.seismic.enabled ? "on" : "off";
|
||||
seismic.input.disabled = !view.editable;
|
||||
seismic.input.addEventListener("change", () => {
|
||||
g.seismic.enabled = seismic.input.checked;
|
||||
kh.disabled = !view.editable || !g.seismic.enabled;
|
||||
view.change();
|
||||
});
|
||||
const water = WATER_KEYS.map((key) =>
|
||||
field(
|
||||
L(`E02_Water_${key}`),
|
||||
selectInput(
|
||||
view,
|
||||
`groundwater.${key}`,
|
||||
WATER_RULES.map((r) => ({ value: r, text: L(`E02_Rule_${r}`) })),
|
||||
g.groundwater[key],
|
||||
(v) => (g.groundwater[key] = v as (typeof WATER_RULES)[number]),
|
||||
{ src: `groundwater.${key}`, def: L(`E02_Rule_${d.groundwater[key]}`) },
|
||||
),
|
||||
),
|
||||
);
|
||||
const grid = el("div", { className: "e02-grid" });
|
||||
grid.append(
|
||||
field(
|
||||
t("E02_Surcharge"),
|
||||
numInput(
|
||||
view,
|
||||
"road_surcharge_kpa",
|
||||
g.road_surcharge_kpa,
|
||||
(v) => (g.road_surcharge_kpa = v as number),
|
||||
{
|
||||
src: "road_surcharge_kpa",
|
||||
def: d.road_surcharge_kpa,
|
||||
},
|
||||
),
|
||||
),
|
||||
...water,
|
||||
seismic.root,
|
||||
field(t("E02_Kh"), kh),
|
||||
);
|
||||
const { section, head } = card("E02_Card_Loads", grid);
|
||||
headButtons(
|
||||
view,
|
||||
head,
|
||||
createButton({
|
||||
label: t("E02_Btn_Defaults"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
g.road_surcharge_kpa = d.road_surcharge_kpa;
|
||||
g.groundwater = clone(d.groundwater);
|
||||
g.seismic = clone(d.seismic);
|
||||
view.rebuild();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
/** 본문 전체 — 돌려준 refreshStations = 값만 바뀐 뒤 측점별 적용 표를 다시 그림 */
|
||||
export function buildCards(view: E02View): { root: HTMLElement; refreshStations: () => void } {
|
||||
const root = el("div", { className: "e02-cards" });
|
||||
const zones = zonesCard(view);
|
||||
root.append(materialsCard(view), zones.section, safetyCard(view), loadsCard(view));
|
||||
return { root, refreshStations: zones.refresh };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* E02_StabGround_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* 길 선언(PLAN 529-1 · 532-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E02 지반 조건 페이지
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
@@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = {
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E02_STAB_GROUND),
|
||||
load: async () => (await import("../E02_StabGround_UI_Page")).renderE02StabGround,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/* =============================================================================
|
||||
* E02_StabGround_Check.ts
|
||||
* E02 지반 조건 꼴 · 입력 검사 · 측점 적용(요구 347-2 · 3 · 4 · PLAN 532-3) — 순수 함수만.
|
||||
*
|
||||
* ⚠ 파이썬·TS 짝 — `checkGround` ↔ `E02_StabGround_Model.py` `check_ground` ·
|
||||
* `resolveStations` ↔ `resolve_stations`. 한쪽을 고치면 다른 쪽도 · 거울 시험
|
||||
* `resources/tester/test_532_3_e02_ground.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
export type WaterGroup = "soil" | "rock";
|
||||
export type WaterRule = "none" | "surface" | "half_height";
|
||||
|
||||
/** 물성 한 줄 = E03_StabSection_Types.Material + 지하수 무리 */
|
||||
export interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
gamma_t: number;
|
||||
gamma_sat: number;
|
||||
c: number;
|
||||
phi: number;
|
||||
water: WaterGroup;
|
||||
}
|
||||
|
||||
export interface Station {
|
||||
chainage_m: number;
|
||||
ground_type: string;
|
||||
soil_thickness_m: number | null;
|
||||
}
|
||||
|
||||
export interface Zone {
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
soil: string | null;
|
||||
rock: string | null;
|
||||
soil_thickness_m: number | null;
|
||||
}
|
||||
|
||||
export type SlopeFactors = Record<(typeof SLOPES)[number], number>;
|
||||
|
||||
export interface Ground {
|
||||
version: number;
|
||||
materials: Material[];
|
||||
label_map: Record<(typeof LABELS)[number], string>;
|
||||
stations: Station[];
|
||||
zones: Zone[];
|
||||
safety_factors: Record<(typeof CASES)[number], SlopeFactors>;
|
||||
road_surcharge_kpa: number;
|
||||
groundwater: Record<(typeof WATER_KEYS)[number], WaterRule>;
|
||||
seismic: { enabled: boolean; kh: number };
|
||||
}
|
||||
|
||||
export type Limits = Record<string, [number, number]>;
|
||||
|
||||
export interface GroundDefaults extends Ground {
|
||||
note: string;
|
||||
limits: Limits;
|
||||
sources: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CheckError {
|
||||
path: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ResolvedStation {
|
||||
chainage_m: number;
|
||||
ground_type: string;
|
||||
upper: string | null;
|
||||
lower: string | null;
|
||||
soil_thickness_m: number | null;
|
||||
zoned: boolean;
|
||||
}
|
||||
|
||||
export const MATERIAL_FIELDS = ["gamma_t", "gamma_sat", "c", "phi"] as const;
|
||||
export const WATER_GROUPS: readonly WaterGroup[] = ["soil", "rock"];
|
||||
export const LABELS = ["soil", "ripping_rock", "blasting_rock", "fill"] as const;
|
||||
const ROCK_LABELS = ["ripping_rock", "blasting_rock"];
|
||||
export const CASES = ["dry", "wet", "seismic", "short_term"] as const;
|
||||
export const SLOPES = ["cut", "fill", "soft"] as const;
|
||||
export const WATER_KEYS = ["dry", "wet_soil", "wet_rock"] as const;
|
||||
export const WATER_RULES: readonly WaterRule[] = ["none", "surface", "half_height"];
|
||||
|
||||
type Loose = Record<string, unknown>;
|
||||
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
const asList = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
|
||||
const isDict = (v: unknown): v is Loose => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const asDict = (v: unknown): Loose => (isDict(v) ? v : {});
|
||||
|
||||
/** 틀린 칸 목록 — 빈 목록 = 통과(code 는 파이썬과 같은 낱말) */
|
||||
export function checkGround(ground: unknown, limits: Limits): CheckError[] {
|
||||
const errors: CheckError[] = [];
|
||||
const bad = (path: string, code: string): void => void errors.push({ path, code });
|
||||
const ranged = (path: string, value: unknown, key: string): void => {
|
||||
const [low, high] = limits[key];
|
||||
if (!isNum(value)) bad(path, "number");
|
||||
else if (!(low <= value && value <= high)) bad(path, "range");
|
||||
};
|
||||
const doc = asDict(ground);
|
||||
|
||||
const materials = asList(doc.materials);
|
||||
if (!materials.length) bad("materials", "empty");
|
||||
const ids: string[] = [];
|
||||
const names: string[] = [];
|
||||
materials.forEach((item, i) => {
|
||||
const base = `materials.${i}`;
|
||||
if (!isDict(item)) return bad(base, "type");
|
||||
const mid = item.id;
|
||||
if (typeof mid !== "string" || !mid || ids.includes(mid)) bad(`${base}.id`, "id_dup");
|
||||
ids.push(typeof mid === "string" ? mid : "");
|
||||
const name = typeof item.name === "string" ? item.name.trim() : "";
|
||||
const [low, high] = limits.name_length;
|
||||
if (!(low <= name.length && name.length <= high)) bad(`${base}.name`, "name");
|
||||
else if (names.includes(name)) bad(`${base}.name`, "name_dup");
|
||||
names.push(name);
|
||||
for (const field of MATERIAL_FIELDS) ranged(`${base}.${field}`, item[field], field);
|
||||
if (isNum(item.gamma_t) && isNum(item.gamma_sat) && item.gamma_sat < item.gamma_t)
|
||||
bad(`${base}.gamma_sat`, "sat_lt_t");
|
||||
if (!WATER_GROUPS.includes(item.water as WaterGroup)) bad(`${base}.water`, "enum");
|
||||
});
|
||||
|
||||
const labelMap = asDict(doc.label_map);
|
||||
for (const label of LABELS) {
|
||||
const ref = labelMap[label];
|
||||
if (!ref || !ids.includes(ref as string)) bad(`label_map.${label}`, "ref");
|
||||
}
|
||||
|
||||
asList(doc.zones).forEach((zone, i) => {
|
||||
const base = `zones.${i}`;
|
||||
if (!isDict(zone)) return bad(base, "type");
|
||||
ranged(`${base}.from_m`, zone.from_m, "chainage_m");
|
||||
ranged(`${base}.to_m`, zone.to_m, "chainage_m");
|
||||
if (isNum(zone.from_m) && isNum(zone.to_m) && zone.from_m >= zone.to_m)
|
||||
bad(`${base}.to_m`, "order");
|
||||
for (const layer of ["soil", "rock"]) {
|
||||
const ref = zone[layer];
|
||||
if (ref !== null && ref !== undefined && !ids.includes(ref as string))
|
||||
bad(`${base}.${layer}`, "ref");
|
||||
}
|
||||
if (zone.soil_thickness_m !== null && zone.soil_thickness_m !== undefined)
|
||||
ranged(`${base}.soil_thickness_m`, zone.soil_thickness_m, "soil_thickness_m");
|
||||
});
|
||||
|
||||
const factors = asDict(doc.safety_factors);
|
||||
for (const c of CASES)
|
||||
for (const s of SLOPES)
|
||||
ranged(`safety_factors.${c}.${s}`, asDict(factors[c])[s], "safety_factor");
|
||||
|
||||
ranged("road_surcharge_kpa", doc.road_surcharge_kpa, "road_surcharge_kpa");
|
||||
const water = asDict(doc.groundwater);
|
||||
for (const key of WATER_KEYS)
|
||||
if (!WATER_RULES.includes(water[key] as WaterRule)) bad(`groundwater.${key}`, "enum");
|
||||
const seismic = asDict(doc.seismic);
|
||||
if (typeof seismic.enabled !== "boolean") bad("seismic.enabled", "enum");
|
||||
ranged("seismic.kh", seismic.kh, "kh");
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 측점마다 적용 지층 — 위 = 토사 연결 · 아래 = 암 측점의 암 연결 · 두께 = B06 · 구간이 덮어씀
|
||||
* (뒤 구간이 이김 · 양 끝 포함 · 빈 칸은 안 바꿈) · 두께가 없으면 아래 지층 없음 */
|
||||
export function resolveStations(ground: Ground): ResolvedStation[] {
|
||||
return ground.stations.map((station) => {
|
||||
const chainage = station.chainage_m;
|
||||
let upper: string | null = ground.label_map.soil ?? null;
|
||||
let lower: string | null = ROCK_LABELS.includes(station.ground_type)
|
||||
? (ground.label_map[station.ground_type as (typeof LABELS)[number]] ?? null)
|
||||
: null;
|
||||
let thickness = station.soil_thickness_m;
|
||||
let zoned = false;
|
||||
for (const zone of ground.zones) {
|
||||
if (!(zone.from_m <= chainage && chainage <= zone.to_m)) continue;
|
||||
zoned = true;
|
||||
if (zone.soil != null) upper = zone.soil;
|
||||
if (zone.rock != null) lower = zone.rock;
|
||||
if (zone.soil_thickness_m != null) thickness = zone.soil_thickness_m;
|
||||
}
|
||||
if (thickness === null) lower = null; // 경계 없음 = 전부 위 지층
|
||||
return {
|
||||
chainage_m: chainage,
|
||||
ground_type: station.ground_type,
|
||||
upper,
|
||||
lower,
|
||||
soil_thickness_m: lower !== null ? thickness : null,
|
||||
zoned,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -76,6 +76,7 @@ from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_das
|
||||
from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router
|
||||
from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router
|
||||
from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router
|
||||
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
|
||||
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
|
||||
@@ -478,6 +479,7 @@ app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company)
|
||||
app.include_router(d03_plan_analysis_router, dependencies=protected_with_company)
|
||||
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_corridor_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_terrain_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "문헌 추정치 · 현장 시험 없음 · 값은 사용자가 바꿀 수 있음(E02 지반 조건)",
|
||||
"materials": [
|
||||
{ "id": "weathered_soil", "name": "풍화토", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 15.0, "phi": 30.0, "water": "soil" },
|
||||
{ "id": "weathered_rock", "name": "풍화암", "gamma_t": 20.0, "gamma_sat": 21.0, "c": 30.0, "phi": 33.0, "water": "soil" },
|
||||
{ "id": "soft_rock", "name": "연암", "gamma_t": 24.0, "gamma_sat": 24.5, "c": 150.0, "phi": 35.0, "water": "rock" },
|
||||
{ "id": "hard_rock", "name": "경암", "gamma_t": 26.0, "gamma_sat": 26.0, "c": 300.0, "phi": 40.0, "water": "rock" },
|
||||
{ "id": "fill", "name": "성토", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 10.0, "phi": 28.0, "water": "soil" },
|
||||
{ "id": "colluvium", "name": "붕적토", "gamma_t": 17.5, "gamma_sat": 18.5, "c": 5.0, "phi": 27.0, "water": "soil" }
|
||||
],
|
||||
"label_map": { "soil": "weathered_soil", "ripping_rock": "weathered_rock", "blasting_rock": "soft_rock", "fill": "fill" },
|
||||
"stations": [],
|
||||
"zones": [],
|
||||
"safety_factors": {
|
||||
"dry": { "cut": 1.5, "fill": 1.5, "soft": 1.3 },
|
||||
"wet": { "cut": 1.2, "fill": 1.3, "soft": 1.2 },
|
||||
"seismic": { "cut": 1.1, "fill": 1.1, "soft": 1.1 },
|
||||
"short_term": { "cut": 1.1, "fill": 1.1, "soft": 1.1 }
|
||||
},
|
||||
"road_surcharge_kpa": 10.0,
|
||||
"groundwater": { "dry": "none", "wet_soil": "surface", "wet_rock": "half_height" },
|
||||
"seismic": { "enabled": false, "kh": 0.0 },
|
||||
"limits": {
|
||||
"gamma_t": [10.0, 30.0],
|
||||
"gamma_sat": [10.0, 30.0],
|
||||
"c": [0.0, 1000.0],
|
||||
"phi": [0.0, 60.0],
|
||||
"safety_factor": [1.0, 3.0],
|
||||
"road_surcharge_kpa": [0.0, 100.0],
|
||||
"kh": [0.0, 0.5],
|
||||
"soil_thickness_m": [0.0, 50.0],
|
||||
"chainage_m": [0.0, 100000.0],
|
||||
"name_length": [1, 30]
|
||||
},
|
||||
"sources": {
|
||||
"materials.weathered_soil.gamma_t": "07 §3.3(1) 서울시 「지반조사편람」(2006) 풍화토 17~20 · §3.2(2) 「도로교시방서」 표 〈N값과 단위중량〉 사질토 N 10~30 = 18 · §4 종합 18~19",
|
||||
"materials.weathered_soil.gamma_sat": "07 §3.2(2) 「도로교시방서」 γsat ≈ γ′ + 10 · N 10~30 = 18~19 · §4 종합 19~20",
|
||||
"materials.weathered_soil.c": "07 §3.3(1) 「지반조사편람」 풍화토 비탈면 실무 5~20 kPa · §4 종합 5~20",
|
||||
"materials.weathered_soil.phi": "07 §3.3(1) 「지반조사편람」 풍화토 25~30°(조밀 32°) · §4 종합 27~32",
|
||||
"materials.weathered_rock.gamma_t": "07 §3.3(2) 한국지반공학회 사면안정 학술발표회(1996~2003) 풍화암 19.5~21.0 · §4 종합",
|
||||
"materials.weathered_rock.gamma_sat": "07 §4 종합 풍화암 20.5~22.0",
|
||||
"materials.weathered_rock.c": "07 §3.3(2) 사면안정 학술발표회 비탈면 해석용 풍화암 10~30 kPa(1996 · 2001 = 30)",
|
||||
"materials.weathered_rock.phi": "07 §3.3(2) 사면안정 학술발표회 풍화암 30~36° · §4 종합 30~36",
|
||||
"materials.soft_rock.gamma_t": "07 §4 종합 연암 22~24(범위 20~25) · §3.4(3) Hoek & Bray 연질 퇴적암 실무 22~24",
|
||||
"materials.soft_rock.gamma_sat": "07 §4 종합 연암 23~25",
|
||||
"materials.soft_rock.c": "07 §3.4(1) 한국도로공사 「도로설계실무편람」(1996) 연암 · 보통암(중간 파쇄) 150 kPa",
|
||||
"materials.soft_rock.phi": "07 §3.4(1) 「도로설계실무편람」 연암 · 보통암(중간 파쇄) 35°",
|
||||
"materials.hard_rock.gamma_t": "07 §3.4(3) Hoek & Bray 화성암 · 변성암 실무 25~27 · §4 종합 25~27",
|
||||
"materials.hard_rock.gamma_sat": "07 §4 종합 경암 25.5~27.5",
|
||||
"materials.hard_rock.c": "07 §3.4(2) 「건설공사 비탈면 설계기준 해설」 Bieniawski RMR Ⅱ등급 300~400 kPa · §3.4(1) 도로설계실무편람 양호 경암 200 이상",
|
||||
"materials.hard_rock.phi": "07 §3.4(1) 「도로설계실무편람」 양호한 연암 · 경암 40° · §4 종합 40~45",
|
||||
"materials.fill.gamma_t": "07 §4 종합 성토 18~19 · §3.1 「도로설계요령」 표 〈토사의 개략적인 토질 정수〉 밀실 사질토 19",
|
||||
"materials.fill.gamma_sat": "07 §4 종합 성토 19~20",
|
||||
"materials.fill.c": "07 §4 종합 성토 0~15 kPa(비탈면 실무 5~15 보수 적용)",
|
||||
"materials.fill.phi": "07 §4 종합 성토 28~30 · §3.1 「도로설계요령」 밀실 사질토 30° — 아래값 28",
|
||||
"materials.colluvium.gamma_t": "07 §3.1 「도로설계요령」 밀실치 않은 사질토(붕적토) 17 · §3.2(2) 「도로교시방서」 N < 10 = 17 · §4 종합 17~18",
|
||||
"materials.colluvium.gamma_sat": "07 §3.2(2) 「도로교시방서」 N < 10 γsat 17~18 · §4 종합 18~19",
|
||||
"materials.colluvium.c": "07 §3.3(1) 「지반조사편람」 퇴적토 · 붕적토 0~15 · §4 종합 0~10",
|
||||
"materials.colluvium.phi": "07 §3.1 「도로설계요령」 붕적토 25° · §3.3(1) 25~30 · §4 종합 25~28",
|
||||
"safety_factors.dry": "KDS 11 70 05 표 4.3-1(쌓기 일반) · 4.3-2(연약지반 쌓기) · 4.3-3(깎기) 건기",
|
||||
"safety_factors.wet": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 우기 — 깎기 1.2 = 지하수위 결정 방식(강우침투 해석이면 1.3)",
|
||||
"safety_factors.seismic": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 지진 시",
|
||||
"safety_factors.short_term": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 단기(1년 미만 · 시공 중)",
|
||||
"road_surcharge_kpa": "옹벽 배면 상재하중 10 kN/㎡ 준용(지식DB technical_info 구조물_안정계산 · KDS 11 80 05) · 임도 차량하중 고유 근거 없음",
|
||||
"groundwater.dry": "KDS 11 70 05 표 4.3-3 건기 = 지하수 없음",
|
||||
"groundwater.wet_soil": "KDS 11 70 05 표 4.3-3 우기 토층 · 풍화암 = 지하수위 결정(지표 포화 가정)",
|
||||
"groundwater.wet_rock": "KDS 11 70 05 표 4.3-3 우기 연암 · 경암 = 비탈면 높이 ½ 심도까지 포화",
|
||||
"seismic.enabled": "KDS 11 90 00 1.8.1 ② 영향 범위에 주구조물 없으면 비적용 · 임도는 발주자 협의 — 기본 끔",
|
||||
"seismic.kh": "유사정적 수평지진계수 — 지반가속도의 비율로 사용자 입력(KDS 11 70 05 표 4.3 지진 시)",
|
||||
"stations": "B06 횡단 암 경계(지반선 평행 · 기본 −0.5 m · 물량 가름용 값) — 토사 측점은 경계 없음"
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,12 @@ def _e01(monkeypatch, rows):
|
||||
return "stab-id"
|
||||
|
||||
monkeypatch.setattr(e01, "_sources", fake_sources)
|
||||
|
||||
async def fake_seed(project_id):
|
||||
seen["seeded"] = project_id
|
||||
|
||||
monkeypatch.setattr(e01, "create_kind_project", fake_create)
|
||||
monkeypatch.setattr(e01, "seed_initial", fake_seed)
|
||||
payload = e01.CreateStabRequest(source_project_id="s" * 36)
|
||||
return seen, lambda: asyncio.run(e01.post_stab_project(None, payload, USER))
|
||||
|
||||
@@ -157,6 +162,7 @@ def test_e01_requires_b06(monkeypatch):
|
||||
assert call() == {"project_id": "stab-id"}
|
||||
assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36
|
||||
assert seen["name"] == "임도 안정성평가"
|
||||
assert seen["seeded"] == "stab-id" # E02 초기값(532-3)
|
||||
|
||||
|
||||
def _access(monkeypatch, method, assigned, owner_company=2):
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""532-3 E02 지반 조건 — 기본값 json 꼴 = E03 Types · KDS 표 · 검사 · 측점 적용 거울(파이썬 ↔ TS) ·
|
||||
[저장] · [초기화] · 지층 추가 → 저장 → 다시 읽음(임시 storage · DB 대신 측점 가짜).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from E02_StabGround import E02_StabGround_Model as model # noqa: E402
|
||||
from E02_StabGround import E02_StabGround_Router as router # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_Types as types # noqa: E402
|
||||
|
||||
TS = PROJECT_ROOT / "E02_StabGround" / "logic" / "E02_StabGround_Check.ts"
|
||||
TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript"
|
||||
DEFAULTS = model.load_defaults()
|
||||
LIMITS = DEFAULTS["limits"]
|
||||
STATIONS = [
|
||||
{"chainage_m": 0.0, "ground_type": "soil", "soil_thickness_m": None},
|
||||
{"chainage_m": 20.0, "ground_type": "ripping_rock", "soil_thickness_m": 0.5},
|
||||
{"chainage_m": 40.0, "ground_type": "blasting_rock", "soil_thickness_m": 0.8},
|
||||
{"chainage_m": 60.0, "ground_type": "ripping_rock", "soil_thickness_m": 0.5},
|
||||
]
|
||||
|
||||
|
||||
def _ground(**changes):
|
||||
ground = model.build_initial(DEFAULTS, copy.deepcopy(STATIONS))
|
||||
ground.update(changes)
|
||||
return ground
|
||||
|
||||
|
||||
def _bad(mutate):
|
||||
ground = _ground()
|
||||
mutate(ground)
|
||||
return ground
|
||||
|
||||
|
||||
# 거울 시험 판 — 통과 하나 + 틀린 꼴 여럿(NaN 은 JSON 으로 못 넘겨 null · 글 · 참거짓으로)
|
||||
CHECK_CASES = [
|
||||
_ground(),
|
||||
_bad(lambda g: g["materials"][0].update(gamma_t=9.9)),
|
||||
_bad(lambda g: g["materials"][0].update(gamma_sat=17.0)),
|
||||
_bad(lambda g: g["materials"][1].update(c=None, phi="33")),
|
||||
_bad(lambda g: g["materials"][2].update(phi=True, water="sand")),
|
||||
_bad(lambda g: g["materials"][3].update(name=" ", id="soft_rock")),
|
||||
_bad(lambda g: g["materials"][4].update(name="풍화토")),
|
||||
_bad(lambda g: g["materials"][5].update(name="가" * 31)),
|
||||
_bad(lambda g: g["materials"].append(7)),
|
||||
_bad(lambda g: g.update(materials=[])),
|
||||
_bad(lambda g: g["label_map"].update(soil="nope", fill="")),
|
||||
_bad(lambda g: g.update(zones=[{"from_m": 50, "to_m": 50, "soil": "x", "rock": None}])),
|
||||
_bad(
|
||||
lambda g: g.update(
|
||||
zones=[
|
||||
{
|
||||
"from_m": -1,
|
||||
"to_m": 10,
|
||||
"soil": None,
|
||||
"rock": "hard_rock",
|
||||
"soil_thickness_m": 51,
|
||||
},
|
||||
"zone",
|
||||
]
|
||||
)
|
||||
),
|
||||
_bad(lambda g: g["safety_factors"]["wet"].update(cut=0.9, soft=3.1)),
|
||||
_bad(lambda g: g["safety_factors"].pop("seismic")),
|
||||
_bad(lambda g: g.update(road_surcharge_kpa=101)),
|
||||
_bad(lambda g: g["groundwater"].update(wet_rock="full")),
|
||||
_bad(lambda g: g.update(seismic={"enabled": "yes", "kh": 0.6})),
|
||||
_bad(lambda g: g.update(label_map=[], safety_factors=None, groundwater="x")),
|
||||
]
|
||||
|
||||
RESOLVE_CASES = [
|
||||
_ground(),
|
||||
_ground(
|
||||
zones=[
|
||||
{"from_m": 0, "to_m": 20, "soil": "colluvium", "rock": None, "soil_thickness_m": None},
|
||||
{"from_m": 20, "to_m": 50, "soil": None, "rock": "hard_rock", "soil_thickness_m": 2.5},
|
||||
{"from_m": 55, "to_m": 70, "soil": None, "rock": None, "soil_thickness_m": None},
|
||||
]
|
||||
),
|
||||
_ground(
|
||||
zones=[{"from_m": 0, "to_m": 10, "soil": None, "rock": "soft_rock", "soil_thickness_m": 1}]
|
||||
),
|
||||
]
|
||||
|
||||
SCRIPT = r"""
|
||||
const ts = require(process.argv[1]);
|
||||
const fs = require("fs");
|
||||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||||
const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } };
|
||||
const js = ts.transpileModule(src, opts).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", js)(box.exports, box);
|
||||
const m = box.exports;
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify({
|
||||
check: input.check.map((g) => m.checkGround(g, input.limits)),
|
||||
resolve: input.resolve.map((g) => m.resolveStations(g)),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def test_기본값_꼴은_Types_그대로():
|
||||
assert model.check_ground(DEFAULTS, LIMITS) == []
|
||||
assert [m["id"] for m in DEFAULTS["materials"]] == [m["id"] for m in types.DEFAULT_MATERIALS]
|
||||
for item in DEFAULTS["materials"]:
|
||||
assert types.material(item) == {k: item[k] for k in types.Material.__annotations__}
|
||||
for field in model.MATERIAL_FIELDS:
|
||||
assert DEFAULTS["sources"][f"materials.{item['id']}.{field}"].startswith("07 ")
|
||||
assert DEFAULTS["road_surcharge_kpa"] == types.DEFAULT_ROAD_SURCHARGE_KPA == 10.0
|
||||
assert DEFAULTS["groundwater"] == {
|
||||
"dry": "none",
|
||||
"wet_soil": "surface",
|
||||
"wet_rock": "half_height",
|
||||
}
|
||||
assert DEFAULTS["seismic"] == {"enabled": False, "kh": 0.0}
|
||||
# 07 §4 종합 성토 φ 28~30 — 브레인 기본 25 는 범위 밖이라 아래값 28
|
||||
assert next(m for m in DEFAULTS["materials"] if m["id"] == "fill")["phi"] == 28.0
|
||||
assert types.groundwater({"mode": "none"}) == {"mode": "none"}
|
||||
|
||||
|
||||
def test_기준안전율_KDS_11_70_05():
|
||||
assert DEFAULTS["safety_factors"] == {
|
||||
"dry": {"cut": 1.5, "fill": 1.5, "soft": 1.3},
|
||||
"wet": {"cut": 1.2, "fill": 1.3, "soft": 1.2},
|
||||
"seismic": {"cut": 1.1, "fill": 1.1, "soft": 1.1},
|
||||
"short_term": {"cut": 1.1, "fill": 1.1, "soft": 1.1},
|
||||
}
|
||||
|
||||
|
||||
def test_범위_검사():
|
||||
codes = lambda g: {(e["path"], e["code"]) for e in model.check_ground(g, LIMITS)} # noqa: E731
|
||||
assert codes(CHECK_CASES[1]) == {("materials.0.gamma_t", "range")}
|
||||
assert codes(CHECK_CASES[2]) == {("materials.0.gamma_sat", "sat_lt_t")}
|
||||
assert ("materials.4.name", "name_dup") in codes(CHECK_CASES[6])
|
||||
assert ("zones.0.to_m", "order") in codes(CHECK_CASES[11])
|
||||
assert ("zones.0.soil", "ref") in codes(CHECK_CASES[11])
|
||||
assert codes(CHECK_CASES[13]) == {
|
||||
("safety_factors.wet.cut", "range"),
|
||||
("safety_factors.wet.soft", "range"),
|
||||
}
|
||||
assert codes(CHECK_CASES[17]) == {("seismic.enabled", "enum"), ("seismic.kh", "range")}
|
||||
|
||||
|
||||
def test_파이썬과_TS_같음():
|
||||
payload = json.dumps({"limits": LIMITS, "check": CHECK_CASES, "resolve": RESOLVE_CASES})
|
||||
proc = subprocess.run(
|
||||
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS)],
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
out = json.loads(proc.stdout)
|
||||
assert out["check"] == [model.check_ground(g, LIMITS) for g in CHECK_CASES]
|
||||
assert out["resolve"] == [model.resolve_stations(g) for g in RESOLVE_CASES]
|
||||
assert all(out["check"][1:]) # 틀린 판은 모두 걸림
|
||||
|
||||
|
||||
def test_측점_적용():
|
||||
rows = model.resolve_stations(RESOLVE_CASES[1])
|
||||
assert [(r["upper"], r["lower"], r["soil_thickness_m"]) for r in rows] == [
|
||||
("colluvium", None, None), # 토사 측점 · 두께 없음 = 전부 위 지층
|
||||
("colluvium", "hard_rock", 2.5), # 20 = 두 구간 끝 · 뒤 구간이 이김
|
||||
("weathered_soil", "hard_rock", 2.5),
|
||||
("weathered_soil", "weathered_rock", 0.5), # 빈 구간 = B06 그대로
|
||||
]
|
||||
assert [r["zoned"] for r in rows] == [True, True, True, True]
|
||||
assert model.resolve_stations(RESOLVE_CASES[2])[0]["lower"] == "soft_rock"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path, monkeypatch):
|
||||
async def fake_project(_project_id):
|
||||
return tmp_path, {"company_id": 1, "project_kind": "STABILITY", "source_project_id": None}
|
||||
|
||||
async def fake_stations(_connection, _ids):
|
||||
return copy.deepcopy(STATIONS)
|
||||
|
||||
class FakePool:
|
||||
def acquire(self):
|
||||
class Ctx:
|
||||
async def __aenter__(self):
|
||||
return None
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
return Ctx()
|
||||
|
||||
monkeypatch.setattr(router, "_project", fake_project)
|
||||
monkeypatch.setattr(router, "_b06_stations", fake_stations)
|
||||
monkeypatch.setattr(router, "get_db_pool", lambda: FakePool())
|
||||
return tmp_path
|
||||
|
||||
|
||||
PID = "9872e9a7-e483-427b-a6b2-72cdbc063bc3"
|
||||
SESSION = {"role": "USER", "company_id": 1}
|
||||
|
||||
|
||||
def test_저장_다시읽음_초기화(project):
|
||||
from uuid import UUID
|
||||
|
||||
pid = UUID(PID)
|
||||
first = asyncio.run(router.get_ground(pid, SESSION))
|
||||
assert first["saved"] is False and first["editable"] is True
|
||||
assert first["ground"]["stations"] == STATIONS
|
||||
assert model.initial_path(project).is_file() and not model.ground_path(project).is_file()
|
||||
|
||||
ground = copy.deepcopy(first["ground"])
|
||||
ground["materials"].append(
|
||||
{
|
||||
"id": "user_1",
|
||||
"name": " 점토층 ",
|
||||
"gamma_t": 16.5,
|
||||
"gamma_sat": 17.5,
|
||||
"c": 20,
|
||||
"phi": 18,
|
||||
"water": "soil",
|
||||
"extra": 1,
|
||||
}
|
||||
)
|
||||
ground["materials"][0]["c"] = 12.0
|
||||
ground["zones"] = [
|
||||
{"from_m": 0, "to_m": 30, "soil": "user_1", "rock": None, "soil_thickness_m": 1.2}
|
||||
]
|
||||
ground["safety_factors"]["wet"]["cut"] = 1.3
|
||||
ground["stations"] = [] # 브라우저 값은 안 받음
|
||||
asyncio.run(router.put_ground(pid, router.GroundRequest(ground=ground)))
|
||||
|
||||
stored = json.loads(model.ground_path(project).read_text(encoding="utf-8"))
|
||||
assert stored["materials"][-1] == {
|
||||
"id": "user_1",
|
||||
"name": "점토층",
|
||||
"gamma_t": 16.5,
|
||||
"gamma_sat": 17.5,
|
||||
"c": 20,
|
||||
"phi": 18,
|
||||
"water": "soil",
|
||||
}
|
||||
assert stored["materials"][0]["c"] == 12.0
|
||||
assert stored["safety_factors"]["wet"]["cut"] == 1.3
|
||||
assert stored["stations"] == STATIONS
|
||||
assert set(stored) == set(model.GROUND_KEYS)
|
||||
for item in stored["materials"]: # 엔진이 바로 읽음
|
||||
types.material(item)
|
||||
|
||||
again = asyncio.run(router.get_ground(pid, SESSION))
|
||||
assert again["saved"] is True and again["ground"] == stored
|
||||
|
||||
bad = copy.deepcopy(stored)
|
||||
bad["materials"][0]["phi"] = 75
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
asyncio.run(router.put_ground(pid, router.GroundRequest(ground=bad)))
|
||||
assert caught.value.status_code == 422
|
||||
assert caught.value.detail["errors"] == [{"path": "materials.0.phi", "code": "range"}]
|
||||
assert json.loads(model.ground_path(project).read_text(encoding="utf-8")) == stored
|
||||
|
||||
reset = asyncio.run(router.reset_ground(pid))
|
||||
initial = json.loads(model.initial_path(project).read_text(encoding="utf-8"))
|
||||
assert reset["ground"] == initial == model.build_initial(DEFAULTS, STATIONS)
|
||||
assert json.loads(model.ground_path(project).read_text(encoding="utf-8")) == initial
|
||||
|
||||
|
||||
def test_다른_회사는_보기만(project):
|
||||
from uuid import UUID
|
||||
|
||||
body = asyncio.run(router.get_ground(UUID(PID), {"role": "USER", "company_id": 2}))
|
||||
assert body["editable"] is False
|
||||
admin = asyncio.run(router.get_ground(UUID(PID), {"role": "SYSTEM_ADMIN", "company_id": None}))
|
||||
assert admin["editable"] is True
|
||||
@@ -30,6 +30,7 @@ import { ui_locales_b5 } from "./ui_template_locale_b5";
|
||||
import { ui_locales_m1 } from "./ui_template_locale_m1";
|
||||
import { ui_locales_m2 } from "./ui_template_locale_m2";
|
||||
import { ui_locales_de } from "./ui_template_locale_de";
|
||||
import { ui_locales_e } from "./ui_template_locale_e";
|
||||
|
||||
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
|
||||
export const LANGUAGES = ["ko", "en"] as const;
|
||||
@@ -68,6 +69,7 @@ export const ui_locales = {
|
||||
...ui_locales_m1,
|
||||
...ui_locales_m2,
|
||||
...ui_locales_de,
|
||||
...ui_locales_e,
|
||||
} as const;
|
||||
|
||||
export type LocaleKey = keyof typeof ui_locales;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_e.ts
|
||||
* 다국어 사전 — E 안정성평가 페이지 본문(E02 지반 조건 · 요구 347-2 · 3 · 4)
|
||||
*
|
||||
* 신규 문구는 해당 섹션 최하단에 추가. 단계 막대 · 등록 화면 문구는 ui_template_locale_de.ts.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LocaleEntry } from "./ui_template_locale_common";
|
||||
|
||||
export const ui_locales_e = {
|
||||
/* --- E02 지반 조건 — 카드 --- */
|
||||
E02_Card_Materials: ["지반물성치", "Material Properties"],
|
||||
E02_Card_Zones: ["층 두께 · 구간 덮어쓰기", "Layer Thickness · Zone Overrides"],
|
||||
E02_Card_Stations: ["측점별 적용 지층", "Layers per Station"],
|
||||
E02_Card_Safety: ["기준안전율", "Required Factors of Safety"],
|
||||
E02_Card_Loads: ["노면 하중 · 지하수 · 지진", "Surcharge · Groundwater · Seismic"],
|
||||
E02_Note: [
|
||||
"문헌 추정치 · 현장 시험 없음 — 값은 바꿀 수 있습니다. 칸에 마우스를 올리면 근거가 보입니다.",
|
||||
"Literature estimates without field tests — values are editable. Hover a cell for its source.",
|
||||
],
|
||||
|
||||
/* --- 물성 표 --- */
|
||||
E02_Col_Name: ["지층", "Layer"],
|
||||
E02_Col_GammaT: ["γt (kN/m³)", "γt (kN/m³)"],
|
||||
E02_Col_GammaSat: ["γsat (kN/m³)", "γsat (kN/m³)"],
|
||||
E02_Col_C: ["c (kPa)", "c (kPa)"],
|
||||
E02_Col_Phi: ["φ (°)", "φ (°)"],
|
||||
E02_Col_Water: ["지하수 무리", "Water group"],
|
||||
E02_Water_Soil: ["토사 · 풍화암", "Soil · weathered rock"],
|
||||
E02_Water_Rock: ["암반", "Rock"],
|
||||
E02_Btn_AddLayer: ["지층 추가", "Add layer"],
|
||||
E02_Btn_Defaults: ["기본값", "Defaults"],
|
||||
E02_Btn_Remove: ["지움", "Remove"],
|
||||
E02_NewLayer: ["새 지층", "New layer"],
|
||||
E02_InUse: [
|
||||
"쓰이는 지층은 지울 수 없습니다(B06 지반 연결 · 구간).",
|
||||
"A layer in use cannot be removed (B06 mapping · zones).",
|
||||
],
|
||||
E02_LabelMap: ["B06 지반 → 지층", "B06 ground → layer"],
|
||||
E02_Label_soil: ["토사 (경계 위)", "Soil (above boundary)"],
|
||||
E02_Label_ripping_rock: ["리핑암 (경계 아래)", "Ripping rock (below)"],
|
||||
E02_Label_blasting_rock: ["발파암 (경계 아래)", "Blasting rock (below)"],
|
||||
E02_Label_fill: ["성토체", "Embankment"],
|
||||
|
||||
/* --- 구간 · 측점 표 --- */
|
||||
E02_Col_From: ["시작 (m)", "From (m)"],
|
||||
E02_Col_To: ["끝 (m)", "To (m)"],
|
||||
E02_Col_Upper: ["위 지층", "Upper layer"],
|
||||
E02_Col_Lower: ["아래 지층", "Lower layer"],
|
||||
E02_Col_Thickness: ["토층 두께 (m)", "Soil thickness (m)"],
|
||||
E02_Col_Station: ["측점", "Station"],
|
||||
E02_Col_GroundType: ["B06 지반", "B06 ground"],
|
||||
E02_Col_B06Thickness: ["B06 토층 두께 (m)", "B06 thickness (m)"],
|
||||
E02_Btn_AddZone: ["구간 추가", "Add zone"],
|
||||
E02_Zone_Keep: ["B06 그대로", "Keep B06"],
|
||||
E02_Zone_Empty: [
|
||||
"구간 덮어쓰기 없음 — 모든 측점이 B06 값 그대로입니다.",
|
||||
"No zone overrides — every station keeps its B06 values.",
|
||||
],
|
||||
E02_Zone_Hint: [
|
||||
"구간 안 측점(양 끝 포함)은 고른 지층 · 두께로 바뀝니다. 빈 칸은 B06 그대로 · 뒤 줄이 앞 줄을 이깁니다.",
|
||||
"Stations inside a zone (ends included) take its layers and thickness. Blank keeps B06 · later rows win.",
|
||||
],
|
||||
E02_Stations_Empty: ["B06 측점이 없습니다.", "No B06 stations."],
|
||||
E02_Ground_soil: ["토사", "Soil"],
|
||||
E02_Ground_ripping_rock: ["리핑암", "Ripping rock"],
|
||||
E02_Ground_blasting_rock: ["발파암", "Blasting rock"],
|
||||
E02_AllUpper: ["전부 위 지층", "All upper layer"],
|
||||
|
||||
/* --- 기준안전율 --- */
|
||||
E02_Case_dry: ["건기", "Dry"],
|
||||
E02_Case_wet: ["우기", "Wet"],
|
||||
E02_Case_seismic: ["지진 시", "Seismic"],
|
||||
E02_Case_short_term: ["단기", "Short-term"],
|
||||
E02_Slope_cut: ["깎기", "Cut"],
|
||||
E02_Slope_fill: ["쌓기", "Fill"],
|
||||
E02_Slope_soft: ["쌓기(연약지반)", "Fill (soft ground)"],
|
||||
|
||||
/* --- 하중 · 지하수 · 지진 --- */
|
||||
E02_Surcharge: ["노면 하중 (kPa)", "Road surcharge (kPa)"],
|
||||
E02_Water_dry: ["건기 지하수", "Dry groundwater"],
|
||||
E02_Water_wet_soil: ["우기 · 토사 · 풍화암", "Wet · soil · weathered rock"],
|
||||
E02_Water_wet_rock: ["우기 · 암반", "Wet · rock"],
|
||||
E02_Rule_none: ["없음", "None"],
|
||||
E02_Rule_surface: ["지표 포화", "Saturated to surface"],
|
||||
E02_Rule_half_height: ["비탈 높이 ½ 포화", "Saturated to ½ height"],
|
||||
E02_Seismic: ["지진 검토", "Seismic check"],
|
||||
E02_Kh: ["수평지진계수 kh", "Horizontal coefficient kh"],
|
||||
|
||||
/* --- 상태 · 알림 --- */
|
||||
E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
|
||||
E02_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
|
||||
E02_Saved: ["저장했습니다.", "Saved."],
|
||||
E02_ResetDone: ["초기값으로 되돌렸습니다.", "Restored to initial values."],
|
||||
E02_ResetConfirm: [
|
||||
"초기값(생성 때 기본값 + B06 측점)으로 되돌릴까요? 저장한 값이 사라집니다.",
|
||||
"Restore initial values (defaults + B06 at creation)? Saved values will be lost.",
|
||||
],
|
||||
E02_Invalid: ["입력값을 확인하세요 — 틀린 칸", "Check inputs — invalid cells"],
|
||||
E02_DefaultIs: ["기본값", "Default"],
|
||||
E02_UserLayer: ["사용자 지층 — 출처 없음", "User layer — no source"],
|
||||
E02_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
|
||||
|
||||
/* --- 입력 검사(코드 = 파이썬 check_ground 와 같은 낱말) --- */
|
||||
E02_Err_number: ["숫자를 넣으세요", "Enter a number"],
|
||||
E02_Err_range: ["범위 밖", "Out of range"],
|
||||
E02_Err_order: ["끝은 시작보다 커야 합니다", "End must exceed start"],
|
||||
E02_Err_sat_lt_t: ["γsat 는 γt 이상이어야 합니다", "γsat must be ≥ γt"],
|
||||
E02_Err_name: ["이름은 1~30자", "Name must be 1–30 characters"],
|
||||
E02_Err_name_dup: ["같은 이름이 있습니다", "Duplicate name"],
|
||||
E02_Err_id_dup: ["지층 번호가 겹칩니다", "Duplicate layer id"],
|
||||
E02_Err_enum: ["값을 고르세요", "Pick a value"],
|
||||
E02_Err_ref: ["지층을 고르세요", "Pick a layer"],
|
||||
E02_Err_empty: ["지층이 하나는 있어야 합니다", "At least one layer is required"],
|
||||
E02_Err_type: ["꼴이 틀립니다", "Invalid shape"],
|
||||
} as const satisfies Record<string, LocaleEntry>;
|
||||
Reference in New Issue
Block a user