Merge origin/sub_laptop_2 — E03 2D 해석 길 · 화면 · main.py 라우터 충돌 풂(E03 · E04 · E05 모두 등록)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"""E03 2D 측점 해석 API (요구 345 · 347 · 351 · PLAN 532-3).
|
||||
|
||||
- `POST /api/projects/{id}/stab/analyze2d` — 전 측점 해석(뒤에서 · 바뀐 측점만) · 같은 지문 = 안 셈
|
||||
- `GET …/stab/progress` — 진행률
|
||||
- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면)
|
||||
- `GET …/stab/section/{station_key}` — 그 측점 지층 폴리곤 · 지하수선 · 임계면 · 후보 · 절편 · 격자
|
||||
- `PUT …/stab/representative` — 대표 단면 켬/끔 → 작업본 `E03_StabSection/representative.json`
|
||||
- `GET …/stab/corridor3d` — 복사본 노선면(1 m 솎음 · 3D 색칠 보기용)
|
||||
|
||||
입력 = E01 복사본(측점 · 횡단 · DTM) + E02 지반 조건(작업본 · 없으면 초기값)
|
||||
계산 자리 = ③ 서버 단독.
|
||||
결과 = 파생 저장본 `_derived/stab2d_station/<측점 지문>` · `_derived/stab2d_summary/<전체 지문>` —
|
||||
측점 지문 = 엔진 코드 · 복사 지문 · 물성 · 조건 · 하중 · 그 측점 지층. 공유 회사(보기만)는
|
||||
`require_project_access` 가 POST · PUT 를 막음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_derived_store import (
|
||||
derived_dir,
|
||||
derived_key,
|
||||
read_derived,
|
||||
write_derived,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_station_key import chainage_key
|
||||
from common_util.common_util_surface_sampler import DtmGridSampler
|
||||
from E01_StabRegister import E01_StabRegister_Source as src
|
||||
from E02_StabGround import E02_StabGround_Model as ground_model
|
||||
from E02_StabGround import E02_StabGround_Router as e02
|
||||
from E03_StabSection import E03_StabSection_View as view
|
||||
from E03_StabSection import E03_StabSection_Worker as worker
|
||||
from E03_StabSection.E03_StabSection_Engine_Model import (
|
||||
ground_from_cross,
|
||||
section_model_from_b06,
|
||||
widen_ground,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["E03 Stab Section"])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
KIND_STATION = worker.KIND_STATION
|
||||
KIND_SUMMARY = "stab2d_summary"
|
||||
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
|
||||
ENGINE_FILES = (
|
||||
"E03_StabSection_Types.py",
|
||||
"E03_StabSection_Engine_Model.py",
|
||||
"E03_StabSection_Engine_LEM.py",
|
||||
"E03_StabSection_Engine_Search.py",
|
||||
"E03_StabSection_Engine_Station.py",
|
||||
)
|
||||
#: 3D 보기 노선면 솎음 간격(m)
|
||||
CORRIDOR_STEP_M = 1.0
|
||||
#: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험)
|
||||
WORKERS: int | None = None
|
||||
|
||||
# 진행 — 프로젝트 → {running, percent, message, error, done, total} · 프로세스 메모리
|
||||
_JOBS: dict[str, dict[str, Any]] = {}
|
||||
_TASKS: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
class RepresentativeRequest(BaseModel):
|
||||
overrides: dict[str, bool | None]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def engine_hash() -> str:
|
||||
"""엔진 코드 지문 — 코드가 바뀌면 모든 측점을 다시 셈."""
|
||||
digest = hashlib.sha1()
|
||||
for name in ENGINE_FILES:
|
||||
digest.update((Path(__file__).parent / name).read_bytes())
|
||||
return digest.hexdigest()[:12]
|
||||
|
||||
|
||||
def _ground(root: Path) -> dict[str, Any]:
|
||||
"""E02 지반 조건 — 작업본 · 없으면 초기값."""
|
||||
return e02._read_working(root) or e02.ensure_initial(root)
|
||||
|
||||
|
||||
def _layers(resolved: dict[str, Any] | None) -> list | None:
|
||||
"""E02 측점 적용 지층 → 엔진 지층(위부터 [물성 id, 원지반 아래 바닥 깊이 | None]).
|
||||
|
||||
없으면 None(엔진 기본 = B06 라벨)."""
|
||||
if not resolved or not resolved.get("upper"):
|
||||
return None
|
||||
if resolved.get("lower"):
|
||||
return [[resolved["upper"], float(resolved["soil_thickness_m"])], [resolved["lower"], None]]
|
||||
return [[resolved["upper"], None]]
|
||||
|
||||
|
||||
def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]:
|
||||
"""측점마다 입력 · 지문 + 전체 지문(판정 기준 포함) — 복사본이 없으면 400."""
|
||||
try:
|
||||
source = src.source_dir(root)
|
||||
copy_fp = src.manifest(source)["source_fingerprint"]
|
||||
stations = src.stations(source)
|
||||
except (OSError, KeyError) as error:
|
||||
raise HTTPException(status_code=400, detail="원본 복사본이 없습니다.") from error
|
||||
seismic = ground.get("seismic") or {}
|
||||
conditions = ["dry", "wet"] + (["seismic"] if seismic.get("enabled") else [])
|
||||
shared = {
|
||||
"materials": ground.get("materials"),
|
||||
"conditions": conditions,
|
||||
"seismic_kh": float(seismic.get("kh") or 0.0) if seismic.get("enabled") else 0.0,
|
||||
"road_surcharge_kpa": float(ground.get("road_surcharge_kpa") or 0.0),
|
||||
}
|
||||
resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)}
|
||||
items = []
|
||||
for station in stations:
|
||||
key = chainage_key(station["chainage_m"])
|
||||
layers = _layers(resolved.get(key))
|
||||
items.append(
|
||||
{
|
||||
"key": key,
|
||||
"chainage_m": station["chainage_m"],
|
||||
"label": station.get("label"),
|
||||
"ground_type": station.get("ground_type"),
|
||||
"center": [station.get("center_x"), station.get("center_y")],
|
||||
"layers": layers,
|
||||
"fp": derived_key(engine_hash(), copy_fp, shared, key, layers),
|
||||
}
|
||||
)
|
||||
factors = ground.get("safety_factors") or {}
|
||||
overall = derived_key([item["fp"] for item in items], factors)
|
||||
return {"source": source, "shared": shared, "items": items, "factors": factors, "fp": overall}
|
||||
|
||||
|
||||
def _station_input(source: Path, item: dict[str, Any], sampler: Any) -> dict[str, Any]:
|
||||
"""측점 하나 엔진 입력 — 횡단 · 설계 · 넓힌 지반선(DTM 밖은 표본 그대로)."""
|
||||
cross = src.cross_section(source, item["chainage_m"])
|
||||
try:
|
||||
ground = widen_ground(cross, sampler) if sampler else ground_from_cross(cross)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
ground = ground_from_cross(cross)
|
||||
return {"cross": cross, "design": cross["design"], "ground": [list(p) for p in ground]}
|
||||
|
||||
|
||||
def _sampler(source: Path) -> Any:
|
||||
try:
|
||||
return DtmGridSampler.from_npz(src.terrain_grid_path(source))
|
||||
except (OSError, FileNotFoundError, ValueError):
|
||||
logger.warning("E03 지형 격자 없음 — 횡단 표본 폭으로 해석: %s", source)
|
||||
return None
|
||||
|
||||
|
||||
def _meta(item: dict[str, Any], design: dict[str, Any]) -> dict[str, Any]:
|
||||
keys = ("key", "chainage_m", "label", "ground_type", "center", "fp")
|
||||
return {**{k: item[k] for k in keys}, "section_mode": design.get("section_mode")}
|
||||
|
||||
|
||||
def _run_worker(job: dict[str, Any], emit: Any) -> None:
|
||||
"""일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError."""
|
||||
with tempfile.TemporaryDirectory(prefix="aislo_e03_") as folder:
|
||||
path = Path(folder) / "job.json"
|
||||
path.write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8")
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)],
|
||||
cwd=PROJECT_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
for line in process.stdout:
|
||||
if line.startswith("{"):
|
||||
emit(json.loads(line).get("done"))
|
||||
error = process.stderr.read()
|
||||
if process.wait() != 0:
|
||||
raise RuntimeError(f"해석 일꾼 실패: {error.strip().splitlines()[-1:]}")
|
||||
|
||||
|
||||
def run_all(root: Path, plan: dict[str, Any], report: Any) -> int:
|
||||
"""지문이 없는 측점만 셈(일꾼 프로세스 병렬) → 측점 저장본 · 요약 저장본. 센 측점 수."""
|
||||
source, shared, items = plan["source"], plan["shared"], plan["items"]
|
||||
todo = [it for it in items if read_derived(root, KIND_STATION, it["fp"]) is None]
|
||||
if todo:
|
||||
sampler = _sampler(source)
|
||||
job_items = []
|
||||
for it in todo:
|
||||
data = _station_input(source, it, sampler)
|
||||
job_items.append({"fp": it["fp"], "meta": _meta(it, data["design"]), **data})
|
||||
job_items[-1]["layers"] = it["layers"]
|
||||
job = {
|
||||
"root": str(root),
|
||||
"keep": max(3 * len(items), 6),
|
||||
"workers": WORKERS or max(1, (os.cpu_count() or 2) - 2),
|
||||
"kwargs": {
|
||||
"materials": shared["materials"],
|
||||
"conditions": list(shared["conditions"]),
|
||||
"seismic_kh": shared["seismic_kh"],
|
||||
"road_surcharge_kpa": shared["road_surcharge_kpa"],
|
||||
},
|
||||
"items": job_items,
|
||||
}
|
||||
report(2, f"측점 {len(todo)} 개 해석 시작")
|
||||
done = [0]
|
||||
|
||||
def emit(_key: str | None) -> None:
|
||||
done[0] += 1
|
||||
report(2 + 95 * done[0] // len(todo), f"측점 {done[0]} / {len(todo)}")
|
||||
|
||||
if WORKERS == 1:
|
||||
worker.run_job(job, emit)
|
||||
else:
|
||||
_run_worker(job, emit)
|
||||
rows = []
|
||||
for it in items:
|
||||
saved = read_derived(root, KIND_STATION, it["fp"])
|
||||
value = saved["value"] if saved else {"meta": _meta(it, {}), "out": None, "error": "없음"}
|
||||
rows.append(view.station_row(value["meta"], value["out"], plan["factors"], value["error"]))
|
||||
summary = {
|
||||
"fingerprint": plan["fp"],
|
||||
"computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"conditions": shared["conditions"],
|
||||
"method": view.PRIMARY,
|
||||
"factors": plan["factors"],
|
||||
"summary": view.summarize(rows, shared["conditions"]),
|
||||
"stations": rows,
|
||||
}
|
||||
write_derived(root, KIND_SUMMARY, plan["fp"], summary)
|
||||
return len(todo)
|
||||
|
||||
|
||||
def _latest_summary(root: Path, fp: str) -> tuple[dict[str, Any] | None, bool]:
|
||||
"""(요약, 지금 입력과 같은지) — 같은 지문이 없으면 가장 최근 것(옛 결과)."""
|
||||
saved = read_derived(root, KIND_SUMMARY, fp)
|
||||
if saved:
|
||||
return saved["value"], True
|
||||
files = sorted(
|
||||
derived_dir(root, KIND_SUMMARY).glob("*.json"),
|
||||
key=lambda p: p.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
for path in files:
|
||||
old = read_derived(root, KIND_SUMMARY, path.stem)
|
||||
if old:
|
||||
return old["value"], False
|
||||
return None, False
|
||||
|
||||
|
||||
def _overrides(root: Path) -> dict[str, bool]:
|
||||
path = root / REPRESENTATIVE
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return {k: bool(v) for k, v in (data.get("overrides") or {}).items() if v is not None}
|
||||
|
||||
|
||||
@router.post("/{project_id}/stab/analyze2d", response_model=None)
|
||||
async def analyze2d(project_id: UUID) -> dict[str, Any]:
|
||||
"""전 측점 해석(뒤에서) — 지문이 같으면 다시 안 셈(`fresh`) · 진행은 `GET …/stab/progress`."""
|
||||
root, _ = await e02._project(project_id)
|
||||
plan = plan_run(root, _ground(root))
|
||||
if read_derived(root, KIND_SUMMARY, plan["fp"]):
|
||||
return {"status": "fresh", "fingerprint": plan["fp"]}
|
||||
key = str(project_id)
|
||||
if (_JOBS.get(key) or {}).get("running"):
|
||||
raise HTTPException(status_code=409, detail="이미 해석 중입니다.")
|
||||
job = {"running": True, "percent": 0, "message": "시작", "error": None, "computed": 0}
|
||||
_JOBS[key] = job
|
||||
|
||||
def report(percent: int, message: str) -> None:
|
||||
job.update(percent=int(percent), message=message)
|
||||
|
||||
async def work() -> None:
|
||||
try:
|
||||
job["computed"] = await asyncio.to_thread(run_all, root, plan, report)
|
||||
job.update(percent=100, message="끝")
|
||||
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
|
||||
logger.exception("E03 2D 해석 실패: project_id=%s", project_id)
|
||||
job["error"] = str(getattr(error, "detail", None) or error)
|
||||
finally:
|
||||
job["running"] = False
|
||||
|
||||
task = asyncio.create_task(work(), name=f"E03 analyze2d {project_id}")
|
||||
_TASKS.add(task)
|
||||
task.add_done_callback(_TASKS.discard)
|
||||
return {"status": "accepted", "fingerprint": plan["fp"], **job}
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/progress", response_model=None)
|
||||
async def progress(project_id: UUID) -> dict[str, Any]:
|
||||
await e02._project(project_id)
|
||||
job = _JOBS.get(str(project_id))
|
||||
return dict(job) if job else {"running": False, "percent": 0, "message": "", "error": None}
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/result2d", response_model=None)
|
||||
async def result2d(
|
||||
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
"""요약 + 측점 목록 — 결과가 없으면 복사본 측점만(status none)."""
|
||||
root, project = await e02._project(project_id)
|
||||
ground = _ground(root)
|
||||
plan = plan_run(root, ground)
|
||||
summary, fresh = _latest_summary(root, plan["fp"])
|
||||
editable = e02._editable(session, project)
|
||||
running = bool((_JOBS.get(str(project_id)) or {}).get("running"))
|
||||
if not summary:
|
||||
rows = [view.station_row(item, None, plan["factors"]) for item in plan["items"]]
|
||||
view.mark_representative(rows, _overrides(root))
|
||||
return {
|
||||
"status": "none",
|
||||
"fresh": False,
|
||||
"editable": editable,
|
||||
"running": running,
|
||||
"conditions": plan["shared"]["conditions"],
|
||||
"factors": plan["factors"],
|
||||
"stations": rows,
|
||||
}
|
||||
rows = summary["stations"]
|
||||
view.mark_representative(rows, _overrides(root))
|
||||
return {
|
||||
"status": "ok",
|
||||
"fresh": fresh,
|
||||
"editable": editable,
|
||||
"running": running,
|
||||
**{k: v for k, v in summary.items() if k != "stations"},
|
||||
"stations": rows,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/section/{station_key}", response_model=None)
|
||||
async def section(project_id: UUID, station_key: str) -> dict[str, Any]:
|
||||
"""측점 하나 — 해석에 쓴 지반선 · 지층으로 모형을 다시 세워(빠름) 결과와 함께."""
|
||||
root, _ = await e02._project(project_id)
|
||||
ground = _ground(root)
|
||||
plan = plan_run(root, ground)
|
||||
summary, fresh = _latest_summary(root, plan["fp"])
|
||||
row = next((r for r in (summary or {}).get("stations", []) if r["key"] == station_key), None)
|
||||
saved = read_derived(root, KIND_STATION, row["fp"]) if row else None
|
||||
if not saved or not saved["value"]["out"]:
|
||||
raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.")
|
||||
value = saved["value"]
|
||||
cross = src.cross_section(plan["source"], row["chainage_m"])
|
||||
kw = {
|
||||
"materials": plan["shared"]["materials"],
|
||||
"layers": value["inputs"]["layers"],
|
||||
"ground": [tuple(p) for p in value["inputs"]["ground"]],
|
||||
"road_surcharge_kpa": plan["shared"]["road_surcharge_kpa"],
|
||||
}
|
||||
dry = section_model_from_b06(cross, cross["design"], **kw)
|
||||
wet = section_model_from_b06(cross, cross["design"], wet=True, **kw)
|
||||
water = {
|
||||
cond: (wet["groundwater"].get("line") if cond == "wet" else None)
|
||||
for cond in summary["conditions"]
|
||||
}
|
||||
out = view.section_view(row, value["out"], dry, water)
|
||||
out["fresh"] = fresh
|
||||
return out
|
||||
|
||||
|
||||
@router.put("/{project_id}/stab/representative", response_model=None)
|
||||
async def put_representative(project_id: UUID, payload: RepresentativeRequest) -> dict[str, Any]:
|
||||
"""대표 단면 켬/끔(None = 자동) — 아는 측점 열쇠만."""
|
||||
root, _ = await e02._project(project_id)
|
||||
known = {item["key"] for item in plan_run(root, _ground(root))["items"]}
|
||||
unknown = sorted(set(payload.overrides) - known)
|
||||
if unknown:
|
||||
raise HTTPException(status_code=422, detail=f"모르는 측점: {unknown[:5]}")
|
||||
overrides = {k: v for k, v in sorted(payload.overrides.items()) if v is not None}
|
||||
atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides})
|
||||
return {"overrides": overrides}
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
ribbons = []
|
||||
origin = None
|
||||
for ribbon in data.get("ribbons") or []:
|
||||
cols = int(ribbon["colCount"])
|
||||
chain = np.asarray(ribbon["chainages"], dtype=float)
|
||||
pos = np.frombuffer(base64.b64decode(ribbon["positionsBase64"]), dtype="<f8")
|
||||
pos = pos.reshape(len(chain), cols, 3)
|
||||
if origin is None and len(pos):
|
||||
origin = pos[0, 0].tolist()
|
||||
# 솎음 — 1 m 마다 첫 줄 + 끝 줄
|
||||
bins = np.floor(np.round(chain, 6) / CORRIDOR_STEP_M)
|
||||
keep = np.flatnonzero(np.r_[True, np.diff(bins) > 0])
|
||||
if keep[-1] != len(chain) - 1:
|
||||
keep = np.r_[keep, len(chain) - 1]
|
||||
local = pos[keep] - np.asarray(origin)
|
||||
ribbons.append(
|
||||
{
|
||||
"kind": ribbon["kind"],
|
||||
"side": ribbon["side"],
|
||||
"colCount": cols,
|
||||
"chainages": np.round(chain[keep], 3).tolist(),
|
||||
"positions": np.round(local, 3).reshape(-1).tolist(),
|
||||
}
|
||||
)
|
||||
return {"origin": origin, "ribbons": ribbons}
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/corridor3d", response_model=None)
|
||||
async def corridor3d(project_id: UUID) -> dict[str, Any]:
|
||||
"""복사본 노선면 — 원점 빼고 1 m 솎음(모델 좌표 x · y · z)."""
|
||||
root, _ = await e02._project(project_id)
|
||||
path = src.corridor_path(src.source_dir(root))
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.")
|
||||
stat = path.stat()
|
||||
return await asyncio.to_thread(_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size))
|
||||
@@ -0,0 +1,109 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_UI_List.ts
|
||||
* E03 좌측 측점 목록(PLAN 532-3) — 측점 · 깎기/쌓기 · 조건별 최소 안전율 · 판정(아이콘 + 글) ·
|
||||
* 대표 단면 별(자동 = 채움 · 사용자 켬/끔 = 밑줄 · 누르면 바꿈). 정렬 · 거름은 logic `listRows`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import {
|
||||
listRows,
|
||||
roleText,
|
||||
verdictOf,
|
||||
type Condition,
|
||||
type SortMode,
|
||||
type StationRow,
|
||||
} from "./logic/E03_StabSection_Logic";
|
||||
|
||||
const fmt = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(2);
|
||||
|
||||
export interface ListDraw {
|
||||
rows: StationRow[];
|
||||
conditions: Condition[];
|
||||
condition: Condition;
|
||||
sort: SortMode;
|
||||
ngOnly: boolean;
|
||||
active: string | null;
|
||||
/** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */
|
||||
representative: (row: StationRow) => { on: boolean; user: boolean | null };
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export function buildList(
|
||||
draw: ListDraw,
|
||||
onSelect: (key: string) => void,
|
||||
onToggle: (key: string) => void,
|
||||
): HTMLElement {
|
||||
const box = el("div", { className: "e03-list" });
|
||||
const table = el("table", { attrs: { "data-role": "station-list" } });
|
||||
const head = el("tr");
|
||||
head.append(
|
||||
el("th", { text: t("E03_Col_Station") }),
|
||||
el("th", { text: t("E03_Col_Role") }),
|
||||
...draw.conditions.map((c) =>
|
||||
el("th", {
|
||||
className: "is-num",
|
||||
text: t(`E03_Cond_${c}` as LocaleKey),
|
||||
attrs: c === draw.condition ? { "aria-sort": "other" } : {},
|
||||
}),
|
||||
),
|
||||
el("th", { text: t("E03_Verdict") }),
|
||||
el("th", { text: t("E03_Col_Rep"), attrs: { title: t("E03_Rep_Hint") } }),
|
||||
);
|
||||
table.append(el("thead", { children: [head] }));
|
||||
const body = el("tbody");
|
||||
const rows = listRows(draw.rows, draw.condition, draw.sort, draw.ngOnly);
|
||||
for (const row of rows) {
|
||||
const tr = el("tr", {
|
||||
className: row.key === draw.active ? "is-active" : "",
|
||||
attrs: { "data-key": row.key, "data-verdict": verdictOf(row, draw.condition) },
|
||||
});
|
||||
const v = verdictOf(row, draw.condition);
|
||||
const rep = draw.representative(row);
|
||||
const star = el("button", {
|
||||
className: `e03-star${rep.on ? " is-on" : ""}${rep.user !== null ? " is-user" : ""}`,
|
||||
text: rep.on ? "★" : "☆",
|
||||
attrs: {
|
||||
type: "button",
|
||||
title:
|
||||
[
|
||||
...(row.representative?.auto ?? []).map((r) => t(`E03_Rep_${r}` as LocaleKey)),
|
||||
rep.user !== null ? t("E03_Rep_User") : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || t("E03_Rep_Hint"),
|
||||
"aria-pressed": String(rep.on),
|
||||
},
|
||||
});
|
||||
star.disabled = !draw.editable;
|
||||
star.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
onToggle(row.key);
|
||||
});
|
||||
tr.append(
|
||||
el("td", { text: row.label?.replace(/^STA\./, "") ?? row.key }),
|
||||
el("td", {
|
||||
text: row.sides.length ? roleText(row, { cut: t("E03_Cut"), fill: t("E03_Fill") }) : "-",
|
||||
}),
|
||||
...draw.conditions.map((c) => el("td", { className: "is-num", text: fmt(row.min[c]) })),
|
||||
el("td", {
|
||||
children: [
|
||||
el("span", {
|
||||
className: `e03-verdict e03-verdict--${v}`,
|
||||
text: row.error ? t("E03_Error") : t(`E03_Verdict_${v}` as LocaleKey),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("td", { children: [star] }),
|
||||
);
|
||||
if (row.error) tr.title = row.error;
|
||||
tr.addEventListener("click", () => onSelect(row.key));
|
||||
body.append(tr);
|
||||
}
|
||||
table.append(body);
|
||||
box.append(table);
|
||||
if (!rows.length) box.append(el("p", { className: "e03-empty", text: t("E03_ListEmpty") }));
|
||||
queueMicrotask(() => box.querySelector("tr.is-active")?.scrollIntoView({ block: "nearest" }));
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_UI_Page.ts
|
||||
* E03 2D 측점 해석(요구서 345 4-3 · 요구 347 · 351 · PLAN 532-3) — B05~B09 틀(group_workflow).
|
||||
* 좌 = 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면 별) · [해석] · [3D 보기] · [대표 단면 저장]
|
||||
* 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프
|
||||
* 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) ·
|
||||
* 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./E03_StabSection_UI_Style.css";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createSelectField,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
setLoadingOverlayProgress,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import { buildList } from "./E03_StabSection_UI_List";
|
||||
import { buildPlan } from "./E03_StabSection_UI_Plan";
|
||||
import { buildResult } from "./E03_StabSection_UI_Result";
|
||||
import {
|
||||
buildLegends,
|
||||
createSectionCanvas,
|
||||
type SectionToggles,
|
||||
} from "./E03_StabSection_UI_Section";
|
||||
import { openView3d } from "./E03_StabSection_UI_View3D";
|
||||
import type {
|
||||
Analysis,
|
||||
Condition,
|
||||
SectionView,
|
||||
SortMode,
|
||||
StationRow,
|
||||
} from "./logic/E03_StabSection_Logic";
|
||||
|
||||
interface Result2d {
|
||||
status: "ok" | "none";
|
||||
fresh: boolean;
|
||||
editable: boolean;
|
||||
running: boolean;
|
||||
conditions: Condition[];
|
||||
computed_at?: string;
|
||||
summary?: Record<string, unknown>;
|
||||
stations: StationRow[];
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
running: boolean;
|
||||
percent: number;
|
||||
message: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function checkbox(label: string, checked: boolean, onChange: (v: boolean) => void): HTMLElement {
|
||||
const input = el("input", { attrs: { type: "checkbox" } });
|
||||
input.checked = checked;
|
||||
input.addEventListener("change", () => onChange(input.checked));
|
||||
return el("label", { className: "e03-check", children: [input, label] });
|
||||
}
|
||||
|
||||
export function renderE03StabSection(root: HTMLElement): void {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const main = el("div", { className: "e03-page" });
|
||||
const panel = el("div", { className: "ui-sidebar-panel e03-panel" });
|
||||
mountGroupStep(root, ROUTES.E03_STAB_SECTION, { mainContent: main, leftPanel: panel });
|
||||
if (!projectId)
|
||||
return void main.append(el("p", { className: "e03-empty", text: t("E03_NoProject") }));
|
||||
const pid: string = projectId;
|
||||
const base = `/projects/${pid}/stab`;
|
||||
|
||||
let result: Result2d | null = null;
|
||||
const sections = new Map<string, SectionView>();
|
||||
let condition: Condition = "dry";
|
||||
let sort: SortMode = "chainage";
|
||||
let ngOnly = false;
|
||||
let active: string | null = null;
|
||||
let side: string | null = null;
|
||||
let picked: number | null = null;
|
||||
const toggles: SectionToggles = { map: true, centers: false, candidates: true };
|
||||
let repCache: Record<string, boolean | null> = JSON.parse(
|
||||
sessionStorage.getItem(repKey(pid)) ?? "{}",
|
||||
);
|
||||
|
||||
// ---- 좌측 ----
|
||||
const condField = createSelectField({
|
||||
label: t("E03_Condition"),
|
||||
options: [],
|
||||
compact: true,
|
||||
onChange: (v) => {
|
||||
condition = v as Condition;
|
||||
side = null;
|
||||
picked = null;
|
||||
paint();
|
||||
},
|
||||
});
|
||||
const sortField = createSelectField({
|
||||
label: t("E03_Sort"),
|
||||
options: [
|
||||
{ value: "chainage", text: t("E03_Sort_Chainage") },
|
||||
{ value: "fs", text: t("E03_Sort_Fs") },
|
||||
],
|
||||
compact: true,
|
||||
onChange: (v) => {
|
||||
sort = v as SortMode;
|
||||
paintList();
|
||||
},
|
||||
});
|
||||
const ngBox = checkbox(t("E03_NgOnly"), false, (v) => {
|
||||
ngOnly = v;
|
||||
paintList();
|
||||
});
|
||||
const status = el("p", { className: "e03-muted", attrs: { "data-role": "status" } });
|
||||
const listHost = el("div", {
|
||||
className: "e03-panel",
|
||||
attrs: { style: "flex: 1 1 auto; min-height: 0" },
|
||||
});
|
||||
const analyzeButton = createButton({
|
||||
label: t("E03_Analyze"),
|
||||
variant: "filled",
|
||||
onClick: () => void analyze(),
|
||||
});
|
||||
const view3dButton = createButton({
|
||||
label: t("E03_View3d"),
|
||||
variant: "ghost",
|
||||
onClick: () => void show3d(),
|
||||
});
|
||||
const repButton = createButton({
|
||||
label: t("E03_RepSave"),
|
||||
variant: "ghost",
|
||||
onClick: () => void saveRep(),
|
||||
});
|
||||
const controls = el("div", { className: "e03-panel__row" });
|
||||
controls.append(condField.root, sortField.root);
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(repButton, view3dButton, analyzeButton);
|
||||
panel.append(controls, ngBox, status, listHost, actions);
|
||||
|
||||
// ---- 본문 ----
|
||||
const planHost = el("div");
|
||||
const toolbar = el("div", { className: "e03-card__head" });
|
||||
const sectionCard = el("div", { className: "e03-card" });
|
||||
const legendHost = el("div");
|
||||
const canvas = createSectionCanvas((i) => {
|
||||
picked = i;
|
||||
paintSection();
|
||||
});
|
||||
sectionCard.append(toolbar, canvas.root, legendHost);
|
||||
const resultHost = el("div", { className: "e03-result", attrs: { style: "min-height: 0" } });
|
||||
const body = el("div", { className: "e03-body" });
|
||||
body.append(sectionCard, resultHost);
|
||||
main.append(planHost, body);
|
||||
|
||||
const repOf = (row: StationRow): { on: boolean; user: boolean | null } => {
|
||||
const server = row.representative ?? { auto: [], user: null, on: false };
|
||||
if (!(row.key in repCache)) return { on: server.on, user: server.user };
|
||||
const user = repCache[row.key];
|
||||
return { on: user === null ? server.auto.length > 0 : user, user };
|
||||
};
|
||||
const repDirty = (): boolean => Object.keys(repCache).length > 0;
|
||||
|
||||
const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active);
|
||||
|
||||
/** 고른 측점 · 조건의 비탈 — 고른 게 없으면 안전율 가장 낮은 쪽 */
|
||||
const currentAnalysis = (view: SectionView): Analysis | null => {
|
||||
const list = view.analyses.filter((a) => a.condition === condition);
|
||||
if (!list.length) return null;
|
||||
const found = side ? list.find((a) => a.side === side) : null;
|
||||
if (found) return found;
|
||||
const worst = list
|
||||
.slice()
|
||||
.sort((a, b) => (a.critical?.fs ?? Infinity) - (b.critical?.fs ?? Infinity))[0];
|
||||
side = worst.side;
|
||||
return worst;
|
||||
};
|
||||
|
||||
const paintStatus = (): void => {
|
||||
const lines: string[] = [];
|
||||
if (!result) lines.push(t("E03_Loading"));
|
||||
else if (result.status === "none") lines.push(t("E03_NotYet"));
|
||||
else {
|
||||
lines.push(`${t("E03_Computed")} ${result.computed_at ?? ""}`);
|
||||
if (!result.fresh) lines.push(t("E03_Stale"));
|
||||
}
|
||||
if (result && !result.editable) lines.push(t("E03_ReadOnly"));
|
||||
if (repDirty()) lines.push(t("E03_RepUnsaved"));
|
||||
status.textContent = lines.join("\n");
|
||||
analyzeButton.disabled = !result?.editable;
|
||||
repButton.disabled = !result?.editable || !repDirty();
|
||||
view3dButton.disabled = result?.status !== "ok";
|
||||
};
|
||||
|
||||
const paintList = (): void => {
|
||||
if (!result) return;
|
||||
listHost.replaceChildren(
|
||||
buildList(
|
||||
{
|
||||
rows: result.stations,
|
||||
conditions: result.conditions,
|
||||
condition,
|
||||
sort,
|
||||
ngOnly,
|
||||
active,
|
||||
representative: repOf,
|
||||
editable: result.editable,
|
||||
},
|
||||
(key) => void select(key),
|
||||
(key) => {
|
||||
const row = result!.stations.find((r) => r.key === key)!;
|
||||
const now = repOf(row).on;
|
||||
const auto = (row.representative?.auto.length ?? 0) > 0;
|
||||
// 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌
|
||||
const next = !now === auto ? null : !now;
|
||||
if (next === (row.representative?.user ?? null)) delete repCache[key];
|
||||
else repCache[key] = next;
|
||||
sessionStorage.setItem(repKey(pid), JSON.stringify(repCache));
|
||||
paintList();
|
||||
paintStatus();
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const paintSection = (): void => {
|
||||
const view = active ? sections.get(active) : undefined;
|
||||
toolbar.replaceChildren();
|
||||
resultHost.replaceChildren();
|
||||
legendHost.replaceChildren();
|
||||
if (!view) {
|
||||
canvas.render(null);
|
||||
const text = result?.status === "ok" ? t("E03_PickStation") : t("E03_NotYet");
|
||||
resultHost.append(
|
||||
el("div", { className: "e03-card", children: [el("p", { className: "e03-empty", text })] }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const analysis = currentAnalysis(view);
|
||||
const sides = view.analyses.filter((a) => a.condition === condition);
|
||||
const sideField = createSelectField({
|
||||
options: sides.map((a) => ({
|
||||
value: a.side,
|
||||
text: `${t(a.side === "left" ? "E03_Left" : "E03_Right")} · ${t(a.role === "cut" ? "E03_Cut" : "E03_Fill")}`,
|
||||
})),
|
||||
value: analysis?.side,
|
||||
compact: true,
|
||||
ariaLabel: t("E03_Side"),
|
||||
onChange: (v) => {
|
||||
side = v;
|
||||
picked = null;
|
||||
paintSection();
|
||||
},
|
||||
});
|
||||
const title = el("h3", {
|
||||
className: "e03-card__title",
|
||||
text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
|
||||
});
|
||||
const flags = el("div", { className: "e03-panel__row" });
|
||||
flags.append(
|
||||
checkbox(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())),
|
||||
checkbox(
|
||||
t("E03_Show_Centers"),
|
||||
toggles.centers,
|
||||
(v) => ((toggles.centers = v), paintSection()),
|
||||
),
|
||||
checkbox(
|
||||
t("E03_Show_Candidates"),
|
||||
toggles.candidates,
|
||||
(v) => ((toggles.candidates = v), paintSection()),
|
||||
),
|
||||
);
|
||||
toolbar.append(title, sideField.root, flags);
|
||||
const draw = { view, analysis, toggles, picked };
|
||||
canvas.render(draw);
|
||||
legendHost.append(
|
||||
buildLegends(draw),
|
||||
el("p", { className: "e03-muted", text: t("E03_NoWalls") }),
|
||||
);
|
||||
resultHost.append(
|
||||
buildResult(view, analysis, picked, (i) => {
|
||||
picked = i;
|
||||
paintSection();
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const paintPlan = (): void => {
|
||||
planHost.replaceChildren(
|
||||
result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"),
|
||||
);
|
||||
};
|
||||
|
||||
function paint(): void {
|
||||
paintStatus();
|
||||
paintList();
|
||||
paintPlan();
|
||||
paintSection();
|
||||
}
|
||||
|
||||
async function select(key: string): Promise<void> {
|
||||
if (active !== key) picked = null;
|
||||
active = key;
|
||||
paintList();
|
||||
paintPlan();
|
||||
if (result?.status === "ok" && !sections.has(key) && !currentRow()?.error) {
|
||||
try {
|
||||
sections.set(key, await api<SectionView>(`${base}/section/${encodeURIComponent(key)}`));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
}
|
||||
}
|
||||
if (active === key) paintSection();
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
result = await api<Result2d>(`${base}/result2d`);
|
||||
sections.clear();
|
||||
const conds = result.conditions;
|
||||
if (!conds.includes(condition)) condition = conds[0] ?? "dry";
|
||||
condField.setOptions(
|
||||
conds.map((c) => ({ value: c, text: t(`E03_Cond_${c}` as LocaleKey) })),
|
||||
condition,
|
||||
);
|
||||
const keys = result.stations.map((r) => r.key);
|
||||
repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k)));
|
||||
if (!active || !keys.includes(active)) {
|
||||
// 처음 = 그 조건 가장 낮은 안전율 측점
|
||||
const worst = result.stations
|
||||
.filter((r) => r.min[condition] !== null && r.min[condition] !== undefined)
|
||||
.sort((a, b) => (a.min[condition] as number) - (b.min[condition] as number))[0];
|
||||
active = worst?.key ?? keys[0] ?? null;
|
||||
}
|
||||
paint();
|
||||
if (active) await select(active);
|
||||
if (result.running) await watch();
|
||||
}
|
||||
|
||||
/** 잠금판 + 진행률 — 끝나면 다시 읽음 */
|
||||
async function watch(): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
for (;;) {
|
||||
const p = await api<Progress>(`${base}/progress`);
|
||||
setLoadingOverlayProgress(
|
||||
p.percent / 100,
|
||||
`${t("E03_Analyzing")} ${p.percent}% · ${p.message}`,
|
||||
);
|
||||
if (!p.running) {
|
||||
if (p.error) showToast(p.error, "error");
|
||||
else showToast(t("E03_Done"), "success");
|
||||
break;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
async function analyze(): Promise<void> {
|
||||
try {
|
||||
const response = await api<{ status: string }>(`${base}/analyze2d`, { method: "POST" });
|
||||
if (response.status === "fresh") {
|
||||
showToast(t("E03_Fresh"), "info");
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
await watch();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRep(): Promise<void> {
|
||||
try {
|
||||
const overrides: Record<string, boolean | null> = {};
|
||||
for (const row of result?.stations ?? []) {
|
||||
const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null);
|
||||
if (user !== null) overrides[row.key] = user;
|
||||
}
|
||||
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
|
||||
repCache = {};
|
||||
sessionStorage.removeItem(repKey(pid));
|
||||
showToast(t("E03_RepSaved"), "success");
|
||||
await load();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function show3d(): Promise<void> {
|
||||
if (!result) return;
|
||||
try {
|
||||
const data = await api<Parameters<typeof openView3d>[0]>(`${base}/corridor3d`);
|
||||
await openView3d(data, result.stations, condition, t(`E03_Cond_${condition}` as LocaleKey));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
paintStatus();
|
||||
load().catch((error) => {
|
||||
status.textContent = error instanceof Error ? error.message : t("Common_Status_Error");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_UI_Plan.ts
|
||||
* E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이
|
||||
* 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점.
|
||||
* 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import {
|
||||
bandSegments,
|
||||
planFit,
|
||||
verdictOf,
|
||||
type Condition,
|
||||
type Point,
|
||||
type StationRow,
|
||||
} from "./logic/E03_StabSection_Logic";
|
||||
import { svgEl } from "./E03_StabSection_UI_Section";
|
||||
|
||||
const H = 96;
|
||||
const PAD = 10;
|
||||
|
||||
export function buildPlan(
|
||||
rows: StationRow[],
|
||||
condition: Condition,
|
||||
active: string | null,
|
||||
onSelect: (key: string) => void,
|
||||
): HTMLElement {
|
||||
const box = el("div", { className: "e03-plan e03-card" });
|
||||
const head = el("div", { className: "e03-card__head" });
|
||||
head.append(el("h3", { className: "e03-card__title", text: t("E03_Plan") }));
|
||||
const legend = el("div", { className: "e03-legend", attrs: { "data-role": "plan-legend" } });
|
||||
const counts = { ok: 0, ng: 0, none: 0 };
|
||||
rows.forEach((r) => (counts[verdictOf(r, condition)] += 1));
|
||||
for (const v of ["ng", "ok", "none"] as const) {
|
||||
if (!counts[v]) continue;
|
||||
const item = el("span", { className: `e03-verdict e03-verdict--${v}` });
|
||||
item.append(`${t(`E03_Verdict_${v}` as LocaleKey)} ${counts[v]}`);
|
||||
legend.append(item);
|
||||
}
|
||||
head.append(legend);
|
||||
box.append(head);
|
||||
|
||||
const host = el("div");
|
||||
box.append(host);
|
||||
const draw = (): void => {
|
||||
const W = Math.max(host.clientWidth, 300);
|
||||
const segs = bandSegments(rows, condition);
|
||||
const all: Point[] = segs.flatMap((s) => s.points);
|
||||
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" });
|
||||
if (all.length) {
|
||||
const fit = planFit(all, W, H, PAD);
|
||||
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
|
||||
for (const seg of segs) {
|
||||
svg.append(
|
||||
svgEl("polyline", {
|
||||
points: seg.points.map((p) => scr(p).join(",")).join(" "),
|
||||
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
|
||||
"data-verdict": seg.verdict,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null);
|
||||
const picked = centers.find((r) => r.key === active);
|
||||
if (picked) {
|
||||
const [x, y] = scr([picked.center![0]!, picked.center![1]!]);
|
||||
svg.append(
|
||||
svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }),
|
||||
);
|
||||
}
|
||||
svg.addEventListener("click", (ev) => {
|
||||
const box2 = svg.getBoundingClientRect();
|
||||
const px = ((ev.clientX - box2.left) / box2.width) * W;
|
||||
const py = ((ev.clientY - box2.top) / box2.height) * H;
|
||||
let best: StationRow | null = null;
|
||||
let dist = Infinity;
|
||||
for (const r of centers) {
|
||||
const [x, y] = scr([r.center![0]!, r.center![1]!]);
|
||||
const d = Math.hypot(x - px, y - py);
|
||||
if (d < dist) [best, dist] = [r, d];
|
||||
}
|
||||
if (best && dist < 30) onSelect(best.key);
|
||||
});
|
||||
}
|
||||
host.replaceChildren(svg);
|
||||
};
|
||||
new ResizeObserver(draw).observe(host);
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_UI_Result.ts
|
||||
* E03 우측 결과(PLAN 532-3) — 안전율 · 기준 · 판정 · 방법 · 수렴 · 중심/반경 · 방법별 최소 안전율 ·
|
||||
* 절편표(누르면 고름) · 절편 자유물체도(고른 절편 힘 화살) · 절편력 그래프(바닥 유효 수직응력 ·
|
||||
* 간극수압 · 동원 전단응력 — kPa 한 축). 계산은 logic.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import {
|
||||
forceArrows,
|
||||
niceTicks,
|
||||
pointsText,
|
||||
sliceShape,
|
||||
sliceStress,
|
||||
type Analysis,
|
||||
type Point,
|
||||
type SectionView,
|
||||
} from "./logic/E03_StabSection_Logic";
|
||||
import { materialClass, svgEl } from "./E03_StabSection_UI_Section";
|
||||
|
||||
const fmt = (v: number | null | undefined, d = 2): string =>
|
||||
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d);
|
||||
|
||||
const METHODS = ["ordinary", "bishop", "janbu", "spencer"] as const;
|
||||
|
||||
export function verdictChip(ok: boolean | null): HTMLElement {
|
||||
const v = ok === null ? "none" : ok ? "ok" : "ng";
|
||||
return el("span", {
|
||||
className: `e03-verdict e03-verdict--${v}`,
|
||||
text: t(`E03_Verdict_${v}` as LocaleKey),
|
||||
});
|
||||
}
|
||||
|
||||
function kpi(analysis: Analysis): HTMLElement {
|
||||
const c = analysis.critical;
|
||||
const dl = el("dl", { className: "e03-kpi" });
|
||||
const row = (label: string, value: HTMLElement | string, big = false): void => {
|
||||
const dd = el("dd", { className: big ? "is-big" : "" });
|
||||
dd.append(value);
|
||||
dl.append(el("dt", { text: label }), dd);
|
||||
};
|
||||
row(t("E03_Fs"), fmt(c?.fs, 3), true);
|
||||
row(t("E03_Verdict"), verdictChip(analysis.ok));
|
||||
row(t("E03_Required"), fmt(analysis.required));
|
||||
row(
|
||||
t("E03_Method"),
|
||||
`${t("E03_Method_bishop")} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`,
|
||||
);
|
||||
row(
|
||||
t("E03_Converged"),
|
||||
c
|
||||
? `${c.converged ? t("E03_Yes") : t("E03_No")} · ${c.iterations ?? "-"}${t("E03_Iter")}`
|
||||
: "-",
|
||||
);
|
||||
row(t("E03_Height"), `${fmt(analysis.height_m)} m`);
|
||||
if (c?.center) {
|
||||
row(t("E03_Center"), `${fmt(c.center[0])}, ${fmt(c.center[1])}`);
|
||||
row(t("E03_Radius"), `${fmt(c.radius)} m`);
|
||||
}
|
||||
return dl;
|
||||
}
|
||||
|
||||
function methodTable(analysis: Analysis): HTMLElement {
|
||||
const table = el("table", { className: "e03-table", attrs: { "data-role": "methods" } });
|
||||
const head = el("tr");
|
||||
const body = el("tr");
|
||||
for (const m of METHODS) {
|
||||
head.append(el("th", { className: "is-num", text: t(`E03_Method_${m}` as LocaleKey) }));
|
||||
body.append(el("td", { className: "is-num", text: fmt(analysis.min_by_method[m], 3) }));
|
||||
}
|
||||
table.append(el("thead", { children: [head] }), el("tbody", { children: [body] }));
|
||||
return table;
|
||||
}
|
||||
|
||||
function sliceTable(
|
||||
analysis: Analysis,
|
||||
picked: number | null,
|
||||
onPick: (i: number | null) => void,
|
||||
): HTMLElement {
|
||||
const wrap = el("div", { className: "e03-slices" });
|
||||
const table = el("table", { className: "e03-table", attrs: { "data-role": "slice-table" } });
|
||||
const cols: [LocaleKey, (s: Analysis["slices"][number]) => string][] = [
|
||||
["E03_Col_No", (s) => String(s.i)],
|
||||
["E03_Col_Width", (s) => fmt(s.width)],
|
||||
["E03_Col_Height", (s) => fmt(s.height)],
|
||||
["E03_Col_Alpha", (s) => fmt(s.alpha_deg, 1)],
|
||||
["E03_Col_W", (s) => fmt(s.weight + s.surcharge, 1)],
|
||||
["E03_Col_U", (s) => fmt(s.pore_pressure, 1)],
|
||||
["E03_Col_N", (s) => fmt(s.N_eff, 1)],
|
||||
["E03_Col_S", (s) => fmt(s.S, 1)],
|
||||
["E03_Col_C", (s) => fmt(s.c, 0)],
|
||||
["E03_Col_Phi", (s) => fmt(s.phi_deg, 0)],
|
||||
];
|
||||
table.append(
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", { children: cols.map(([k]) => el("th", { className: "is-num", text: t(k) })) }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = el("tbody");
|
||||
analysis.slices.forEach((s, i) => {
|
||||
const tr = el("tr", {
|
||||
className: picked === i ? "is-picked" : "",
|
||||
attrs: { "data-i": String(i) },
|
||||
});
|
||||
for (const [, f] of cols) tr.append(el("td", { className: "is-num", text: f(s) }));
|
||||
tr.addEventListener("click", () => onPick(picked === i ? null : i));
|
||||
body.append(tr);
|
||||
});
|
||||
table.append(body);
|
||||
wrap.append(table);
|
||||
if (picked !== null)
|
||||
queueMicrotask(() => body.children[picked]?.scrollIntoView({ block: "nearest" }));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 자유물체도 — 고른 절편을 판에 맞춰 키우고 힘 화살(길이 = 힘 비례) · 값 글 */
|
||||
function freeBody(view: SectionView, analysis: Analysis, picked: number | null): HTMLElement {
|
||||
const box = el("div", { className: "e03-fbd" });
|
||||
const slice = picked !== null ? analysis.slices[picked] : null;
|
||||
if (!slice || !analysis.critical) {
|
||||
box.append(el("p", { className: "e03-muted", text: t("E03_PickSlice") }));
|
||||
return box;
|
||||
}
|
||||
const W = 380;
|
||||
const H = 260;
|
||||
const shape = sliceShape(slice, view.surface, analysis.critical.points);
|
||||
const xs = shape.map((p) => p[0]);
|
||||
const zs = shape.map((p) => p[1]);
|
||||
const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs), 0.01);
|
||||
const k = 90 / span; // 절편 = 약 90 px
|
||||
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||
const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
|
||||
// 화면 = 횡단과 같은 방향(좌측 +offset 이 왼쪽)
|
||||
const scr = (p: Point): Point => [W / 2 - k * (p[0] - cx), H / 2 - k * (p[1] - cz)];
|
||||
const forces = [slice.weight, slice.surcharge, slice.N_eff, slice.U, slice.S].concat(
|
||||
[slice.E_left, slice.E_right, slice.X_left, slice.X_right].map(Math.abs),
|
||||
);
|
||||
const maxF = Math.max(...forces, 1e-6);
|
||||
const lengthPerKn = 80 / k / maxF; // 가장 큰 힘 = 80 px
|
||||
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "fbd" });
|
||||
const defs = svgEl("defs");
|
||||
const marker = svgEl("marker", {
|
||||
id: "e03-head",
|
||||
viewBox: "0 0 10 10",
|
||||
refX: 9,
|
||||
refY: 5,
|
||||
markerWidth: 7,
|
||||
markerHeight: 7,
|
||||
orient: "auto-start-reverse",
|
||||
});
|
||||
marker.append(svgEl("path", { d: "M0,0 L10,5 L0,10 z", fill: "context-stroke" }));
|
||||
defs.append(marker);
|
||||
svg.append(defs);
|
||||
svg.append(
|
||||
svgEl("polygon", {
|
||||
points: shape.map((p) => scr(p).join(",")).join(" "),
|
||||
class: `e03-region ${materialClass(view, slice.material ?? "")}`,
|
||||
}),
|
||||
);
|
||||
for (const a of forceArrows(slice, shape, analysis.direction, lengthPerKn)) {
|
||||
const [p0, p1] = [scr(a.from), scr(a.to)];
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
x1: p0[0],
|
||||
y1: p0[1],
|
||||
x2: p1[0],
|
||||
y2: p1[1],
|
||||
class: `e03-arrow e03-arrow--${a.name}`,
|
||||
"marker-end": "url(#e03-head)",
|
||||
"data-force": a.name,
|
||||
"data-value": String(a.value),
|
||||
}),
|
||||
);
|
||||
const tip = a.name.startsWith("E") || a.name.startsWith("X") || a.name === "Q" ? p0 : p1;
|
||||
svg.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: tip[0] + 4, y: tip[1] - 4, class: "e03-label" },
|
||||
`${a.name} ${fmt(a.value, 1)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
box.append(svg);
|
||||
box.append(
|
||||
el("p", {
|
||||
className: "e03-muted",
|
||||
text: `#${slice.i} · α ${fmt(slice.alpha_deg, 1)}° · b ${fmt(slice.width)} m · ${t("E03_FbdUnit")}`,
|
||||
}),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 절편력 그래프 — x = 절편 가운데(횡단과 같은 방향) · y = kPa(한 축) · 고른 절편 세로선 */
|
||||
function stressGraph(analysis: Analysis, picked: number | null): HTMLElement {
|
||||
const box = el("div", { className: "e03-graph" });
|
||||
const rows = sliceStress(analysis.slices);
|
||||
if (!rows.length) return box;
|
||||
const W = 380;
|
||||
const H = 200;
|
||||
const pad = { l: 44, r: 10, t: 10, b: 26 };
|
||||
const xs = rows.map((r) => r.x);
|
||||
const values = rows.flatMap((r) => [r.sigma, r.u, r.tau]);
|
||||
const ticks = niceTicks(Math.min(...values), Math.max(...values), 4);
|
||||
const [y0, y1] = [ticks[0], ticks[ticks.length - 1]];
|
||||
const [x0, x1] = [Math.min(...xs), Math.max(...xs)];
|
||||
const sx = (x: number): number =>
|
||||
pad.l + ((x1 - x) / Math.max(x1 - x0, 1e-6)) * (W - pad.l - pad.r);
|
||||
const sy = (y: number): number =>
|
||||
H - pad.b - ((y - y0) / Math.max(y1 - y0, 1e-6)) * (H - pad.t - pad.b);
|
||||
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "stress-graph" });
|
||||
const axis = svgEl("g", { class: "e03-axis" });
|
||||
for (const v of ticks) {
|
||||
axis.append(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: sy(v), y2: sy(v) }));
|
||||
axis.append(svgEl("text", { x: pad.l - 4, y: sy(v) + 4, "text-anchor": "end" }, String(v)));
|
||||
}
|
||||
axis.append(svgEl("text", { x: pad.l, y: H - 6 }, `${fmt(x1, 1)} m`));
|
||||
axis.append(svgEl("text", { x: W - pad.r, y: H - 6, "text-anchor": "end" }, `${fmt(x0, 1)} m`));
|
||||
axis.append(svgEl("text", { x: W / 2, y: H - 6, "text-anchor": "middle" }, t("E03_Offset")));
|
||||
svg.append(axis);
|
||||
if (picked !== null && rows[picked]) {
|
||||
const x = sx(rows[picked].x);
|
||||
svg.append(svgEl("line", { x1: x, x2: x, y1: pad.t, y2: H - pad.b, class: "e03-radius" }));
|
||||
}
|
||||
for (const key of ["sigma", "u", "tau"] as const) {
|
||||
const pts: Point[] = rows.map((r) => [sx(r.x), sy(r[key])]);
|
||||
svg.append(svgEl("polyline", { points: pointsText(pts), class: `e03-line e03-line--${key}` }));
|
||||
}
|
||||
// 가는 기둥마다 툴팁(절편 번호 · 세 값)
|
||||
rows.forEach((r, i) => {
|
||||
const w = (W - pad.l - pad.r) / rows.length;
|
||||
const hit = svgEl("rect", {
|
||||
x: sx(r.x) - w / 2,
|
||||
y: pad.t,
|
||||
width: w,
|
||||
height: H - pad.t - pad.b,
|
||||
fill: "transparent",
|
||||
});
|
||||
hit.append(
|
||||
svgEl(
|
||||
"title",
|
||||
{},
|
||||
`#${i + 1} · σ' ${fmt(r.sigma, 1)} · u ${fmt(r.u, 1)} · τ ${fmt(r.tau, 1)} kPa`,
|
||||
),
|
||||
);
|
||||
svg.append(hit);
|
||||
});
|
||||
box.append(svg);
|
||||
const legend = el("div", { className: "e03-legend" });
|
||||
for (const [key, label] of [
|
||||
["sigma", "E03_Graph_Sigma"],
|
||||
["u", "E03_Graph_U"],
|
||||
["tau", "E03_Graph_Tau"],
|
||||
] as const) {
|
||||
const item = el("span", { className: "e03-legend__item" });
|
||||
const sw = svgEl("svg", { width: 18, height: 8 });
|
||||
sw.append(svgEl("line", { x1: 0, x2: 18, y1: 4, y2: 4, class: `e03-line e03-line--${key}` }));
|
||||
item.append(sw as unknown as HTMLElement, t(label as LocaleKey));
|
||||
legend.append(item);
|
||||
}
|
||||
box.append(legend);
|
||||
return box;
|
||||
}
|
||||
|
||||
export function buildResult(
|
||||
view: SectionView,
|
||||
analysis: Analysis | null,
|
||||
picked: number | null,
|
||||
onPick: (i: number | null) => void,
|
||||
): HTMLElement {
|
||||
const root = el("div", { className: "e03-result e03-card" });
|
||||
if (!analysis || !analysis.critical) {
|
||||
root.append(el("p", { className: "e03-empty", text: t("E03_NoSurface") }));
|
||||
return root;
|
||||
}
|
||||
const head = (key: LocaleKey): HTMLElement =>
|
||||
el("h3", { className: "e03-card__title", text: t(key) });
|
||||
root.append(head("E03_Result"), kpi(analysis), methodTable(analysis));
|
||||
root.append(head("E03_Slices"), sliceTable(analysis, picked, onPick));
|
||||
root.append(head("E03_Fbd"), freeBody(view, analysis, picked));
|
||||
root.append(head("E03_Graph"), stressGraph(analysis, picked));
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_UI_Section.ts
|
||||
* E03 가운데 횡단(PLAN 532-3) — 지층 색 · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자
|
||||
* 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름).
|
||||
* 도형 좌표 = 결과 좌표 그대로 · 묶음 transform(`viewMatrix`) 하나 — 계산은 logic.
|
||||
* 줌 · 팬 = 공용 `attachSvgZoomPan`(휠 = 줌 · 가운데 끌기 = 팬 · 두 번 누름 = 원배율).
|
||||
* ========================================================================== */
|
||||
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
||||
import {
|
||||
bandIndex,
|
||||
bandLegend,
|
||||
fitView,
|
||||
pointsText,
|
||||
sliceShape,
|
||||
toModel,
|
||||
toScreen,
|
||||
viewMatrix,
|
||||
type Analysis,
|
||||
type Point,
|
||||
type SectionView,
|
||||
} from "./logic/E03_StabSection_Logic";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const PAD = 28;
|
||||
|
||||
export function svgEl<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number> = {},
|
||||
text?: string,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const node = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
export interface SectionToggles {
|
||||
map: boolean;
|
||||
centers: boolean;
|
||||
candidates: boolean;
|
||||
}
|
||||
|
||||
export interface SectionDraw {
|
||||
view: SectionView;
|
||||
analysis: Analysis | null;
|
||||
toggles: SectionToggles;
|
||||
picked: number | null;
|
||||
}
|
||||
|
||||
const fmt = (v: number | null | undefined, d = 3): string =>
|
||||
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d);
|
||||
|
||||
/** 지층 색 번호 — 물성 표 차례(정해진 차례 · 순환 없이 8 넘으면 마지막 색) */
|
||||
export function materialClass(view: SectionView, id: string): string {
|
||||
const i = view.materials.findIndex((m) => m.id === id);
|
||||
return `e03-mat-${Math.min(Math.max(i, 0), 7)}`;
|
||||
}
|
||||
|
||||
/** 범례 줄 — 등안전율 띠(기준안전율 기준 실제 값) · 지층 */
|
||||
export function buildLegends(draw: SectionDraw): HTMLElement {
|
||||
const box = el("div", { className: "e03-legend" });
|
||||
const required = draw.analysis?.required ?? null;
|
||||
if (draw.toggles.map && draw.analysis?.grid) {
|
||||
box.append(el("span", { className: "e03-muted", text: t("E03_Legend_Map") }));
|
||||
for (const band of bandLegend(required)) {
|
||||
const text =
|
||||
band.from === null
|
||||
? `< ${band.to}`
|
||||
: band.to === null
|
||||
? `≥ ${band.from}`
|
||||
: `${band.from}–${band.to}`;
|
||||
const item = el("span", {
|
||||
className: "e03-legend__item",
|
||||
attrs: { "data-band": String(band.index) },
|
||||
});
|
||||
item.append(el("span", { className: `e03-legend__swatch e03-band-${band.index}` }), text);
|
||||
box.append(item);
|
||||
}
|
||||
}
|
||||
const used = new Set(draw.view.regions.map((r) => r.material_id));
|
||||
box.append(el("span", { className: "e03-muted", text: t("E03_Legend_Layers") }));
|
||||
for (const m of draw.view.materials.filter((x) => used.has(x.id))) {
|
||||
const item = el("span", { className: "e03-legend__item" });
|
||||
item.append(
|
||||
el("span", { className: `e03-legend__swatch ${materialClass(draw.view, m.id)}` }),
|
||||
m.name,
|
||||
);
|
||||
box.append(item);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
export interface SectionCanvas {
|
||||
root: HTMLElement;
|
||||
render: (draw: SectionDraw | null) => void;
|
||||
}
|
||||
|
||||
export function createSectionCanvas(onPick: (index: number | null) => void): SectionCanvas {
|
||||
const root = el("div", { className: "e03-section" });
|
||||
const tip = el("div", { className: "e03-tip" });
|
||||
let last: SectionDraw | null = null;
|
||||
let zoom: ZoomPanState | undefined;
|
||||
let zoomKey = "";
|
||||
|
||||
const paint = (): void => {
|
||||
root.replaceChildren(tip);
|
||||
const draw = last;
|
||||
if (!draw) return;
|
||||
const width = Math.max(root.clientWidth, 320);
|
||||
const height = Math.max(root.clientHeight, 240);
|
||||
const { view, analysis, toggles } = draw;
|
||||
const fit = fitView(view, width, height, PAD);
|
||||
const svg = svgEl("svg", { viewBox: `0 0 ${width} ${height}`, "data-role": "section" });
|
||||
const layer = svgEl("g");
|
||||
const world = svgEl("g", { transform: viewMatrix(fit), "data-role": "world" });
|
||||
const ns = { "vector-effect": "non-scaling-stroke" };
|
||||
const unit = 1 / fit.k; // 1 px 의 결과 좌표 길이
|
||||
|
||||
for (const region of view.regions) {
|
||||
const ring = svgEl("polygon", {
|
||||
points: pointsText(region.polygon),
|
||||
class: `e03-region ${materialClass(view, region.material_id)}`,
|
||||
...ns,
|
||||
});
|
||||
world.append(ring);
|
||||
}
|
||||
// 등안전율 색 지도 — 격자 중심마다 칸(이웃 중심 사이 절반)
|
||||
const grid = analysis?.grid;
|
||||
if (grid && toggles.map) {
|
||||
const half = (arr: number[], i: number): [number, number] => {
|
||||
const lo =
|
||||
i > 0 ? (arr[i] - arr[i - 1]) / 2 : i + 1 < arr.length ? (arr[i + 1] - arr[i]) / 2 : 0.5;
|
||||
const hi = i + 1 < arr.length ? (arr[i + 1] - arr[i]) / 2 : lo;
|
||||
return [arr[i] - lo, arr[i] + hi];
|
||||
};
|
||||
const cells = svgEl("g", { "data-role": "safety-map", opacity: 0.8 });
|
||||
grid.z.forEach((_z, iz) => {
|
||||
grid.x.forEach((_x, ix) => {
|
||||
const band = bandIndex(grid.fs[iz]?.[ix], analysis.required);
|
||||
if (band === null) return;
|
||||
const [x0, x1] = half(grid.x, ix).sort((a, b) => a - b);
|
||||
const [z0, z1] = half(grid.z, iz).sort((a, b) => a - b);
|
||||
const cell = svgEl("rect", {
|
||||
x: x0,
|
||||
y: z0,
|
||||
width: x1 - x0,
|
||||
height: z1 - z0,
|
||||
class: `e03-cell e03-band-${band}`,
|
||||
"data-fs": String(grid.fs[iz][ix]),
|
||||
});
|
||||
cells.append(cell);
|
||||
});
|
||||
});
|
||||
world.append(cells);
|
||||
}
|
||||
if (grid && toggles.centers) {
|
||||
const dots = svgEl("g", { "data-role": "centers" });
|
||||
grid.z.forEach((z) =>
|
||||
grid.x.forEach((x) =>
|
||||
dots.append(svgEl("circle", { cx: x, cy: z, r: 1.6 * unit, class: "e03-gridpt" })),
|
||||
),
|
||||
);
|
||||
world.append(dots);
|
||||
}
|
||||
// 노면 하중 — 지표 위 띠 + 아래 화살 눈금
|
||||
for (const load of view.surcharge) {
|
||||
const zTop = Math.max(
|
||||
...view.surface.filter((p) => p[0] >= load.x0 && p[0] <= load.x1).map((p) => p[1]),
|
||||
);
|
||||
if (!Number.isFinite(zTop)) continue;
|
||||
const up = zTop + 14 * unit;
|
||||
const g = svgEl("g", { class: "e03-load", "data-role": "surcharge" });
|
||||
g.append(
|
||||
svgEl("polyline", {
|
||||
points: pointsText([
|
||||
[load.x0, up],
|
||||
[load.x1, up],
|
||||
]),
|
||||
...ns,
|
||||
}),
|
||||
);
|
||||
const n = Math.max(2, Math.round((load.x1 - load.x0) / (10 * unit)));
|
||||
for (let i = 0; i <= n; i += 1) {
|
||||
const x = load.x0 + ((load.x1 - load.x0) * i) / n;
|
||||
g.append(
|
||||
svgEl("polyline", {
|
||||
points: pointsText([
|
||||
[x, up],
|
||||
[x, zTop + 3 * unit],
|
||||
]),
|
||||
...ns,
|
||||
}),
|
||||
);
|
||||
}
|
||||
world.append(g);
|
||||
}
|
||||
world.append(
|
||||
svgEl("polyline", { points: pointsText(view.surface), class: "e03-surface", ...ns }),
|
||||
);
|
||||
const water = analysis ? view.water[analysis.condition] : null;
|
||||
if (water) {
|
||||
world.append(
|
||||
svgEl("polyline", {
|
||||
points: pointsText(water),
|
||||
class: "e03-water",
|
||||
"data-role": "water",
|
||||
...ns,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const critical = analysis?.critical ?? null;
|
||||
if (analysis && toggles.candidates) {
|
||||
for (const c of analysis.candidates) {
|
||||
if (critical && c.method === critical.method && c.kind === critical.kind) continue;
|
||||
const line = svgEl("polyline", {
|
||||
points: pointsText(c.points),
|
||||
class: "e03-candidate",
|
||||
"data-role": "candidate",
|
||||
...ns,
|
||||
});
|
||||
line.append(
|
||||
svgEl(
|
||||
"title",
|
||||
{},
|
||||
`${t(`E03_Method_${c.method}` as never)} · ${c.kind} · Fs ${fmt(c.fs)}`,
|
||||
),
|
||||
);
|
||||
world.append(line);
|
||||
}
|
||||
}
|
||||
const labels = svgEl("g", { "data-role": "labels" });
|
||||
if (critical && analysis) {
|
||||
const slices = svgEl("g", { "data-role": "slices" });
|
||||
analysis.slices.forEach((slice, i) => {
|
||||
const poly = svgEl("polygon", {
|
||||
points: pointsText(sliceShape(slice, view.surface, critical.points)),
|
||||
class: `e03-slice${draw.picked === i ? " is-picked" : ""}`,
|
||||
"data-i": String(i),
|
||||
...ns,
|
||||
});
|
||||
poly.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
onPick(draw.picked === i ? null : i);
|
||||
});
|
||||
slices.append(poly);
|
||||
});
|
||||
world.append(slices);
|
||||
if (critical.center && critical.entry && critical.exit) {
|
||||
const c = critical.center;
|
||||
world.append(
|
||||
svgEl("polyline", {
|
||||
points: pointsText([critical.entry, c, critical.exit]),
|
||||
class: "e03-radius",
|
||||
...ns,
|
||||
}),
|
||||
svgEl("circle", { cx: c[0], cy: c[1], r: 3 * unit, class: "e03-gridpt" }),
|
||||
);
|
||||
}
|
||||
world.append(
|
||||
svgEl("polyline", {
|
||||
points: pointsText(critical.points),
|
||||
class: "e03-critical",
|
||||
"data-role": "critical",
|
||||
...ns,
|
||||
}),
|
||||
);
|
||||
// 글 — 안전율 · 중심 · 반경(화면 좌표 · 뒤집지 않음)
|
||||
const anchor: Point =
|
||||
critical.center ?? critical.points[Math.floor(critical.points.length / 2)];
|
||||
const [ax, ay] = toScreen(fit, anchor);
|
||||
labels.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: ax + 8, y: ay - 10, class: "e03-label e03-label--fs", "data-role": "fs-label" },
|
||||
`Fs = ${fmt(critical.fs)}`,
|
||||
),
|
||||
);
|
||||
if (critical.center) {
|
||||
labels.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: ax + 8, y: ay + 6, class: "e03-label" },
|
||||
`Xc ${fmt(critical.center[0], 2)} · Zc ${fmt(critical.center[1], 2)} · R ${fmt(critical.radius, 2)} m`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
layer.append(world, labels);
|
||||
svg.append(layer);
|
||||
svg.addEventListener("click", () => draw.picked !== null && onPick(null));
|
||||
root.append(svg);
|
||||
|
||||
// 마우스 아래 offset · 표고 (줌 상태 되돌려 셈)
|
||||
svg.addEventListener("mousemove", (ev) => {
|
||||
const box = svg.getBoundingClientRect();
|
||||
const sx = ((ev.clientX - box.left) / box.width) * width;
|
||||
const sy = ((ev.clientY - box.top) / box.height) * height;
|
||||
const s = zoom ?? { scale: 1, tx: 0, ty: 0 };
|
||||
const [x, z] = toModel(fit, [(sx - s.tx) / s.scale, (sy - s.ty) / s.scale]);
|
||||
tip.textContent = `${t("E03_Offset")} ${x.toFixed(2)} m · EL ${z.toFixed(2)} m`;
|
||||
tip.style.display = "block";
|
||||
tip.style.left = `${ev.clientX - box.left + 12}px`;
|
||||
tip.style.top = `${ev.clientY - box.top + 12}px`;
|
||||
});
|
||||
svg.addEventListener("mouseleave", () => (tip.style.display = "none"));
|
||||
|
||||
const key = `${view.key}|${width}x${height}`;
|
||||
if (key !== zoomKey) zoom = undefined;
|
||||
zoomKey = key;
|
||||
attachSvgZoomPan({
|
||||
svg,
|
||||
layer,
|
||||
widthPx: width,
|
||||
heightPx: height,
|
||||
pad: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
wheelZoom: true,
|
||||
maxScale: 20,
|
||||
initial: zoom,
|
||||
onChange: (state) => (zoom = state),
|
||||
});
|
||||
};
|
||||
|
||||
new ResizeObserver(() => last && paint()).observe(root);
|
||||
return {
|
||||
root,
|
||||
render(draw) {
|
||||
last = draw;
|
||||
paint();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_UI_Style.css
|
||||
* E03 2D 측점 해석 — 판정 색(상태 · 아이콘 + 글과 함께) · 등안전율 띠(빨강 ↔ 회색 ↔ 파랑) ·
|
||||
* 지층 색(정해진 차례) · 밝음 · 어두움 각각 고른 값
|
||||
* ========================================================================== */
|
||||
|
||||
:root {
|
||||
--e03-ok: #0ca30c;
|
||||
--e03-ng: #d03b3b;
|
||||
--e03-none: #9a9a95;
|
||||
--e03-band-0: #a31f1f;
|
||||
--e03-band-1: #e34948;
|
||||
--e03-band-2: #f29b97;
|
||||
--e03-band-3: #e6e4df;
|
||||
--e03-band-4: #9ec5f4;
|
||||
--e03-band-5: #3987e5;
|
||||
--e03-band-6: #184f95;
|
||||
--e03-mat-0: #2a78d6;
|
||||
--e03-mat-1: #eb6834;
|
||||
--e03-mat-2: #1baf7a;
|
||||
--e03-mat-3: #eda100;
|
||||
--e03-mat-4: #e87ba4;
|
||||
--e03-mat-5: #008300;
|
||||
--e03-mat-6: #6250d6;
|
||||
--e03-mat-7: #e34948;
|
||||
--e03-water: #1c74d9;
|
||||
--e03-critical: #0b0b0b;
|
||||
--e03-surface-line: #52514e;
|
||||
--e03-plot: #fcfcfb;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--e03-band-0: #c43c3c;
|
||||
--e03-band-1: #e66767;
|
||||
--e03-band-2: #8f4a48;
|
||||
--e03-band-3: #383835;
|
||||
--e03-band-4: #1c5cab;
|
||||
--e03-band-5: #3987e5;
|
||||
--e03-band-6: #86b6ef;
|
||||
--e03-mat-0: #3987e5;
|
||||
--e03-mat-1: #d95926;
|
||||
--e03-mat-2: #199e70;
|
||||
--e03-mat-3: #c98500;
|
||||
--e03-mat-4: #d55181;
|
||||
--e03-mat-5: #008300;
|
||||
--e03-mat-6: #9085e9;
|
||||
--e03-mat-7: #e66767;
|
||||
--e03-water: #6da7ec;
|
||||
--e03-critical: #f0efec;
|
||||
--e03-surface-line: #c3c2b7;
|
||||
--e03-plot: #1a1a19;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--e03-band-0: #c43c3c;
|
||||
--e03-band-1: #e66767;
|
||||
--e03-band-2: #8f4a48;
|
||||
--e03-band-3: #383835;
|
||||
--e03-band-4: #1c5cab;
|
||||
--e03-band-5: #3987e5;
|
||||
--e03-band-6: #86b6ef;
|
||||
--e03-mat-0: #3987e5;
|
||||
--e03-mat-1: #d95926;
|
||||
--e03-mat-2: #199e70;
|
||||
--e03-mat-3: #c98500;
|
||||
--e03-mat-4: #d55181;
|
||||
--e03-mat-5: #008300;
|
||||
--e03-mat-6: #9085e9;
|
||||
--e03-mat-7: #e66767;
|
||||
--e03-water: #6da7ec;
|
||||
--e03-critical: #f0efec;
|
||||
--e03-surface-line: #c3c2b7;
|
||||
--e03-plot: #1a1a19;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 틀 ---- */
|
||||
.e03-page {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: var(--spacing-8);
|
||||
height: 100%;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.e03-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.e03-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.e03-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.e03-card__title {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.e03-muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.e03-empty {
|
||||
margin: auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body);
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ---- 좌측 측점 목록 ---- */
|
||||
.e03-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.e03-panel__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.e03-panel__row > .ui-field {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.e03-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.e03-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.e03-list table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.e03-list th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: var(--spacing-4);
|
||||
background: var(--color-surface-raised);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e03-list td {
|
||||
padding: 3px var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e03-list td.is-num,
|
||||
.e03-list th.is-num,
|
||||
.e03-table td.is-num,
|
||||
.e03-table th.is-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.e03-list tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e03-list tbody tr:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.e03-list tbody tr.is-active {
|
||||
background: var(--color-lavender-wash);
|
||||
outline: 1px solid var(--color-accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.e03-verdict {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e03-verdict::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--e03-none);
|
||||
}
|
||||
|
||||
.e03-verdict--ok::before {
|
||||
background: var(--e03-ok);
|
||||
}
|
||||
|
||||
.e03-verdict--ng::before {
|
||||
background: var(--e03-ng);
|
||||
border-radius: 2px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.e03-star {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0 2px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e03-star.is-on {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.e03-star.is-user {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.e03-star:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- 평면 띠 ---- */
|
||||
.e03-plan svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
background: var(--e03-plot);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.e03-plan__seg {
|
||||
fill: none;
|
||||
stroke-width: 7;
|
||||
stroke-linecap: butt;
|
||||
stroke-linejoin: round;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e03-plan__seg--ok {
|
||||
stroke: var(--e03-ok);
|
||||
}
|
||||
|
||||
.e03-plan__seg--ng {
|
||||
stroke: var(--e03-ng);
|
||||
}
|
||||
|
||||
.e03-plan__seg--none {
|
||||
stroke: var(--e03-none);
|
||||
}
|
||||
|
||||
.e03-plan__pick {
|
||||
fill: var(--e03-plot);
|
||||
stroke: var(--e03-critical);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.e03-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
align-items: center;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.e03-legend__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e03-legend__swatch {
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- 횡단 ---- */
|
||||
.e03-section {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.e03-section svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--e03-plot);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.e03-region {
|
||||
fill-opacity: 0.32;
|
||||
stroke: var(--e03-surface-line);
|
||||
stroke-opacity: 0.45;
|
||||
stroke-width: 0.6;
|
||||
}
|
||||
|
||||
.e03-surface {
|
||||
fill: none;
|
||||
stroke: var(--e03-surface-line);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
.e03-water {
|
||||
fill: none;
|
||||
stroke: var(--e03-water);
|
||||
stroke-width: 1.6;
|
||||
stroke-dasharray: 8 4;
|
||||
}
|
||||
|
||||
.e03-load {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.e03-cell {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.e03-gridpt {
|
||||
fill: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e03-candidate {
|
||||
fill: none;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-opacity: 0.55;
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.e03-critical {
|
||||
fill: none;
|
||||
stroke: var(--e03-critical);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.e03-radius {
|
||||
stroke: var(--e03-critical);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 3;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.e03-slice {
|
||||
fill: transparent;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-opacity: 0.35;
|
||||
stroke-width: 0.6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e03-slice:hover {
|
||||
fill: var(--color-accent);
|
||||
fill-opacity: 0.18;
|
||||
}
|
||||
|
||||
.e03-slice.is-picked {
|
||||
fill: var(--color-accent);
|
||||
fill-opacity: 0.35;
|
||||
stroke: var(--color-accent);
|
||||
stroke-opacity: 1;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.e03-label {
|
||||
fill: var(--color-text);
|
||||
font-size: 12px;
|
||||
paint-order: stroke;
|
||||
stroke: var(--e03-plot);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.e03-label--fs {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.e03-axis line {
|
||||
stroke: var(--color-border);
|
||||
}
|
||||
|
||||
.e03-axis text {
|
||||
fill: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.e03-tip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- 우측 결과 ---- */
|
||||
.e03-result {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.e03-kpi {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.e03-kpi dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e03-kpi dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.e03-kpi dd.is-big {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.e03-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.e03-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface-raised);
|
||||
padding: 2px var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e03-table td {
|
||||
padding: 2px var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e03-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e03-table tbody tr.is-picked {
|
||||
background: var(--color-lavender-wash);
|
||||
}
|
||||
|
||||
.e03-slices {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.e03-fbd svg,
|
||||
.e03-graph svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--e03-plot);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.e03-arrow {
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.e03-arrow--W,
|
||||
.e03-arrow--Q {
|
||||
stroke: var(--e03-mat-6);
|
||||
}
|
||||
|
||||
.e03-arrow--N {
|
||||
stroke: var(--e03-mat-0);
|
||||
}
|
||||
|
||||
.e03-arrow--U {
|
||||
stroke: var(--e03-water);
|
||||
stroke-dasharray: 4 2;
|
||||
}
|
||||
|
||||
.e03-arrow--S {
|
||||
stroke: var(--e03-mat-1);
|
||||
}
|
||||
|
||||
.e03-arrow--EL,
|
||||
.e03-arrow--ER,
|
||||
.e03-arrow--XL,
|
||||
.e03-arrow--XR {
|
||||
stroke: var(--e03-mat-2);
|
||||
}
|
||||
|
||||
.e03-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.e03-line--sigma {
|
||||
stroke: var(--e03-mat-0);
|
||||
}
|
||||
|
||||
.e03-line--u {
|
||||
stroke: var(--e03-water);
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
|
||||
.e03-line--tau {
|
||||
stroke: var(--e03-mat-1);
|
||||
}
|
||||
|
||||
/* ---- 3D 보기 ---- */
|
||||
.e03-view3d {
|
||||
position: fixed;
|
||||
inset: 5vh 5vw;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.e03-view3d__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.e03-view3d__canvas {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.e03-view3d__canvas canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---- 색 칸(지층 · 등안전율 띠) ---- */
|
||||
.e03-mat-0 {
|
||||
fill: var(--e03-mat-0);
|
||||
background: var(--e03-mat-0);
|
||||
}
|
||||
|
||||
.e03-mat-1 {
|
||||
fill: var(--e03-mat-1);
|
||||
background: var(--e03-mat-1);
|
||||
}
|
||||
|
||||
.e03-mat-2 {
|
||||
fill: var(--e03-mat-2);
|
||||
background: var(--e03-mat-2);
|
||||
}
|
||||
|
||||
.e03-mat-3 {
|
||||
fill: var(--e03-mat-3);
|
||||
background: var(--e03-mat-3);
|
||||
}
|
||||
|
||||
.e03-mat-4 {
|
||||
fill: var(--e03-mat-4);
|
||||
background: var(--e03-mat-4);
|
||||
}
|
||||
|
||||
.e03-mat-5 {
|
||||
fill: var(--e03-mat-5);
|
||||
background: var(--e03-mat-5);
|
||||
}
|
||||
|
||||
.e03-mat-6 {
|
||||
fill: var(--e03-mat-6);
|
||||
background: var(--e03-mat-6);
|
||||
}
|
||||
|
||||
.e03-mat-7 {
|
||||
fill: var(--e03-mat-7);
|
||||
background: var(--e03-mat-7);
|
||||
}
|
||||
|
||||
.e03-band-0 {
|
||||
fill: var(--e03-band-0);
|
||||
background: var(--e03-band-0);
|
||||
}
|
||||
|
||||
.e03-band-1 {
|
||||
fill: var(--e03-band-1);
|
||||
background: var(--e03-band-1);
|
||||
}
|
||||
|
||||
.e03-band-2 {
|
||||
fill: var(--e03-band-2);
|
||||
background: var(--e03-band-2);
|
||||
}
|
||||
|
||||
.e03-band-3 {
|
||||
fill: var(--e03-band-3);
|
||||
background: var(--e03-band-3);
|
||||
}
|
||||
|
||||
.e03-band-4 {
|
||||
fill: var(--e03-band-4);
|
||||
background: var(--e03-band-4);
|
||||
}
|
||||
|
||||
.e03-band-5 {
|
||||
fill: var(--e03-band-5);
|
||||
background: var(--e03-band-5);
|
||||
}
|
||||
|
||||
.e03-band-6 {
|
||||
fill: var(--e03-band-6);
|
||||
background: var(--e03-band-6);
|
||||
}
|
||||
|
||||
.e03-swatch-none {
|
||||
background: var(--e03-none);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_UI_View3D.ts
|
||||
* E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만).
|
||||
* 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) ·
|
||||
* 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import {
|
||||
bandIndex,
|
||||
RATIO_BREAKS,
|
||||
type Condition,
|
||||
type StationRow,
|
||||
} from "./logic/E03_StabSection_Logic";
|
||||
|
||||
interface Corridor3d {
|
||||
origin: [number, number, number];
|
||||
ribbons: {
|
||||
kind: string;
|
||||
side: string;
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
positions: number[];
|
||||
}[];
|
||||
}
|
||||
|
||||
/** 측점 하나 그 조건 최소 비(안전율 ÷ 기준)의 띠 */
|
||||
function stationBand(row: StationRow, condition: Condition): number | null {
|
||||
let best: number | null = null;
|
||||
for (const c of Object.values(row.cases[condition] ?? {})) {
|
||||
const band = bandIndex(c.fs, c.required);
|
||||
if (band !== null && (best === null || band < best)) best = band;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function cssColor(name: string): THREE.Color {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return new THREE.Color(value || "#888888");
|
||||
}
|
||||
|
||||
export async function openView3d(
|
||||
data: Corridor3d,
|
||||
rows: StationRow[],
|
||||
condition: Condition,
|
||||
conditionLabel: string,
|
||||
): Promise<void> {
|
||||
const shell = el("div", { className: "e03-view3d", attrs: { "data-role": "view3d" } });
|
||||
const head = el("div", { className: "e03-view3d__head" });
|
||||
const legend = el("div", { className: "e03-legend" });
|
||||
legend.append(
|
||||
el("span", { className: "e03-muted", text: `${conditionLabel} · ${t("E03_View3d_Legend")}` }),
|
||||
);
|
||||
const edges = [null, ...RATIO_BREAKS, null];
|
||||
for (let i = 0; i <= RATIO_BREAKS.length; i += 1) {
|
||||
const item = el("span", { className: "e03-legend__item" });
|
||||
const text =
|
||||
edges[i] === null
|
||||
? `< ${edges[i + 1]}`
|
||||
: edges[i + 1] === null
|
||||
? `≥ ${edges[i]}`
|
||||
: `${edges[i]}–${edges[i + 1]}`;
|
||||
item.append(el("span", { className: `e03-legend__swatch e03-band-${i}` }), text);
|
||||
legend.append(item);
|
||||
}
|
||||
const noneItem = el("span", { className: "e03-legend__item" });
|
||||
noneItem.append(
|
||||
el("span", { className: "e03-legend__swatch e03-swatch-none" }),
|
||||
t("E03_Verdict_none"),
|
||||
);
|
||||
legend.append(noneItem);
|
||||
const close = createButton({ label: t("Common_Btn_Close"), variant: "ghost", size: "sm" });
|
||||
head.append(el("h3", { className: "e03-card__title", text: t("E03_View3d") }), legend, close);
|
||||
const host = el("div", { className: "e03-view3d__canvas" });
|
||||
shell.append(head, host);
|
||||
document.body.append(shell);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
host.append(renderer.domElement);
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = cssColor("--e03-plot");
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.75));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||
sun.position.set(200, 400, 300);
|
||||
scene.add(sun);
|
||||
|
||||
// 측점 누가 → 색
|
||||
const sorted = rows.slice().sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const bandColors = [...Array(RATIO_BREAKS.length + 1).keys()].map((i) =>
|
||||
cssColor(`--e03-band-${i}`),
|
||||
);
|
||||
const noneColor = cssColor("--e03-none");
|
||||
const colorAt = (chainage: number): THREE.Color => {
|
||||
let lo = 0;
|
||||
let hi = sorted.length - 1;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (sorted[mid].chainage_m <= chainage) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const near =
|
||||
sorted.length &&
|
||||
Math.abs(sorted[hi].chainage_m - chainage) < Math.abs(sorted[lo].chainage_m - chainage)
|
||||
? sorted[hi]
|
||||
: sorted[lo];
|
||||
const band = near ? stationBand(near, condition) : null;
|
||||
return band === null ? noneColor : bandColors[band];
|
||||
};
|
||||
|
||||
const group = new THREE.Group();
|
||||
for (const ribbon of data.ribbons) {
|
||||
const rowsN = ribbon.chainages.length;
|
||||
const cols = ribbon.colCount;
|
||||
if (rowsN < 2 || cols < 2) continue;
|
||||
const pos = new Float32Array(rowsN * cols * 3);
|
||||
const col = new Float32Array(rowsN * cols * 3);
|
||||
for (let r = 0; r < rowsN; r += 1) {
|
||||
const c3 = colorAt(ribbon.chainages[r]);
|
||||
for (let c = 0; c < cols; c += 1) {
|
||||
const i = (r * cols + c) * 3;
|
||||
const [x, y, z] = ribbon.positions.slice(i, i + 3);
|
||||
pos.set([x, z, -y], i);
|
||||
col.set([c3.r, c3.g, c3.b], i);
|
||||
}
|
||||
}
|
||||
const index: number[] = [];
|
||||
for (let r = 0; r + 1 < rowsN; r += 1) {
|
||||
for (let c = 0; c + 1 < cols; c += 1) {
|
||||
const a = r * cols + c;
|
||||
index.push(a, a + cols, a + 1, a + 1, a + cols, a + cols + 1);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(col, 3));
|
||||
geometry.setIndex(index);
|
||||
geometry.computeVertexNormals();
|
||||
const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
|
||||
group.add(new THREE.Mesh(geometry, material));
|
||||
}
|
||||
scene.add(group);
|
||||
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000));
|
||||
camera.position
|
||||
.copy(sphere.center)
|
||||
.add(new THREE.Vector3(0, sphere.radius * 1.4, sphere.radius * 1.2));
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.target.copy(sphere.center);
|
||||
controls.update();
|
||||
|
||||
let frame = 0;
|
||||
const resize = (): void => {
|
||||
const w = Math.max(host.clientWidth, 1);
|
||||
const h = Math.max(host.clientHeight, 1);
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
const loop = (): void => {
|
||||
frame = requestAnimationFrame(loop);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
resize();
|
||||
loop();
|
||||
(window as unknown as { __e03View3d?: unknown }).__e03View3d = {
|
||||
meshes: group.children.length,
|
||||
vertices: group.children.reduce(
|
||||
(n, m) => n + ((m as THREE.Mesh).geometry.getAttribute("position").count ?? 0),
|
||||
0,
|
||||
),
|
||||
};
|
||||
|
||||
const dispose = (): void => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
group.traverse((o) => {
|
||||
if (o instanceof THREE.Mesh) {
|
||||
o.geometry.dispose();
|
||||
(o.material as THREE.Material).dispose();
|
||||
}
|
||||
});
|
||||
renderer.dispose();
|
||||
shell.remove();
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent): void => {
|
||||
if (ev.key === "Escape") dispose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
close.addEventListener("click", dispose);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"""E03 2D 해석 결과 → 화면 꼴 (요구 345 · 347 · PLAN 532-3) — storage 를 모르는 순수 함수.
|
||||
|
||||
엔진 출력 = `E03_StabSection_Engine_Station.analyze_station` 그대로(cases × methods).
|
||||
판정 = 대표 방법(Bishop) 최소 안전율 ≥ 기준안전율(E02 `safety_factors[조건][깎기 · 쌓기]`).
|
||||
대표 단면(보고서 몫) = 자동(쌓기 높이 ≥ 10 m · 깎기 ≥ 15 m · 최소 안전율 하위 5) + 사용자 켬/끔.
|
||||
좌표 = 엔진 그대로(x = B06 offset · 좌 + · z = 표고) — 화면이 같은 좌표로 그림.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
PRIMARY = "bishop"
|
||||
CONDITIONS = ("dry", "wet", "seismic")
|
||||
REPRESENTATIVE_FILL_M = 10.0
|
||||
REPRESENTATIVE_CUT_M = 15.0
|
||||
REPRESENTATIVE_LOWEST = 5
|
||||
#: 화면에 넘기는 면 꼭짓점 · 절편 자릿수
|
||||
_ROUND = 3
|
||||
|
||||
|
||||
def _r(value: Any, digits: int = _ROUND) -> Any:
|
||||
if isinstance(value, float):
|
||||
return round(value, digits) if math.isfinite(value) else None
|
||||
if isinstance(value, list):
|
||||
return [_r(v, digits) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def required_factor(factors: dict[str, Any], condition: str, role: str) -> float | None:
|
||||
"""기준안전율 — 조건 · 비탈 역할(cut · fill). 없으면 None."""
|
||||
value = (factors.get(condition) or {}).get(role)
|
||||
return float(value) if isinstance(value, int | float) else None
|
||||
|
||||
|
||||
def _governing(case: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""한 비탈 · 조건의 대표 방법 최소 면(원호 · 비원호 중 작은 쪽)."""
|
||||
return ((case.get("result") or {}).get("methods") or {}).get(PRIMARY, {}).get("min")
|
||||
|
||||
|
||||
def station_row(
|
||||
meta: dict[str, Any],
|
||||
out: dict[str, Any] | None,
|
||||
factors: dict[str, Any],
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""측점 목록 한 줄 — 조건 × 비탈 안전율 · 기준 · 판정 · 비탈 높이."""
|
||||
cases: dict[str, dict[str, Any]] = {}
|
||||
sides: dict[str, dict[str, Any]] = {}
|
||||
for case in (out or {}).get("cases", []):
|
||||
cond, side, role = case["condition"], case["side"], case["role"]
|
||||
sides.setdefault(side, {"side": side, "role": role, "height_m": _r(case.get("height_m"))})
|
||||
best = _governing(case)
|
||||
fs = best.get("F") if best else None
|
||||
need = required_factor(factors, cond, role)
|
||||
cases.setdefault(cond, {})[side] = {
|
||||
"fs": _r(fs),
|
||||
"required": need,
|
||||
"ok": None if fs is None or need is None else bool(fs >= need),
|
||||
"kind": best.get("kind") if best else None,
|
||||
"converged": best.get("converged") if best else None,
|
||||
}
|
||||
minimum: dict[str, float | None] = {}
|
||||
verdict: dict[str, str] = {}
|
||||
for cond, by_side in cases.items():
|
||||
values = [v["fs"] for v in by_side.values() if v["fs"] is not None]
|
||||
minimum[cond] = min(values) if values else None
|
||||
oks = [v["ok"] for v in by_side.values() if v["ok"] is not None]
|
||||
verdict[cond] = "none" if not oks else ("ok" if all(oks) else "ng")
|
||||
return {
|
||||
"key": meta["key"],
|
||||
"chainage_m": meta["chainage_m"],
|
||||
"label": meta.get("label"),
|
||||
"section_mode": meta.get("section_mode"),
|
||||
"ground_type": meta.get("ground_type"),
|
||||
"center": meta.get("center"),
|
||||
"fp": meta.get("fp"),
|
||||
"sides": list(sides.values()),
|
||||
"cases": cases,
|
||||
"min": minimum,
|
||||
"verdict": verdict,
|
||||
"error": error,
|
||||
"elapsed_s": (out or {}).get("elapsed_s"),
|
||||
}
|
||||
|
||||
|
||||
def overall_verdict(row: dict[str, Any], condition: str | None = None) -> str:
|
||||
"""측점 판정 — 조건 하나 또는 전 조건 중 가장 나쁜 것(ng > ok > none)."""
|
||||
verdict = row.get("verdict") or {}
|
||||
values = [verdict.get(condition, "none")] if condition else list(verdict.values())
|
||||
if "ng" in values:
|
||||
return "ng"
|
||||
return "ok" if "ok" in values else "none"
|
||||
|
||||
|
||||
def auto_representative(rows: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""자동 대표 단면 — 열쇠 → 까닭(fill_height · cut_height · lowest)."""
|
||||
reasons: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
for side in row["sides"]:
|
||||
height = side.get("height_m") or 0.0
|
||||
if side["role"] == "fill" and height >= REPRESENTATIVE_FILL_M:
|
||||
reasons.setdefault(row["key"], []).append("fill_height")
|
||||
if side["role"] == "cut" and height >= REPRESENTATIVE_CUT_M:
|
||||
reasons.setdefault(row["key"], []).append("cut_height")
|
||||
ranked = sorted(
|
||||
(
|
||||
(min(v for v in row["min"].values() if v is not None), row["key"])
|
||||
for row in rows
|
||||
if any(v is not None for v in row["min"].values())
|
||||
),
|
||||
)
|
||||
for _, key in ranked[:REPRESENTATIVE_LOWEST]:
|
||||
reasons.setdefault(key, []).append("lowest")
|
||||
return {key: sorted(set(v)) for key, v in reasons.items()}
|
||||
|
||||
|
||||
def mark_representative(rows: list[dict[str, Any]], overrides: dict[str, bool]) -> None:
|
||||
"""행마다 대표 단면 칸 — auto(까닭) · user(켬/끔 · 없으면 None) · on(최종)."""
|
||||
auto = auto_representative(rows)
|
||||
for row in rows:
|
||||
user = overrides.get(row["key"])
|
||||
reasons = auto.get(row["key"], [])
|
||||
row["representative"] = {
|
||||
"auto": reasons,
|
||||
"user": user,
|
||||
"on": bool(reasons) if user is None else bool(user),
|
||||
}
|
||||
|
||||
|
||||
def summarize(rows: list[dict[str, Any]], conditions: list[str]) -> dict[str, Any]:
|
||||
"""머리 요약 — 조건별 최소 안전율 · N.G 측점 수."""
|
||||
out: dict[str, Any] = {"stations": len(rows), "errors": sum(1 for r in rows if r["error"])}
|
||||
for cond in conditions:
|
||||
values = [r["min"].get(cond) for r in rows if r["min"].get(cond) is not None]
|
||||
out[cond] = {
|
||||
"min_fs": min(values) if values else None,
|
||||
"ng": sum(1 for r in rows if r["verdict"].get(cond) == "ng"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _slices(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""절편표 + 자유물체도 힘 — N(전 수직력) = N' + u·l · U = u·l · 오른쪽 = 다음 절편의 왼쪽."""
|
||||
out = []
|
||||
for i, s in enumerate(rows):
|
||||
nxt = rows[i + 1] if i + 1 < len(rows) else None
|
||||
U = s["pore_pressure"] * s["base_length"]
|
||||
out.append(
|
||||
{
|
||||
"i": i + 1,
|
||||
**{k: _r(s.get(k)) for k in ("x_left", "x_right", "width", "height", "alpha_deg")},
|
||||
**{k: _r(s.get(k)) for k in ("weight", "surcharge", "pore_pressure")},
|
||||
**{k: _r(s.get(k)) for k in ("base_length", "c", "phi_deg")},
|
||||
"material": s.get("material"),
|
||||
"N": _r(s["normal_eff"] + U),
|
||||
"N_eff": _r(s["normal_eff"]),
|
||||
"U": _r(U),
|
||||
"S": _r(s["shear_mobilized"]),
|
||||
"E_left": _r(s["E_left"]),
|
||||
"E_right": _r(nxt["E_left"]) if nxt else 0.0,
|
||||
"X_left": _r(s["X_left"]),
|
||||
"X_right": _r(nxt["X_left"]) if nxt else 0.0,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _surface(found: dict[str, Any] | None, method: str) -> dict[str, Any] | None:
|
||||
if not found:
|
||||
return None
|
||||
out = {
|
||||
"method": method,
|
||||
"kind": found.get("kind"),
|
||||
"fs": _r(found.get("F")),
|
||||
"points": _r(found.get("points") or []),
|
||||
"entry": _r(found.get("entry")),
|
||||
"exit": _r(found.get("exit")),
|
||||
"converged": found.get("converged"),
|
||||
"iterations": found.get("iterations"),
|
||||
}
|
||||
if found.get("center") is not None:
|
||||
out["center"] = _r(found["center"])
|
||||
out["radius"] = _r(found.get("radius"))
|
||||
for key in ("lambda", "theta_deg", "F0", "f0"):
|
||||
if key in found:
|
||||
out[key] = _r(found[key])
|
||||
return out
|
||||
|
||||
|
||||
def section_view(
|
||||
row: dict[str, Any],
|
||||
out: dict[str, Any],
|
||||
model: dict[str, Any],
|
||||
water: dict[str, list[list[float]] | None],
|
||||
) -> dict[str, Any]:
|
||||
"""측점 하나 화면 — 지층 · 지표 · 지하수선 · 하중 · 비탈 × 조건 임계면 · 후보 · 절편 · 격자."""
|
||||
analyses = []
|
||||
for case in out.get("cases", []):
|
||||
result = case.get("result") or {}
|
||||
methods = result.get("methods") or {}
|
||||
best = (methods.get(PRIMARY) or {}).get("min")
|
||||
candidates = []
|
||||
for method, found in methods.items():
|
||||
for kind in ("circular", "noncircular"):
|
||||
surface = _surface(found.get(kind), method)
|
||||
if surface and surface["fs"] is not None:
|
||||
candidates.append(surface)
|
||||
side_row = (row["cases"].get(case["condition"]) or {}).get(case["side"]) or {}
|
||||
contour = result.get("contour")
|
||||
analyses.append(
|
||||
{
|
||||
"condition": case["condition"],
|
||||
"side": case["side"],
|
||||
"role": case["role"],
|
||||
"height_m": _r(case.get("height_m")),
|
||||
"limits": _r(list(case.get("limits") or [])),
|
||||
"direction": case.get("direction"),
|
||||
"required": side_row.get("required"),
|
||||
"ok": side_row.get("ok"),
|
||||
"min_by_method": {m: _r(v) for m, v in (case.get("min_F") or {}).items()},
|
||||
"critical": _surface(best, PRIMARY),
|
||||
"candidates": candidates,
|
||||
"slices": _slices(best.get("slices") or []) if best else [],
|
||||
"grid": (
|
||||
{
|
||||
"method": contour.get("method"),
|
||||
"x": _r(contour["x"]),
|
||||
"z": _r(contour["z"]),
|
||||
"fs": _r(contour["F"]),
|
||||
}
|
||||
if contour
|
||||
else None
|
||||
),
|
||||
"counts": result.get("counts"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"key": row["key"],
|
||||
"chainage_m": row["chainage_m"],
|
||||
"label": row.get("label"),
|
||||
"materials": [
|
||||
{k: m.get(k) for k in ("id", "name", "gamma_t", "gamma_sat", "c", "phi")}
|
||||
for m in model["materials"]
|
||||
],
|
||||
"regions": [
|
||||
{
|
||||
"material_id": r["material_id"],
|
||||
"polygon": _r(r["polygon"]),
|
||||
"holes": _r(r.get("holes") or []),
|
||||
}
|
||||
for r in model["regions"]
|
||||
],
|
||||
"surface": _r(model["surface"]),
|
||||
"bottom_z": _r(model.get("bottom_z")),
|
||||
"surcharge": model.get("surcharge") or [],
|
||||
"water": {cond: _r(line) if line else None for cond, line in water.items()},
|
||||
"areas": {k: _r(v) for k, v in (out.get("areas") or {}).items()},
|
||||
"analyses": analyses,
|
||||
"row": row,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""E03 측점 해석 일꾼 — 서버 밖 프로세스(PLAN 532-3).
|
||||
|
||||
`python -m E03_StabSection.E03_StabSection_Worker <job.json>` — 서버(`Router.run_all`)가 띄움.
|
||||
측점마다 프로세스로 나눠 돌리고 끝날 때마다 파생 저장본을 쓰고 한 줄(JSON)을 찍음 → 서버 진행률.
|
||||
서버 프로세스에서 바로 프로세스 풀을 열면 자식마다 `main.py` 를 다시 읽어 이 모듈을 입구로 둠.
|
||||
job = {root, keep, kwargs, workers, items: [{fp, meta, layers, cross, design, ground}]}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from common_util.common_util_derived_store import write_derived
|
||||
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station
|
||||
|
||||
KIND_STATION = "stab2d_station"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _analyze(item: dict[str, Any], kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
kw = {**kwargs, "layers": item["layers"], "ground": [tuple(p) for p in item["ground"]]}
|
||||
return analyze_station(item["cross"], item["design"], **kw)
|
||||
|
||||
|
||||
def _value(item: dict[str, Any], out: dict | None, error: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"meta": item["meta"],
|
||||
"inputs": {"layers": item["layers"], "ground": item["ground"]},
|
||||
"out": out,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def run_job(job: dict[str, Any], emit: Callable[[str], None]) -> None:
|
||||
"""측점마다 해석 → 저장본 · emit(열쇠). 한 측점 실패는 그 줄에 까닭만 남기고 이어 감."""
|
||||
root, keep, kwargs = Path(job["root"]), int(job["keep"]), job["kwargs"]
|
||||
items = job["items"]
|
||||
|
||||
def save(item: dict[str, Any], out: dict | None, error: str | None) -> None:
|
||||
write_derived(root, KIND_STATION, item["fp"], _value(item, out, error), keep=keep)
|
||||
emit(item["meta"]["key"])
|
||||
|
||||
workers = max(1, min(int(job.get("workers") or 1), len(items)))
|
||||
if workers == 1:
|
||||
for item in items:
|
||||
try:
|
||||
save(item, _analyze(item, kwargs), None)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.exception("E03 측점 해석 실패: %s", item["meta"]["key"])
|
||||
save(item, None, str(error))
|
||||
return
|
||||
with ProcessPoolExecutor(max_workers=workers) as pool:
|
||||
futures = {pool.submit(_analyze, item, kwargs): item for item in items}
|
||||
for future in as_completed(futures):
|
||||
item = futures[future]
|
||||
try:
|
||||
save(item, future.result(), None)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.exception("E03 측점 해석 실패: %s", item["meta"]["key"])
|
||||
save(item, None, str(error))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
job = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
run_job(job, lambda key: print(json.dumps({"done": key}), flush=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* 길 선언(PLAN 529-1 · 532-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E03 2D 측점 해석 페이지
|
||||
* ========================================================================== */
|
||||
|
||||
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.E03_STAB_SECTION),
|
||||
load: async () => (await import("../E03_StabSection_UI_Page")).renderE03StabSection,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_Logic.ts
|
||||
* E03 2D 측점 해석 화면 계산(PLAN 532-3) — 화면 · three · fetch · sessionStorage 없음 · 순수 함수만.
|
||||
* 좌표 = 서버 결과 그대로(x = B06 offset · 좌 + · z = 표고). 화면은 B06 처럼 좌측(+offset)을 왼쪽에 둠
|
||||
* → 화면 x = −k·x + tx · y = −k·z + ty (`fitView`). SVG 도형 좌표는 결과 좌표를 그대로 쓰고
|
||||
* 묶음 transform 하나(`viewMatrix`)로 옮김 — 「SVG 좌표 = 결과 좌표」.
|
||||
* 판정 = 서버 `E03_StabSection_View.overall_verdict` 와 같은 규칙(ng > ok > none).
|
||||
* ========================================================================== */
|
||||
|
||||
export type Condition = "dry" | "wet" | "seismic";
|
||||
export type Verdict = "ok" | "ng" | "none";
|
||||
export type Point = [number, number];
|
||||
|
||||
export interface SideCase {
|
||||
fs: number | null;
|
||||
required: number | null;
|
||||
ok: boolean | null;
|
||||
kind: string | null;
|
||||
converged: boolean | null;
|
||||
}
|
||||
|
||||
export interface StationRow {
|
||||
key: string;
|
||||
chainage_m: number;
|
||||
label: string | null;
|
||||
section_mode: string | null;
|
||||
ground_type: string | null;
|
||||
center: [number | null, number | null] | null;
|
||||
sides: { side: string; role: "cut" | "fill"; height_m: number | null }[];
|
||||
cases: Partial<Record<Condition, Record<string, SideCase>>>;
|
||||
min: Partial<Record<Condition, number | null>>;
|
||||
verdict: Partial<Record<Condition, Verdict>>;
|
||||
error: string | null;
|
||||
representative?: { auto: string[]; user: boolean | null; on: boolean };
|
||||
}
|
||||
|
||||
export interface Surface {
|
||||
method: string;
|
||||
kind: string | null;
|
||||
fs: number | null;
|
||||
points: Point[];
|
||||
entry: Point | null;
|
||||
exit: Point | null;
|
||||
converged: boolean | null;
|
||||
iterations: number | null;
|
||||
center?: Point;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
export interface SliceRow {
|
||||
i: number;
|
||||
x_left: number;
|
||||
x_right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
alpha_deg: number;
|
||||
weight: number;
|
||||
surcharge: number;
|
||||
pore_pressure: number;
|
||||
base_length: number;
|
||||
c: number;
|
||||
phi_deg: number;
|
||||
material: string | null;
|
||||
N: number;
|
||||
N_eff: number;
|
||||
U: number;
|
||||
S: number;
|
||||
E_left: number;
|
||||
E_right: number;
|
||||
X_left: number;
|
||||
X_right: number;
|
||||
}
|
||||
|
||||
export interface Analysis {
|
||||
condition: Condition;
|
||||
side: string;
|
||||
role: "cut" | "fill";
|
||||
height_m: number | null;
|
||||
limits: [number, number];
|
||||
direction: number;
|
||||
required: number | null;
|
||||
ok: boolean | null;
|
||||
min_by_method: Record<string, number | null>;
|
||||
critical: Surface | null;
|
||||
candidates: Surface[];
|
||||
slices: SliceRow[];
|
||||
grid: { method: string; x: number[]; z: number[]; fs: (number | null)[][] } | null;
|
||||
}
|
||||
|
||||
export interface SectionView {
|
||||
key: string;
|
||||
chainage_m: number;
|
||||
label: string | null;
|
||||
materials: {
|
||||
id: string;
|
||||
name: string;
|
||||
gamma_t: number;
|
||||
gamma_sat: number;
|
||||
c: number;
|
||||
phi: number;
|
||||
}[];
|
||||
regions: { material_id: string; polygon: Point[]; holes: Point[][] }[];
|
||||
surface: Point[];
|
||||
bottom_z: number | null;
|
||||
surcharge: { x0: number; x1: number; q: number }[];
|
||||
water: Partial<Record<Condition, Point[] | null>>;
|
||||
analyses: Analysis[];
|
||||
}
|
||||
|
||||
/* ---- 판정 · 목록 ---------------------------------------------------------- */
|
||||
|
||||
/** 측점 판정 — 조건 하나(또는 전 조건 중 가장 나쁜 것) */
|
||||
export function verdictOf(row: StationRow, condition?: Condition): Verdict {
|
||||
const values = condition ? [row.verdict[condition] ?? "none"] : Object.values(row.verdict);
|
||||
if (values.includes("ng")) return "ng";
|
||||
return values.includes("ok") ? "ok" : "none";
|
||||
}
|
||||
|
||||
export type SortMode = "chainage" | "fs";
|
||||
|
||||
/** 목록 정렬 · 거름 — fs = 그 조건 최소 안전율 낮은 순(없으면 맨 뒤) */
|
||||
export function listRows(
|
||||
rows: StationRow[],
|
||||
condition: Condition,
|
||||
sort: SortMode,
|
||||
ngOnly: boolean,
|
||||
): StationRow[] {
|
||||
const kept = ngOnly ? rows.filter((r) => verdictOf(r, condition) === "ng") : rows.slice();
|
||||
if (sort === "fs") {
|
||||
const fs = (r: StationRow): number => r.min[condition] ?? Number.POSITIVE_INFINITY;
|
||||
kept.sort((a, b) => fs(a) - fs(b) || a.chainage_m - b.chainage_m);
|
||||
} else kept.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** 깎기 · 쌓기 줄임 — 비탈 역할 좌/우(예 「쌓기 · 깎기」) */
|
||||
export function roleText(row: StationRow, names: { cut: string; fill: string }): string {
|
||||
const order = ["left", "right"];
|
||||
return row.sides
|
||||
.slice()
|
||||
.sort((a, b) => order.indexOf(a.side) - order.indexOf(b.side))
|
||||
.map((s) => names[s.role])
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/** 평면 띠 — 측점 중심을 이은 선을 측점 판정으로 나눈 구간(이웃 측점 사이 중간에서 끊음) */
|
||||
export function bandSegments(
|
||||
rows: StationRow[],
|
||||
condition: Condition | undefined,
|
||||
): { verdict: Verdict; points: Point[]; keys: string[] }[] {
|
||||
const pts = rows
|
||||
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m)
|
||||
.map((r) => ({ row: r, p: [r.center![0]!, r.center![1]!] as Point }));
|
||||
const out: { verdict: Verdict; points: Point[]; keys: string[] }[] = [];
|
||||
const mid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
pts.forEach((item, i) => {
|
||||
const v = verdictOf(item.row, condition);
|
||||
const start = i > 0 ? mid(pts[i - 1].p, item.p) : item.p;
|
||||
const end = i < pts.length - 1 ? mid(item.p, pts[i + 1].p) : item.p;
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.verdict === v) {
|
||||
last.points.push(item.p, end);
|
||||
last.keys.push(item.row.key);
|
||||
} else out.push({ verdict: v, points: [start, item.p, end], keys: [item.row.key] });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 평면 띠 보기 틀 — 북쪽 위 · 같은 축척 · 여백 pad(px) */
|
||||
export function planFit(
|
||||
points: Point[],
|
||||
width: number,
|
||||
height: number,
|
||||
pad: number,
|
||||
): { k: number; tx: number; ty: number } {
|
||||
const xs = points.map((p) => p[0]);
|
||||
const ys = points.map((p) => p[1]);
|
||||
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
||||
const k = Math.min(
|
||||
(width - 2 * pad) / Math.max(x1 - x0, 1),
|
||||
(height - 2 * pad) / Math.max(y1 - y0, 1),
|
||||
);
|
||||
return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 };
|
||||
}
|
||||
|
||||
/* ---- 안전율 색 ------------------------------------------------------------ */
|
||||
|
||||
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
|
||||
export const RATIO_BREAKS = [0.8, 0.9, 1.0, 1.1, 1.25, 1.5] as const;
|
||||
|
||||
/** 띠 번호 0~6 — 0 = 가장 위험 · 3 = 기준 바로 위 · null = 값 없음 */
|
||||
export function bandIndex(fs: number | null | undefined, required: number | null): number | null {
|
||||
if (fs === null || fs === undefined || !Number.isFinite(fs)) return null;
|
||||
const ratio = Math.round((fs / (required && required > 0 ? required : 1)) * 1e9) / 1e9;
|
||||
const i = RATIO_BREAKS.findIndex((b) => ratio < b);
|
||||
return i < 0 ? RATIO_BREAKS.length : i;
|
||||
}
|
||||
|
||||
/** 범례 — 띠마다 안전율 구간(기준안전율을 곱한 실제 값) */
|
||||
export function bandLegend(
|
||||
required: number | null,
|
||||
): { index: number; from: number | null; to: number | null }[] {
|
||||
const base = required && required > 0 ? required : 1;
|
||||
const edges = RATIO_BREAKS.map((b) => Math.round(b * base * 100) / 100);
|
||||
return [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => ({
|
||||
index: i,
|
||||
from: i === 0 ? null : edges[i - 1],
|
||||
to: i === RATIO_BREAKS.length ? null : edges[i],
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---- 횡단 보기 틀 --------------------------------------------------------- */
|
||||
|
||||
export interface ViewFit {
|
||||
k: number; // px / m (가로 · 세로 같음 — 참 모양)
|
||||
tx: number;
|
||||
ty: number;
|
||||
}
|
||||
|
||||
/** 범위(지표 · 바닥 · 격자 중심 · 임계면) 를 판에 맞춤 — 좌측(+offset) 이 왼쪽 */
|
||||
export function fitView(view: SectionView, width: number, height: number, pad: number): ViewFit {
|
||||
const xs: number[] = [];
|
||||
const zs: number[] = [];
|
||||
const add = (p: Point): void => {
|
||||
xs.push(p[0]);
|
||||
zs.push(p[1]);
|
||||
};
|
||||
view.surface.forEach(add);
|
||||
if (view.bottom_z !== null) zs.push(view.bottom_z);
|
||||
for (const a of view.analyses) {
|
||||
if (a.grid) {
|
||||
a.grid.x.forEach((x) => xs.push(x));
|
||||
a.grid.z.forEach((z) => zs.push(z));
|
||||
}
|
||||
a.critical?.points.forEach(add);
|
||||
}
|
||||
const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)];
|
||||
const k = Math.min(
|
||||
(width - 2 * pad) / Math.max(x1 - x0, 1),
|
||||
(height - 2 * pad) / Math.max(z1 - z0, 1),
|
||||
);
|
||||
return { k, tx: width / 2 + (k * (x0 + x1)) / 2, ty: height / 2 + (k * (z0 + z1)) / 2 };
|
||||
}
|
||||
|
||||
/** 결과 좌표 → 화면 px */
|
||||
export function toScreen(fit: ViewFit, p: Point): Point {
|
||||
return [-fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
|
||||
}
|
||||
|
||||
/** 화면 px → 결과 좌표(마우스 아래 값) */
|
||||
export function toModel(fit: ViewFit, p: Point): Point {
|
||||
return [(fit.tx - p[0]) / fit.k, (fit.ty - p[1]) / fit.k];
|
||||
}
|
||||
|
||||
/** 결과 좌표 도형 묶음에 거는 transform — 도형 points 는 결과 좌표 그대로 */
|
||||
export function viewMatrix(fit: ViewFit): string {
|
||||
return `matrix(${-fit.k} 0 0 ${-fit.k} ${fit.tx} ${fit.ty})`;
|
||||
}
|
||||
|
||||
/** 폴리곤 · 선 → SVG points 글(결과 좌표 그대로) */
|
||||
export function pointsText(points: Point[]): string {
|
||||
return points.map((p) => `${p[0]},${p[1]}`).join(" ");
|
||||
}
|
||||
|
||||
/** 꺾은선 위 x 의 z(선형 · 범위 밖은 끝값) — 선은 x 오름 · 내림 무관 */
|
||||
export function zAt(line: Point[], x: number): number | null {
|
||||
if (!line.length) return null;
|
||||
const pts = line.slice().sort((a, b) => a[0] - b[0]);
|
||||
if (x <= pts[0][0]) return pts[0][1];
|
||||
for (let i = 1; i < pts.length; i += 1) {
|
||||
if (x <= pts[i][0]) {
|
||||
const [a, b] = [pts[i - 1], pts[i]];
|
||||
const t = b[0] === a[0] ? 0 : (x - a[0]) / (b[0] - a[0]);
|
||||
return a[1] + t * (b[1] - a[1]);
|
||||
}
|
||||
}
|
||||
return pts[pts.length - 1][1];
|
||||
}
|
||||
|
||||
/** 절편 네 꼭짓점 — 바닥 = 임계면 · 위 = 지표 */
|
||||
export function sliceShape(slice: SliceRow, surface: Point[], base: Point[]): Point[] {
|
||||
const at = (line: Point[], x: number): number => zAt(line, x) ?? 0;
|
||||
return [
|
||||
[slice.x_left, at(base, slice.x_left)],
|
||||
[slice.x_right, at(base, slice.x_right)],
|
||||
[slice.x_right, at(surface, slice.x_right)],
|
||||
[slice.x_left, at(surface, slice.x_left)],
|
||||
];
|
||||
}
|
||||
|
||||
/* ---- 자유물체도 --------------------------------------------------------- */
|
||||
|
||||
export interface ForceArrow {
|
||||
name: "W" | "Q" | "N" | "U" | "S" | "EL" | "ER" | "XL" | "XR";
|
||||
value: number;
|
||||
/** 화살 시작 · 끝(결과 좌표 · 길이 = 값 × scale) */
|
||||
from: Point;
|
||||
to: Point;
|
||||
}
|
||||
|
||||
/**
|
||||
* 절편 하나 힘 화살 — W · Q 아래 · N · U 바닥 법선(절편 안쪽) · S 바닥 따라 미끄럼 반대 ·
|
||||
* E 좌우 면 수평 · X 좌우 면 연직. 엔진 절편표는 활동 방향(direction)으로 뒤집은 틀의 차례라
|
||||
* 그 틀의 「왼쪽 면」은 direction < 0 이면 결과 좌표의 오른쪽 면. 길이 = 값 × lengthPerKn.
|
||||
*/
|
||||
export function forceArrows(
|
||||
slice: SliceRow,
|
||||
shape: Point[],
|
||||
direction: number,
|
||||
lengthPerKn: number,
|
||||
): ForceArrow[] {
|
||||
const s = direction < 0 ? -1 : 1;
|
||||
const [bl, br, tr, tl] = shape;
|
||||
const midBase: Point = [(bl[0] + br[0]) / 2, (bl[1] + br[1]) / 2];
|
||||
const midTop: Point = [(tl[0] + tr[0]) / 2, (tl[1] + tr[1]) / 2];
|
||||
const centroid: Point = [(midBase[0] + midTop[0]) / 2, (midBase[1] + midTop[1]) / 2];
|
||||
const len = Math.hypot(br[0] - bl[0], br[1] - bl[1]) || 1;
|
||||
const t: Point = [(br[0] - bl[0]) / len, (br[1] - bl[1]) / len]; // 바닥 왼→오
|
||||
const n: Point = [-t[1], t[0]]; // 바닥 법선 위쪽
|
||||
const faceMid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
const [nearFace, farFace] =
|
||||
s > 0 ? [faceMid(bl, tl), faceMid(br, tr)] : [faceMid(br, tr), faceMid(bl, tl)];
|
||||
const arrow = (
|
||||
name: ForceArrow["name"],
|
||||
value: number,
|
||||
at: Point,
|
||||
dir: Point,
|
||||
ending = false,
|
||||
): ForceArrow => {
|
||||
const d: Point = [dir[0] * value * lengthPerKn, dir[1] * value * lengthPerKn];
|
||||
return ending
|
||||
? { name, value, from: [at[0] - d[0], at[1] - d[1]], to: at }
|
||||
: { name, value, from: at, to: [at[0] + d[0], at[1] + d[1]] };
|
||||
};
|
||||
return [
|
||||
arrow("W", slice.weight, centroid, [0, -1]),
|
||||
arrow("Q", slice.surcharge, midTop, [0, -1], true),
|
||||
arrow("N", slice.N_eff, midBase, n),
|
||||
arrow("U", slice.U, midBase, n),
|
||||
arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]]),
|
||||
arrow("EL", slice.E_left, nearFace, [s, 0], true),
|
||||
arrow("ER", slice.E_right, farFace, [-s, 0], true),
|
||||
arrow("XL", slice.X_left, nearFace, [0, 1], true),
|
||||
arrow("XR", slice.X_right, farFace, [0, -1], true),
|
||||
].filter((a) => Math.abs(a.value) > 1e-9);
|
||||
}
|
||||
|
||||
/** 절편력 그래프 꼴 — 절편 가운데 x 마다 바닥 유효 수직응력 · 간극수압 · 동원 전단응력(kPa · 한 축) */
|
||||
export function sliceStress(
|
||||
slices: SliceRow[],
|
||||
): { x: number; sigma: number; u: number; tau: number }[] {
|
||||
return slices.map((s) => {
|
||||
const l = s.base_length > 0 ? s.base_length : 1;
|
||||
return { x: (s.x_left + s.x_right) / 2, sigma: s.N_eff / l, u: s.pore_pressure, tau: s.S / l };
|
||||
});
|
||||
}
|
||||
|
||||
/** 그래프 눈금 — 0 포함 · 1 · 2 · 5 × 10ⁿ 간격 · 대략 count 칸 */
|
||||
export function niceTicks(min: number, max: number, count = 5): number[] {
|
||||
const lo = Math.min(0, min);
|
||||
const hi = Math.max(lo + 1e-9, max);
|
||||
const raw = (hi - lo) / Math.max(count, 1);
|
||||
const mag = 10 ** Math.floor(Math.log10(raw));
|
||||
const step = [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= raw) ?? raw;
|
||||
const out: number[] = [];
|
||||
for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-6; v += step)
|
||||
out.push(Math.round(v * 1e6) / 1e6);
|
||||
if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step);
|
||||
return out;
|
||||
}
|
||||
@@ -83,6 +83,7 @@ from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register
|
||||
from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router
|
||||
from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router
|
||||
from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router
|
||||
from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_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
|
||||
@@ -492,6 +493,7 @@ app.include_router(d04_plan_link_router, dependencies=protected_with_company)
|
||||
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
|
||||
app.include_router(e04_stab_3d_router, dependencies=protected_with_company)
|
||||
app.include_router(e05_stab_report_router, dependencies=protected_with_company)
|
||||
app.include_router(e03_stab_section_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,551 @@
|
||||
"""532-3 E03 2D 측점 해석 길 · 화면 계산(요구 345 · 347 · 351).
|
||||
|
||||
- 길: 지문이 같으면 다시 안 셈 · 바뀐 측점만 다시 셈 · 진행률 · 결과 꼴 · 대표 단면 저장 ·
|
||||
공유 회사 POST 403(공용 접근 판정) · 노선면 솎음
|
||||
- 실제 엔진 한 측점 → 화면 꼴(임계면 · 절편 · 격자)
|
||||
- logic(TS) — 판정 규칙 = 파이썬 · 정렬 · 거름 · 등안전율 띠 · 화면 좌표 · 평면 띠 · 힘 화살
|
||||
입력 = 검증_sub2 원본을 임시 폴더로 동결 복사(DB 읽기만) · 엔진 = 가짜(길) / 진짜(꼴).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from common_util import common_util_auth as auth # noqa: E402
|
||||
from common_util import common_util_project_copy as cp # noqa: E402
|
||||
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER # noqa: E402
|
||||
from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402
|
||||
from E02_StabGround import E02_StabGround_Model as ground_model # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_Router as router # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_View as view # noqa: E402
|
||||
from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402
|
||||
from E03_StabSection.E03_StabSection_Engine_Model import section_model_from_b06 # noqa: E402
|
||||
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402
|
||||
|
||||
SOURCE_ID = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(읽기만)
|
||||
PID = "e" * 8 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 12
|
||||
N_STATIONS = 3
|
||||
TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript"
|
||||
TS = ROOT / "E03_StabSection" / "logic" / "E03_StabSection_Logic.ts"
|
||||
|
||||
|
||||
async def _copy(target: Path) -> None:
|
||||
connection = await aiomysql.connect(
|
||||
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, autocommit=True
|
||||
)
|
||||
try:
|
||||
await cp.copy_project(
|
||||
connection, SOURCE_ID, scope="stability", new_id=PID, project_root=target
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def frozen():
|
||||
folder = Path(tempfile.mkdtemp(prefix="aislo_e03_"))
|
||||
try:
|
||||
asyncio.run(_copy(folder / "copy"))
|
||||
except Exception as error: # noqa: BLE001
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
pytest.skip(f"검증 원본 복사 실패: {error}")
|
||||
yield folder / "copy"
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
|
||||
|
||||
CALLS: list[float] = []
|
||||
|
||||
|
||||
def _fake_analyze(cross, design, *, layers=None, ground=None, conditions=("dry", "wet"), **kw):
|
||||
"""엔진 출력 꼴 그대로 · 값은 누가거리로 정함(1.0 + ch/100 · 건기 +0.3)."""
|
||||
CALLS.append(float(cross["chainage_m"]))
|
||||
model = section_model_from_b06(
|
||||
cross, design, layers=layers, ground=ground, materials=kw.get("materials")
|
||||
)
|
||||
cases = []
|
||||
for cond in conditions:
|
||||
for side in model["sides"]:
|
||||
F = round(1.0 + float(cross["chainage_m"]) / 100 + (0.3 if cond == "dry" else 0.0), 3)
|
||||
x0, x1 = side["limits"]
|
||||
row = {
|
||||
"x_left": x0,
|
||||
"x_right": x1,
|
||||
"width": x1 - x0,
|
||||
"height": 1.0,
|
||||
"alpha_deg": 30.0,
|
||||
"weight": 20.0,
|
||||
"surcharge": 0.0,
|
||||
"pore_pressure": 0.0,
|
||||
"base_length": 2.0,
|
||||
"material": "weathered_soil",
|
||||
"c": 15.0,
|
||||
"phi_deg": 30.0,
|
||||
"normal_eff": 15.0,
|
||||
"shear_mobilized": 9.0,
|
||||
"E_left": 0.0,
|
||||
"X_left": 0.0,
|
||||
}
|
||||
found = {
|
||||
"F": F,
|
||||
"kind": "circle",
|
||||
"points": [[x0, 800.0], [x1, 799.0]],
|
||||
"entry": [x0, 800.0],
|
||||
"exit": [x1, 799.0],
|
||||
"iterations": 4,
|
||||
"converged": True,
|
||||
"slices": [row],
|
||||
"center": [0.0, 820.0],
|
||||
"radius": 20.0,
|
||||
}
|
||||
result = {
|
||||
"direction": side["direction"],
|
||||
"limits": list(side["limits"]),
|
||||
"methods": {"bishop": {"circular": found, "min": found}},
|
||||
"contour": {
|
||||
"method": "bishop",
|
||||
"x": [0.0, 1.0],
|
||||
"z": [810.0, 811.0],
|
||||
"F": [[F, None], [2.0, 3.0]],
|
||||
},
|
||||
"counts": {"circles": 1},
|
||||
}
|
||||
cases.append({"condition": cond, **side, "min_F": {"bishop": F}, "result": result})
|
||||
return {
|
||||
"chainage_m": cross["chainage_m"],
|
||||
"label": cross.get("label"),
|
||||
"cases": cases,
|
||||
"areas": model["areas"],
|
||||
"elapsed_s": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(frozen, monkeypatch):
|
||||
"""E 프로젝트 루트(복사본 + 비운 결과) · 측점 셋 · 가짜 엔진 · 서버 안에서 차례로."""
|
||||
for name in ("_derived", "E02_StabGround", "E03_StabSection", "initial_snapshot"):
|
||||
shutil.rmtree(frozen / name, ignore_errors=True)
|
||||
original = src.stations
|
||||
monkeypatch.setattr(router.src, "stations", lambda root: original(root)[:N_STATIONS])
|
||||
|
||||
async def fake_project(_pid):
|
||||
return frozen, {"company_id": 1, "project_kind": "STABILITY"}
|
||||
|
||||
monkeypatch.setattr(router.e02, "_project", fake_project)
|
||||
monkeypatch.setattr(worker, "analyze_station", _fake_analyze)
|
||||
monkeypatch.setattr(router, "WORKERS", 1)
|
||||
router._JOBS.clear()
|
||||
CALLS.clear()
|
||||
return frozen
|
||||
|
||||
|
||||
def _analyze() -> dict:
|
||||
"""POST → 뒤 일이 끝날 때까지 기다림."""
|
||||
|
||||
async def go():
|
||||
first = await router.analyze2d(UUID(PID))
|
||||
while router._TASKS:
|
||||
await asyncio.gather(*list(router._TASKS))
|
||||
return first
|
||||
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
USER = {"role": "USER", "company_id": 1}
|
||||
|
||||
|
||||
def test_지문이_같으면_다시_안셈(project):
|
||||
first = _analyze()
|
||||
assert first["status"] == "accepted" and len(CALLS) == N_STATIONS
|
||||
job = asyncio.run(router.progress(UUID(PID)))
|
||||
assert job == {**job, "running": False, "percent": 100, "error": None, "computed": N_STATIONS}
|
||||
|
||||
again = _analyze()
|
||||
assert again == {"status": "fresh", "fingerprint": first["fingerprint"]}
|
||||
assert len(CALLS) == N_STATIONS # 다시 안 셈
|
||||
|
||||
# 둘째 측점만 구간 덮어쓰기 → 그 측점만 다시 셈 · 다른 지문
|
||||
ground = router._ground(project)
|
||||
second = router.plan_run(project, ground)["items"][1]["chainage_m"]
|
||||
ground["zones"] = [
|
||||
{
|
||||
"from_m": second,
|
||||
"to_m": second,
|
||||
"soil": "colluvium",
|
||||
"rock": None,
|
||||
"soil_thickness_m": None,
|
||||
}
|
||||
]
|
||||
(project / "E02_StabGround").mkdir(exist_ok=True)
|
||||
ground_model.ground_path(project).write_text(json.dumps(ground), encoding="utf-8")
|
||||
CALLS.clear()
|
||||
third = _analyze()
|
||||
assert third["status"] == "accepted" and third["fingerprint"] != first["fingerprint"]
|
||||
assert CALLS == [second]
|
||||
|
||||
# 기준안전율만 바꿈 → 엔진은 안 돌고 판정만 새로(요약 지문만 바뀜)
|
||||
ground["safety_factors"]["dry"]["fill"] = 9.0
|
||||
ground_model.ground_path(project).write_text(json.dumps(ground), encoding="utf-8")
|
||||
CALLS.clear()
|
||||
assert _analyze()["status"] == "accepted" and CALLS == []
|
||||
body = asyncio.run(router.result2d(UUID(PID), USER))
|
||||
fill_dry = [
|
||||
c["dry"]["left"] for c in (r["cases"] for r in body["stations"]) if "left" in c["dry"]
|
||||
]
|
||||
assert body["fresh"] and any(v["required"] == 9.0 and v["ok"] is False for v in fill_dry)
|
||||
|
||||
|
||||
def test_진행률_올라감(project, monkeypatch):
|
||||
seen = []
|
||||
|
||||
def spy(*args, **kw):
|
||||
seen.append(router._JOBS[PID]["percent"])
|
||||
return _fake_analyze(*args, **kw)
|
||||
|
||||
monkeypatch.setattr(worker, "analyze_station", spy)
|
||||
_analyze()
|
||||
assert seen[0] == 2 and seen == sorted(seen) and len(set(seen)) == N_STATIONS
|
||||
assert asyncio.run(router.progress(UUID(PID)))["percent"] == 100
|
||||
|
||||
|
||||
def test_결과_꼴_대표단면(project):
|
||||
none = asyncio.run(router.result2d(UUID(PID), USER))
|
||||
assert none["status"] == "none" and len(none["stations"]) == N_STATIONS
|
||||
_analyze()
|
||||
body = asyncio.run(router.result2d(UUID(PID), USER))
|
||||
assert body["status"] == "ok" and body["fresh"] and body["editable"]
|
||||
assert [r["key"] for r in body["stations"]] == [r["key"] for r in none["stations"]]
|
||||
row = body["stations"][0]
|
||||
assert set(row["verdict"]) == {"dry", "wet"} and row["representative"]["on"] is True # 하위 5
|
||||
other = asyncio.run(router.result2d(UUID(PID), {"role": "USER", "company_id": 2}))
|
||||
assert other["editable"] is False
|
||||
|
||||
sec = asyncio.run(router.section(UUID(PID), row["key"]))
|
||||
assert sec["regions"] and sec["surface"] and sec["surcharge"][0]["q"] == 10.0
|
||||
assert sec["water"]["dry"] is None and sec["water"]["wet"]
|
||||
a = sec["analyses"][0]
|
||||
assert a["critical"]["points"] and a["grid"]["fs"][0][1] is None and a["slices"][0]["N"] == 15.0
|
||||
with pytest.raises(HTTPException) as missing:
|
||||
asyncio.run(router.section(UUID(PID), "99999.00"))
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
saved = asyncio.run(
|
||||
router.put_representative(
|
||||
UUID(PID), router.RepresentativeRequest(overrides={row["key"]: False})
|
||||
)
|
||||
)
|
||||
assert saved == {"overrides": {row["key"]: False}}
|
||||
after = asyncio.run(router.result2d(UUID(PID), USER))["stations"][0]["representative"]
|
||||
assert after == {"auto": ["lowest"], "user": False, "on": False}
|
||||
with pytest.raises(HTTPException) as bad:
|
||||
asyncio.run(
|
||||
router.put_representative(
|
||||
UUID(PID), router.RepresentativeRequest(overrides={"x": True})
|
||||
)
|
||||
)
|
||||
assert bad.value.status_code == 422
|
||||
|
||||
|
||||
def test_노선면_솎음(project):
|
||||
body = asyncio.run(router.corridor3d(UUID(PID)))
|
||||
assert len(body["origin"]) == 3 and body["ribbons"]
|
||||
for ribbon in body["ribbons"]:
|
||||
chain = ribbon["chainages"]
|
||||
assert len(ribbon["positions"]) == len(chain) * ribbon["colCount"] * 3
|
||||
assert chain == sorted(set(chain)) # 1 m 칸마다 한 줄 + 끝 줄
|
||||
assert len(chain) <= math.ceil((chain[-1] - chain[0]) / router.CORRIDOR_STEP_M) + 2
|
||||
assert max(abs(v) for v in ribbon["positions"]) < 5000 # 원점 뺀 자리
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, args=()):
|
||||
self.last = sql
|
||||
|
||||
async def fetchone(self):
|
||||
return self.rows["shared"] if "plan_route_assignees" in self.last else self.rows["project"]
|
||||
|
||||
|
||||
class _Pool:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def acquire(self):
|
||||
pool = self
|
||||
|
||||
class _Conn:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def cursor(self):
|
||||
return _Cursor(pool.rows)
|
||||
|
||||
return _Conn()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,ok", [("GET", True), ("POST", False), ("PUT", False)])
|
||||
def test_공유_회사는_GET_만(monkeypatch, method, ok):
|
||||
monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool({"project": (1,), "shared": (1,)}))
|
||||
request = SimpleNamespace(path_params={"project_id": PID}, method=method)
|
||||
session = {"role": "USER", "company_id": 2}
|
||||
if ok:
|
||||
assert asyncio.run(auth.require_project_access(request, session)) is session
|
||||
else:
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(auth.require_project_access(request, session))
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
def test_E03_길에_공용_접근_판정이_걸림():
|
||||
import main
|
||||
|
||||
paths = {
|
||||
"/api/projects/{project_id}/stab/" + p
|
||||
for p in ("analyze2d", "result2d", "progress", "representative")
|
||||
}
|
||||
found = set()
|
||||
for route in main.app.routes:
|
||||
if getattr(route, "path", None) in paths:
|
||||
calls = {d.call for d in route.dependant.dependencies}
|
||||
assert auth.require_project_access in calls and auth.require_company in calls
|
||||
found.add(route.path)
|
||||
assert found == paths
|
||||
|
||||
|
||||
def test_실제_엔진_한_측점_화면_꼴(frozen):
|
||||
source = src.source_dir(frozen)
|
||||
station = src.stations(source)[5]
|
||||
cross = src.cross_section(source, station["chainage_m"])
|
||||
out = analyze_station(cross, cross["design"], methods=("ordinary", "bishop"), noncircular=False)
|
||||
meta = {
|
||||
"key": "k",
|
||||
"chainage_m": station["chainage_m"],
|
||||
"label": cross["label"],
|
||||
"section_mode": cross["design"]["section_mode"],
|
||||
"center": [0, 0],
|
||||
"fp": "f",
|
||||
}
|
||||
factors = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}}
|
||||
row = view.station_row(meta, out, factors)
|
||||
model = section_model_from_b06(cross, cross["design"])
|
||||
sec = view.section_view(row, out, model, {"dry": None, "wet": model["surface"]})
|
||||
assert {a["condition"] for a in sec["analyses"]} == {"dry", "wet"}
|
||||
for a in sec["analyses"]:
|
||||
c = a["critical"]
|
||||
assert c and c["kind"] == "circle" and c["center"] and c["radius"] > 0
|
||||
# 임계면 꼭짓점 = 원 위(반경 ±0.01 m)
|
||||
for x, z in c["points"]:
|
||||
assert abs(math.hypot(x - c["center"][0], z - c["center"][1]) - c["radius"]) < 0.02
|
||||
assert a["slices"] and a["slices"][0]["x_left"] == pytest.approx(
|
||||
min(p[0] for p in c["points"]), abs=0.01
|
||||
)
|
||||
assert a["min_by_method"]["bishop"] == pytest.approx(c["fs"], abs=1e-3)
|
||||
assert row["cases"][a["condition"]][a["side"]]["ok"] == (c["fs"] >= a["required"])
|
||||
assert len(a["grid"]["fs"]) == len(a["grid"]["z"])
|
||||
|
||||
|
||||
def test_대표단면_자동_규칙():
|
||||
rows = [
|
||||
{"key": str(i), "sides": [{"role": "fill", "height_m": h}], "min": {"dry": 3.0 - i * 0.1}}
|
||||
for i, h in enumerate([10.0, 9.99, 0, 0, 0, 0, 0, 0])
|
||||
]
|
||||
rows[3]["sides"] = [{"role": "cut", "height_m": 15.0}]
|
||||
auto = view.auto_representative(rows)
|
||||
assert (
|
||||
auto["0"] == ["fill_height"] and "1" not in auto and auto["3"] == ["cut_height", "lowest"]
|
||||
)
|
||||
assert sorted(k for k, v in auto.items() if "lowest" in v) == ["3", "4", "5", "6", "7"]
|
||||
|
||||
|
||||
# ---- logic(TS) --------------------------------------------------------------
|
||||
SCRIPT = r"""
|
||||
const ts = require(process.argv[1]);
|
||||
const fs = require("fs");
|
||||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||||
const opts = { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 };
|
||||
const js = ts.transpileModule(src, { compilerOptions: 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"));
|
||||
const fit = m.fitView(input.view, 800, 500, 20);
|
||||
const shape = input.shape;
|
||||
process.stdout.write(JSON.stringify({
|
||||
verdict: input.rows.map((r) => [m.verdictOf(r), m.verdictOf(r, "dry"), m.verdictOf(r, "wet")]),
|
||||
sortFs: m.listRows(input.rows, "wet", "fs", false).map((r) => r.key),
|
||||
ngOnly: m.listRows(input.rows, "wet", "chainage", true).map((r) => r.key),
|
||||
bands: input.fsList.map((f) => m.bandIndex(f, 1.5)),
|
||||
legend: m.bandLegend(1.5),
|
||||
fit,
|
||||
matrix: m.viewMatrix(fit),
|
||||
screen: input.view.surface.map((p) => m.toScreen(fit, p)),
|
||||
back: input.view.surface.map((p) => m.toModel(fit, m.toScreen(fit, p))),
|
||||
segments: m.bandSegments(input.rows, "wet").map((s) => [s.verdict, s.keys]),
|
||||
arrows: m.forceArrows(input.slice, shape, -1, 0.01),
|
||||
arrowsPos: m.forceArrows(input.slice, shape, 1, 0.01),
|
||||
stress: m.sliceStress([input.slice]),
|
||||
ticks: m.niceTicks(-3, 47, 5),
|
||||
zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)],
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _row(key, ch, dry, wet, center):
|
||||
cases = {
|
||||
"dry": {"left": {"fs": dry, "required": 1.5, "ok": None if dry is None else dry >= 1.5}},
|
||||
"wet": {"left": {"fs": wet, "required": 1.3, "ok": None if wet is None else wet >= 1.3}},
|
||||
}
|
||||
verdict = {}
|
||||
for cond, by_side in cases.items():
|
||||
oks = [v["ok"] for v in by_side.values() if v["ok"] is not None]
|
||||
verdict[cond] = "none" if not oks else ("ok" if all(oks) else "ng")
|
||||
return {
|
||||
"key": key,
|
||||
"chainage_m": ch,
|
||||
"label": key,
|
||||
"center": center,
|
||||
"cases": cases,
|
||||
"sides": [{"side": "left", "role": "fill", "height_m": 1.0}],
|
||||
"min": {"dry": dry, "wet": wet},
|
||||
"verdict": verdict,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def test_logic_TS():
|
||||
rows = [
|
||||
_row("a", 0, 2.0, 1.4, [0, 0]),
|
||||
_row("b", 20, 1.6, 1.1, [20, 0]),
|
||||
_row("c", 40, None, None, [40, 5]),
|
||||
_row("d", 60, 1.2, 1.0, [60, 10]),
|
||||
_row("e", 80, 2.0, 1.5, [80, 10]),
|
||||
]
|
||||
surface = [[-20.0, 810.0], [-2.0, 800.0], [2.0, 800.0], [20.0, 790.0]]
|
||||
sec = {"surface": surface, "bottom_z": 780.0, "analyses": []}
|
||||
shape = [[0.0, 0.0], [2.0, 1.0], [2.0, 4.0], [0.0, 3.0]]
|
||||
slice_row = {
|
||||
"x_left": 0,
|
||||
"x_right": 2,
|
||||
"weight": 50,
|
||||
"surcharge": 5,
|
||||
"N_eff": 40,
|
||||
"U": 10,
|
||||
"S": 20,
|
||||
"E_left": 3,
|
||||
"E_right": 4,
|
||||
"X_left": 0,
|
||||
"X_right": 0,
|
||||
"base_length": 2.236,
|
||||
"pore_pressure": 4.47,
|
||||
}
|
||||
fs_list = [None, 1.0, 1.2, 1.35, 1.5, 1.65, 1.875, 2.25, 9.0]
|
||||
payload = {"rows": rows, "view": sec, "fsList": fs_list, "shape": shape, "slice": slice_row}
|
||||
proc = subprocess.run(
|
||||
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS)],
|
||||
input=json.dumps(payload),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
out = json.loads(proc.stdout)
|
||||
|
||||
# 판정 규칙 = 파이썬 overall_verdict
|
||||
for r, got in zip(rows, out["verdict"]):
|
||||
assert got == [
|
||||
view.overall_verdict(r),
|
||||
view.overall_verdict(r, "dry"),
|
||||
view.overall_verdict(r, "wet"),
|
||||
]
|
||||
assert out["sortFs"] == ["d", "b", "a", "e", "c"] # 없음은 맨 뒤
|
||||
assert out["ngOnly"] == ["b", "d"]
|
||||
# 띠 = 안전율 ÷ 기준(1.5) 경계 0.8 · 0.9 · 1.0 · 1.1 · 1.25 · 1.5
|
||||
assert out["bands"] == [None, 0, 1, 2, 3, 4, 5, 6, 6]
|
||||
assert [(b["from"], b["to"]) for b in out["legend"]] == [
|
||||
(None, 1.2),
|
||||
(1.2, 1.35),
|
||||
(1.35, 1.5),
|
||||
(1.5, 1.65),
|
||||
(1.65, 1.88),
|
||||
(1.88, 2.25),
|
||||
(2.25, None),
|
||||
]
|
||||
# 화면 좌표 — 좌측(+offset) 이 왼쪽 · 위 = 높은 표고 · 같은 축척 · 왕복 = 원래 값 · 판 안
|
||||
k, tx, ty = out["fit"]["k"], out["fit"]["tx"], out["fit"]["ty"]
|
||||
assert out["matrix"] == f"matrix({-k} 0 0 {-k} {tx} {ty})"
|
||||
xs = [p[0] for p in out["screen"]]
|
||||
assert xs == sorted(xs, reverse=True)
|
||||
for (x, z), (sx, sy), back in zip(surface, out["screen"], out["back"]):
|
||||
assert (sx, sy) == pytest.approx((-k * x + tx, -k * z + ty))
|
||||
assert back == pytest.approx([x, z]) and 0 <= sx <= 800 and 0 <= sy <= 500
|
||||
# 평면 띠 — 판정이 바뀔 때만 끊음 · 색 수 = 판정 수
|
||||
assert out["segments"] == [
|
||||
["ok", ["a"]],
|
||||
["ng", ["b"]],
|
||||
["none", ["c"]],
|
||||
["ng", ["d"]],
|
||||
["ok", ["e"]],
|
||||
]
|
||||
assert len({s[0] for s in out["segments"]}) == len(
|
||||
{view.overall_verdict(r, "wet") for r in rows}
|
||||
)
|
||||
|
||||
def vec(a):
|
||||
return (a["to"][0] - a["from"][0], a["to"][1] - a["from"][1])
|
||||
|
||||
arrows = {a["name"]: a for a in out["arrows"]}
|
||||
assert set(arrows) == {"W", "Q", "N", "U", "S", "EL", "ER"} # X = 0 은 뺌
|
||||
assert vec(arrows["W"]) == pytest.approx((0, -0.5)) # 50 × 0.01 아래
|
||||
base = (2 / math.sqrt(5), 1 / math.sqrt(5))
|
||||
nx, nz = vec(arrows["N"])
|
||||
assert nx * base[0] + nz * base[1] == pytest.approx(0, abs=1e-9) and nz > 0 # 바닥에 수직 · 위
|
||||
sx, sz = vec(arrows["S"])
|
||||
assert (sx, sz) == pytest.approx(
|
||||
(0.2 * base[0], 0.2 * base[1])
|
||||
) # 방향 −1 → 미끄럼 반대 = +바닥
|
||||
assert vec({a["name"]: a for a in out["arrowsPos"]}["S"]) == pytest.approx(
|
||||
(-0.2 * base[0], -0.2 * base[1])
|
||||
)
|
||||
assert arrows["EL"]["to"] == pytest.approx(
|
||||
[2.0, 2.5]
|
||||
) # 방향 −1 → 틀의 왼쪽 면 = 결과 오른쪽 면
|
||||
assert vec(arrows["EL"]) == pytest.approx((-0.03, 0))
|
||||
assert out["stress"][0] == pytest.approx(
|
||||
{"x": 1, "sigma": 40 / 2.236, "u": 4.47, "tau": 20 / 2.236}
|
||||
)
|
||||
assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"]
|
||||
assert out["zAt"] == [2.5, 0]
|
||||
|
||||
|
||||
def test_엔진_지문은_코드_내용():
|
||||
assert len(router.engine_hash()) == 12
|
||||
item = copy.deepcopy({"upper": "a", "lower": "b", "soil_thickness_m": 0.5})
|
||||
assert router._layers(item) == [["a", 0.5], ["b", None]]
|
||||
assert router._layers({"upper": "a", "lower": None}) == [["a", None]]
|
||||
assert router._layers(None) is None
|
||||
@@ -24,6 +24,7 @@
|
||||
import { ui_locales_b4 } from "./ui_template_locale_b4";
|
||||
import { ui_locales_common } from "./ui_template_locale_common";
|
||||
import { ui_locales_a } from "./ui_template_locale_a";
|
||||
import { ui_locales_e03 } from "./ui_template_locale_e03";
|
||||
import { ui_locales_b1 } from "./ui_template_locale_b1";
|
||||
import { ui_locales_b2 } from "./ui_template_locale_b2";
|
||||
import { ui_locales_b3 } from "./ui_template_locale_b3";
|
||||
@@ -65,6 +66,7 @@ export const ui_locales = {
|
||||
...ui_locales_b4,
|
||||
...ui_locales_common,
|
||||
...ui_locales_a,
|
||||
...ui_locales_e03,
|
||||
...ui_locales_b1,
|
||||
...ui_locales_b2,
|
||||
...ui_locales_b3,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_e03.ts
|
||||
* 다국어 사전 — E03 2D 측점 해석(요구 345 · 347 · PLAN 532-3)
|
||||
*
|
||||
* 신규 문구는 해당 섹션 최하단에 추가.
|
||||
* 이 파일은 사전 데이터만 담는다. 합성·언어 전환·t() 헬퍼는 ui_template_locale.ts 참조.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LocaleEntry } from "./ui_template_locale_common";
|
||||
|
||||
export const ui_locales_e03 = {
|
||||
/* --- 좌측 목록 · 단추 --- */
|
||||
E03_NoProject: ["현재 프로젝트가 없습니다.", "No current project."],
|
||||
E03_Condition: ["조건", "Condition"],
|
||||
E03_Cond_dry: ["건기", "Dry"],
|
||||
E03_Cond_wet: ["우기", "Wet"],
|
||||
E03_Cond_seismic: ["지진", "Seismic"],
|
||||
E03_Sort: ["정렬", "Sort"],
|
||||
E03_Sort_Chainage: ["측점 순", "By station"],
|
||||
E03_Sort_Fs: ["안전율 낮은 순", "Lowest FS first"],
|
||||
E03_NgOnly: ["N.G 만", "N.G only"],
|
||||
E03_Analyze: ["해석", "Analyze"],
|
||||
E03_Analyzing: ["해석 중", "Analyzing"],
|
||||
E03_Done: ["해석이 끝났습니다.", "Analysis finished."],
|
||||
E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."],
|
||||
E03_View3d: ["3D 보기", "3D View"],
|
||||
E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"],
|
||||
E03_RepSave: ["대표 단면 저장", "Save Representative"],
|
||||
E03_RepSaved: ["대표 단면을 저장했습니다.", "Representative sections saved."],
|
||||
E03_RepUnsaved: ["대표 단면 바뀜 — 저장 안 됨", "Representative changes not saved"],
|
||||
E03_Loading: ["불러오는 중…", "Loading…"],
|
||||
E03_NotYet: [
|
||||
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
|
||||
"Not analyzed yet — press [Analyze].",
|
||||
],
|
||||
E03_Computed: ["해석 시각", "Analyzed at"],
|
||||
E03_Stale: [
|
||||
"지반 조건이 바뀌었습니다 — [해석]을 다시 누르세요(바뀐 측점만 다시 셈).",
|
||||
"Ground conditions changed — press [Analyze] again (only changed stations).",
|
||||
],
|
||||
E03_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."],
|
||||
E03_ListEmpty: ["보일 측점이 없습니다.", "No stations to show."],
|
||||
E03_Col_Station: ["측점", "Station"],
|
||||
E03_Col_Role: ["좌 · 우", "L · R"],
|
||||
E03_Col_Rep: ["대표", "Rep."],
|
||||
E03_Verdict: ["판정", "Verdict"],
|
||||
E03_Verdict_ok: ["O.K", "O.K"],
|
||||
E03_Verdict_ng: ["N.G", "N.G"],
|
||||
E03_Verdict_none: ["해석 없음", "No result"],
|
||||
E03_Error: ["오류", "Error"],
|
||||
E03_Cut: ["깎기", "Cut"],
|
||||
E03_Fill: ["쌓기", "Fill"],
|
||||
E03_Left: ["좌 비탈", "Left slope"],
|
||||
E03_Right: ["우 비탈", "Right slope"],
|
||||
E03_Side: ["비탈", "Slope"],
|
||||
E03_Rep_Hint: [
|
||||
"대표 단면(보고서) — 자동: 쌓기 10 m · 깎기 15 m 이상 · 최소 안전율 하위 5 · 누르면 켬/끔",
|
||||
"Representative section (report) — auto: fill ≥ 10 m · cut ≥ 15 m · lowest 5 FS · click to toggle",
|
||||
],
|
||||
E03_Rep_fill_height: ["쌓기 10 m 이상", "Fill ≥ 10 m"],
|
||||
E03_Rep_cut_height: ["깎기 15 m 이상", "Cut ≥ 15 m"],
|
||||
E03_Rep_lowest: ["최소 안전율 하위 5", "Lowest 5 FS"],
|
||||
E03_Rep_User: ["사용자 지정", "User set"],
|
||||
|
||||
/* --- 평면 · 횡단 --- */
|
||||
E03_Plan: ["평면 위험 띠", "Plan Risk Band"],
|
||||
E03_PickStation: ["측점을 고르세요.", "Pick a station."],
|
||||
E03_Show_Map: ["등안전율 지도", "Safety map"],
|
||||
E03_Show_Centers: ["원호 중심 격자", "Center grid"],
|
||||
E03_Show_Candidates: ["다른 후보", "Other surfaces"],
|
||||
E03_Legend_Map: ["등안전율(Fs)", "Safety factor"],
|
||||
E03_Legend_Layers: ["지층", "Layers"],
|
||||
E03_Offset: ["거리", "Offset"],
|
||||
E03_NoWalls: [
|
||||
"옹벽 · 석축은 아직 해석에 반영 전입니다(벽 없는 단면으로 셈).",
|
||||
"Retaining walls and masonry are not yet reflected (analyzed without walls).",
|
||||
],
|
||||
|
||||
/* --- 우측 결과 --- */
|
||||
E03_Result: ["결과", "Result"],
|
||||
E03_NoSurface: ["이 비탈은 해석된 활동면이 없습니다.", "No slip surface for this slope."],
|
||||
E03_Fs: ["최소 안전율", "Min FS"],
|
||||
E03_Required: ["기준안전율", "Required FS"],
|
||||
E03_Method: ["방법", "Method"],
|
||||
E03_Method_ordinary: ["Fellenius", "Fellenius"],
|
||||
E03_Method_bishop: ["Bishop", "Bishop"],
|
||||
E03_Method_janbu: ["Janbu 보정", "Janbu corr."],
|
||||
E03_Method_spencer: ["Spencer", "Spencer"],
|
||||
E03_Circle: ["원호", "Circular"],
|
||||
E03_NonCircle: ["비원호", "Non-circular"],
|
||||
E03_Converged: ["수렴", "Converged"],
|
||||
E03_Yes: ["예", "Yes"],
|
||||
E03_No: ["아니오", "No"],
|
||||
E03_Iter: ["회", " iter."],
|
||||
E03_Height: ["비탈 높이", "Slope height"],
|
||||
E03_Center: ["원호 중심", "Center"],
|
||||
E03_Radius: ["반경", "Radius"],
|
||||
E03_Slices: ["절편표", "Slice Data"],
|
||||
E03_Col_No: ["#", "#"],
|
||||
E03_Col_Width: ["폭 b", "b"],
|
||||
E03_Col_Height: ["높이 h", "h"],
|
||||
E03_Col_Alpha: ["α°", "α°"],
|
||||
E03_Col_W: ["W+Q", "W+Q"],
|
||||
E03_Col_U: ["u", "u"],
|
||||
E03_Col_N: ["N'", "N'"],
|
||||
E03_Col_S: ["S", "S"],
|
||||
E03_Col_C: ["c", "c"],
|
||||
E03_Col_Phi: ["φ°", "φ°"],
|
||||
E03_Fbd: ["절편 자유물체도", "Slice Free Body"],
|
||||
E03_PickSlice: [
|
||||
"절편표 줄이나 횡단의 절편을 누르면 힘이 보입니다.",
|
||||
"Click a slice row or a slice in the section to see its forces.",
|
||||
],
|
||||
E03_FbdUnit: ["힘 kN/m · 길이 = 힘 비례", "forces kN/m · length ∝ force"],
|
||||
E03_Graph: ["절편력 그래프", "Slice Force Graph"],
|
||||
E03_Graph_Sigma: ["유효 수직응력 σ'", "Effective normal σ'"],
|
||||
E03_Graph_U: ["간극수압 u", "Pore pressure u"],
|
||||
E03_Graph_Tau: ["동원 전단응력 τ", "Mobilized shear τ"],
|
||||
} as const satisfies Record<string, LocaleEntry>;
|
||||
Reference in New Issue
Block a user