Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_1
This commit is contained in:
@@ -8,15 +8,17 @@
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
|
||||
/** 빠진 측점이 없으면 빈 칸(띠 없음) */
|
||||
export function buildMissingBanner(missing: number[] | undefined): HTMLElement {
|
||||
/** 빠진 측점이 없으면 빈 칸(띠 없음) · 측점 글 = No.+거리 — interval = 측점 간격(부르는 쪽이
|
||||
* `inferStationInterval(측점 목록)` 으로 넘김 · 없으면 20 m) */
|
||||
export function buildMissingBanner(missing: number[] | undefined, interval = 20): HTMLElement {
|
||||
const list = missing ?? [];
|
||||
if (!list.length) return el("div");
|
||||
const box = createBanner({
|
||||
tone: "warning",
|
||||
text: t("E_Missing_Head").replace("{n}", String(list.length)),
|
||||
details: el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }),
|
||||
details: el("p", { text: list.map((m) => stationLabel(m, interval, 1)).join(" · ") }),
|
||||
});
|
||||
box.classList.add("e-missing");
|
||||
box.dataset.role = "missing-stations";
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { tooltipBox } from "@ui/ui_template_hover_info";
|
||||
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { cutCells, footprintCenter } from "./logic/E04_Stab3D_Mesh";
|
||||
import {
|
||||
@@ -43,8 +44,6 @@ interface View {
|
||||
|
||||
const RANGE_COLOR = "#2563eb"; // 고른 구간 — 3D 노선 관과 같은 색 · 안전율 색 단계와 겹치지 않음
|
||||
|
||||
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
|
||||
|
||||
const css = (name: string, fallback: string): string =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
|
||||
@@ -60,6 +59,7 @@ export function createPlanMap(): PlanMap {
|
||||
|
||||
let line: number[][] = [];
|
||||
let stations: StationPoint[] = [];
|
||||
let interval = 20; // 측점 간격 — setRoute 때 측점 목록에서 추정
|
||||
let range: [number, number] | null = null;
|
||||
let result: Result3d | null = null;
|
||||
let layer: MapLayer = "fs";
|
||||
@@ -140,8 +140,8 @@ export function createPlanMap(): PlanMap {
|
||||
ctx.stroke();
|
||||
}
|
||||
// 측점 — 화면 간격이 좁으면 다섯 개마다 이름
|
||||
// 이름 = "STA.0+020.000" → "0+020" · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다
|
||||
const gap = stations.length > 1 ? 20 * view.scale : 999;
|
||||
// 이름 = No.+거리(예 61+14.5) · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다
|
||||
const gap = stations.length > 1 ? interval * view.scale : 999;
|
||||
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "middle";
|
||||
@@ -152,7 +152,7 @@ export function createPlanMap(): PlanMap {
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (i % every === 0 && s.label) ctx.fillText(shortLabel(s.label), px + 6, py - 8);
|
||||
if (i % every === 0) ctx.fillText(stationLabel(s.chainage_m, interval, 1), px + 6, py - 8);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ export function createPlanMap(): PlanMap {
|
||||
tip.textContent = [
|
||||
`${t("E04_Layer_FS")} ${f.toFixed(3)}`,
|
||||
`${t("E04_Layer_Volume")} ${v === null ? "-" : `${Math.round(v).toLocaleString()} m³`}`,
|
||||
near ? `${t("E04_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
|
||||
near ? `${t("E04_Tip_Station")} ${stationLabel(near.chainage_m, interval, 1)}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
@@ -351,6 +351,7 @@ export function createPlanMap(): PlanMap {
|
||||
setRoute(nextLine, nextStations) {
|
||||
line = nextLine;
|
||||
stations = nextStations;
|
||||
interval = inferStationInterval(stations);
|
||||
fit();
|
||||
draw();
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
|
||||
import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map";
|
||||
import { createView3D } from "./E04_Stab3D_UI_View3D";
|
||||
import { buildSettingsCards, type Settings } from "./E04_Stab3D_UI_Settings";
|
||||
@@ -246,6 +247,9 @@ export function renderE04Stab3D(root: HTMLElement): void {
|
||||
let result: Result3d | null = null;
|
||||
let stations2d: Station2d[] = [];
|
||||
let running = false;
|
||||
/** 측점 글 = No.+거리(소수 한 자리) · 간격 = 측점 목록 추정 */
|
||||
const station = (chainage: number): string =>
|
||||
stationLabel(chainage, inferStationInterval(overview?.stations ?? []), 1);
|
||||
|
||||
const remember = (): void =>
|
||||
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read()));
|
||||
@@ -278,7 +282,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
|
||||
const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`));
|
||||
const saved = savedOf.get(`${key}_${condition}`);
|
||||
const row = createListRow({
|
||||
label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`,
|
||||
label: `${station(section.start_m)} – ${station(section.end_m)}`,
|
||||
trailing: byCondition
|
||||
.flatMap((x) =>
|
||||
x
|
||||
@@ -313,7 +317,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
|
||||
s.end_m,
|
||||
);
|
||||
const ratio = ratio3d2d(s.f_min, f2d);
|
||||
const station = overview?.stations.reduce<StationPoint | null>(
|
||||
const nearest = overview?.stations.reduce<StationPoint | null>(
|
||||
(best, x) =>
|
||||
!best || Math.abs(x.chainage_m - s.chainage_m) < Math.abs(best.chainage_m - s.chainage_m)
|
||||
? x
|
||||
@@ -335,7 +339,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
|
||||
["E04_Result_Bishop", s.by_method.bishop.toFixed(3)],
|
||||
["E04_Result_Janbu", s.by_method.janbu_corrected.toFixed(3)],
|
||||
["E04_Result_Hovland", s.by_method.hovland.toFixed(3)],
|
||||
["E04_Result_Where", station?.label ?? `${s.chainage_m.toFixed(1)} m`],
|
||||
["E04_Result_Where", station(nearest?.chainage_m ?? s.chainage_m)],
|
||||
["E04_Result_Cells", s.cells.toLocaleString()],
|
||||
["E04_Result_Trials", s.trials.toLocaleString()],
|
||||
["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Page.ts
|
||||
* E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow).
|
||||
* 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석]
|
||||
* 좌 = 절리군 표 · 해석 설정 · [초기화] [해석]
|
||||
* 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도)
|
||||
* 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표
|
||||
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset ·
|
||||
* [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET.
|
||||
* 조작 = sessionStorage 캐시만(자동저장 없음) · [초기화] = POST …/reset ·
|
||||
* [해석] = 저장(고침이 있으면 PUT) → POST …/analyze(서버 단독) → 결과 GET · 저장 단추 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./E06_StabRock_UI_Style.css";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import {
|
||||
buildJointCards,
|
||||
@@ -88,11 +89,6 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
// ---- 좌 ----
|
||||
const cards = el("div", { className: "e06-cards" });
|
||||
const status = el("div", { className: "e06-status" });
|
||||
const saveButton = createButton({
|
||||
label: t("Common_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: () => void save(),
|
||||
});
|
||||
const resetButton = createButton({
|
||||
label: t("Common_Btn_Reset"),
|
||||
variant: "danger-ghost",
|
||||
@@ -103,9 +99,9 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
variant: "filled",
|
||||
onClick: () => void analyze(),
|
||||
});
|
||||
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true;
|
||||
resetButton.disabled = analyzeButton.disabled = true;
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(resetButton, saveButton, analyzeButton);
|
||||
actions.append(resetButton, analyzeButton);
|
||||
panel.append(note(t("E06_Note")), cards, status, actions);
|
||||
attachCollapsible(cards);
|
||||
|
||||
@@ -163,6 +159,11 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
let result: RockResult | null = null;
|
||||
let active: string | null = null;
|
||||
let shown: StationResponse | null = null;
|
||||
let interval = 20; // 측점 간격 — 처음 GET 의 측점 목록에서 추정
|
||||
/** 측점 글 = No.+거리(소수 한 자리) — 서버 label(STA. 꼴) 대신 */
|
||||
const relabel = (e: StationEntry): void => {
|
||||
e.label = stationLabel(e.chainage_m, interval, 1);
|
||||
};
|
||||
|
||||
const invalidCount = (): number => cards.querySelectorAll('[aria-invalid="true"]').length;
|
||||
const paintStatus = (): void => {
|
||||
@@ -246,6 +247,7 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
shown = await api<StationResponse>(
|
||||
`/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`,
|
||||
);
|
||||
relabel(shown.entry);
|
||||
} catch (error) {
|
||||
shown = null;
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
@@ -256,6 +258,7 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
async function loadResult(): Promise<void> {
|
||||
const body = await api<ResultResponse>(`/projects/${projectId}/stab/rock/result`);
|
||||
result = body.result;
|
||||
result?.stations.forEach(relabel);
|
||||
staleBar.hidden = !body.stale;
|
||||
const stations = result?.stations ?? [];
|
||||
const keep = stations.find((e) => entryId(e) === active);
|
||||
@@ -274,7 +277,7 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
if (!cached) sessionStorage.removeItem(cacheKey(projectId));
|
||||
view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints;
|
||||
dirty = Boolean(cached);
|
||||
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable;
|
||||
resetButton.disabled = analyzeButton.disabled = !view.editable;
|
||||
renderCards();
|
||||
};
|
||||
|
||||
@@ -292,20 +295,6 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
if (await putJoints()) {
|
||||
showToast(t("E06_Saved"), "success");
|
||||
await loadResult();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
if (!view?.editable) return;
|
||||
const ok = await showConfirmDialog(
|
||||
@@ -353,6 +342,7 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
try {
|
||||
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`);
|
||||
const stations = body.stations ?? [];
|
||||
interval = inferStationInterval(stations);
|
||||
projection = body.joints.settings.projection;
|
||||
paintProjection();
|
||||
view = {
|
||||
|
||||
@@ -113,8 +113,8 @@ export function buildBand(
|
||||
});
|
||||
const ticks = el("div", { className: "e06-band__ticks" });
|
||||
ticks.append(
|
||||
el("span", { text: `${lo.toFixed(0)} m` }),
|
||||
el("span", { text: `${hi.toFixed(0)} m` }),
|
||||
el("span", { text: list[0].label ?? `${lo.toFixed(0)} m` }),
|
||||
el("span", { text: list[list.length - 1].label ?? `${hi.toFixed(0)} m` }),
|
||||
);
|
||||
box.append(legend, strip, ticks);
|
||||
} else box.append(legend, note(t("E06_NoRock")));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""E07 광역 자연사면 위험 지도 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2).
|
||||
|
||||
- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문이 저장본과 같으면
|
||||
`cached` · force = 다시
|
||||
- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문(복사본 · 설정 · E02 물성 ·
|
||||
엔진 파일 해시)이 저장본과 같으면 `cached` · force = 다시(시험 · 관리용)
|
||||
- `GET …/stab/area/progress` — 진행률
|
||||
- `GET …/stab/area` — 기본값(E02 붕적토) · 물성 목록 · 노선 · 측점 · 저장본(격자 · 측점표 · 요약)
|
||||
|
||||
@@ -26,6 +26,7 @@ import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
@@ -46,7 +47,8 @@ from E07_StabArea import E07_StabArea_Engine as engine
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["E07 Stab Area"])
|
||||
|
||||
ENGINE_VERSION = "e07-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
|
||||
ENGINE_FILES = ("E07_StabArea_Engine.py", "E07_StabArea_Router.py") # 엔진 판 = 이 파일들 해시
|
||||
MATERIAL_KEYS = ("gamma_t", "gamma_sat", "c", "phi")
|
||||
RESULT = Path("E07_StabArea") / "_derived" / "area.json"
|
||||
DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토)
|
||||
RANGE_C_KPA, RANGE_PHI_DEG = (
|
||||
@@ -122,8 +124,33 @@ def defaults(project_root: Path) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str:
|
||||
body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION}
|
||||
@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_material(project_root: Path, material_id: str | None) -> dict[str, Any] | None:
|
||||
"""E02 물성 값(고른 id · 없으면 붕적토) — 지문에 넣어 E02 가 바뀌면 옛 결과가 됨."""
|
||||
materials = _ground(project_root).get("materials") or []
|
||||
mat = next((m for m in materials if m.get("id") == material_id), None) or next(
|
||||
(m for m in materials if m.get("id") == DEFAULT_MATERIAL), None
|
||||
)
|
||||
return {k: mat.get(k) for k in ("id", *MATERIAL_KEYS)} if mat else None
|
||||
|
||||
|
||||
def fingerprint(
|
||||
source_fingerprint: str, settings: dict[str, Any], ground: dict[str, Any] | None = None
|
||||
) -> str:
|
||||
body = {
|
||||
"source": source_fingerprint,
|
||||
"settings": settings,
|
||||
"ground": ground,
|
||||
"engine": engine_hash(),
|
||||
}
|
||||
text = json.dumps(body, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -216,7 +243,7 @@ def run(
|
||||
}
|
||||
doc = {
|
||||
"fingerprint": finger,
|
||||
"engine": ENGINE_VERSION,
|
||||
"engine": engine_hash(),
|
||||
"settings": settings,
|
||||
"computed_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"elapsed_s": round(time.perf_counter() - started, 2),
|
||||
@@ -275,7 +302,11 @@ async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]:
|
||||
project_root = await _project_root(project_id)
|
||||
root = _source_root(project_root)
|
||||
settings = payload.model_dump(exclude={"force"})
|
||||
finger = fingerprint(source.manifest(root)["source_fingerprint"], settings)
|
||||
finger = fingerprint(
|
||||
source.manifest(root)["source_fingerprint"],
|
||||
settings,
|
||||
ground_material(project_root, settings["material_id"]),
|
||||
)
|
||||
saved = await asyncio.to_thread(load, project_root)
|
||||
if not payload.force and saved and saved["fingerprint"] == finger:
|
||||
return {"status": "cached", "summary": saved["summary"]}
|
||||
@@ -311,7 +342,11 @@ async def progress(project_id: UUID) -> dict[str, Any]:
|
||||
def _overview(project_root: Path, root: Path) -> dict[str, Any]:
|
||||
saved = load(project_root)
|
||||
if saved:
|
||||
current = fingerprint(source.manifest(root)["source_fingerprint"], saved["settings"])
|
||||
current = fingerprint(
|
||||
source.manifest(root)["source_fingerprint"],
|
||||
saved["settings"],
|
||||
ground_material(project_root, saved["settings"].get("material_id")),
|
||||
)
|
||||
saved["stale"] = saved["fingerprint"] != current
|
||||
return {
|
||||
"defaults": defaults(project_root),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { tooltipBox } from "@ui/ui_template_hover_info";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
|
||||
import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster";
|
||||
|
||||
export type MapLayer = "cls" | "fs";
|
||||
@@ -34,8 +35,6 @@ interface View {
|
||||
scale: number; // px / m
|
||||
}
|
||||
|
||||
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
|
||||
|
||||
const css = (name: string, fallback: string): string =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
|
||||
@@ -51,6 +50,7 @@ export function createAreaMap(): AreaMap {
|
||||
|
||||
let line: number[][] = [];
|
||||
let stations: StationPoint[] = [];
|
||||
let interval = 20; // 측점 간격 — setRoute 때 측점 목록에서 추정
|
||||
let band = 100;
|
||||
let data: Decoded | null = null;
|
||||
let layer: MapLayer = "cls";
|
||||
@@ -112,7 +112,7 @@ export function createAreaMap(): AreaMap {
|
||||
ctx.lineWidth = 2;
|
||||
path();
|
||||
ctx.stroke();
|
||||
const gap = stations.length > 1 ? 20 * view.scale : 999;
|
||||
const gap = stations.length > 1 ? interval * view.scale : 999;
|
||||
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "middle";
|
||||
@@ -124,7 +124,8 @@ export function createAreaMap(): AreaMap {
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9);
|
||||
if (on || i % every === 0)
|
||||
ctx.fillText(stationLabel(s.chainage_m, interval, 1), px + 7, py - 9);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,7 +228,7 @@ export function createAreaMap(): AreaMap {
|
||||
t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey),
|
||||
`SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`,
|
||||
`${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`,
|
||||
near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
|
||||
near ? `${t("E07_Tip_Station")} ${stationLabel(near.chainage_m, interval, 1)}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
@@ -265,6 +266,7 @@ export function createAreaMap(): AreaMap {
|
||||
setRoute(nextLine, nextStations) {
|
||||
line = nextLine;
|
||||
stations = nextStations;
|
||||
interval = inferStationInterval(stations);
|
||||
fit();
|
||||
draw();
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률
|
||||
* 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례
|
||||
* 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점)
|
||||
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage.
|
||||
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage
|
||||
* · 없으면 지난 결과 조건 + 물성은 지금 E02 값.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./E07_StabArea_UI_Style.css";
|
||||
@@ -29,6 +30,7 @@ import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createTable } from "@ui/ui_template_table";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
|
||||
import {
|
||||
@@ -86,8 +88,6 @@ const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [
|
||||
];
|
||||
|
||||
const cacheKey = (projectId: string): string => `e07_settings:${projectId}`;
|
||||
const shortLabel = (label: string | null, ch: number): string =>
|
||||
(label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, "");
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
@@ -239,15 +239,10 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
const analyzeButton = createButton({
|
||||
label: t("E07_Analyze"),
|
||||
variant: "filled",
|
||||
onClick: () => void analyze(false),
|
||||
});
|
||||
const reanalyzeButton = createButton({
|
||||
label: t("E07_Reanalyze"),
|
||||
variant: "ghost",
|
||||
onClick: () => void analyze(true),
|
||||
onClick: () => void analyze(),
|
||||
});
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(defaultsButton, reanalyzeButton, analyzeButton);
|
||||
actions.append(defaultsButton, analyzeButton);
|
||||
const readOnly = note(t("E07_ReadOnly"), "e07-hint");
|
||||
readOnly.hidden = true;
|
||||
panel.append(
|
||||
@@ -295,24 +290,31 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
remember();
|
||||
});
|
||||
fields.get("band_m")!.input.addEventListener("input", paintScenario);
|
||||
material.select.addEventListener("change", () => {
|
||||
const m = overview?.materials.find((x) => x.id === material.select.value);
|
||||
if (!m) return;
|
||||
const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v));
|
||||
/** E02 물성 값(범위 위 = 기본값과 같은 폭) — 없는 id 면 빈 것 */
|
||||
function materialValues(id: unknown): Settings {
|
||||
const m = overview?.materials.find((x) => x.id === id);
|
||||
if (!m) return {};
|
||||
const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c);
|
||||
const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi);
|
||||
set("gamma_t", m.gamma_t);
|
||||
set("gamma_sat", m.gamma_sat);
|
||||
set("c", m.c);
|
||||
set("c_hi", m.c + rangeC);
|
||||
set("phi", m.phi);
|
||||
set("phi_hi", m.phi + rangePhi);
|
||||
return {
|
||||
gamma_t: m.gamma_t,
|
||||
gamma_sat: m.gamma_sat,
|
||||
c: m.c,
|
||||
c_hi: m.c + rangeC,
|
||||
phi: m.phi,
|
||||
phi_hi: m.phi + rangePhi,
|
||||
};
|
||||
}
|
||||
|
||||
material.select.addEventListener("change", () => {
|
||||
const values = materialValues(material.select.value);
|
||||
for (const [key, v] of Object.entries(values)) fields.get(key)!.input.value = String(v);
|
||||
remember();
|
||||
});
|
||||
|
||||
function paintButtons(): void {
|
||||
const editable = Boolean(overview?.editable);
|
||||
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
|
||||
analyzeButton.disabled = !editable || running || !overview;
|
||||
readOnly.hidden = !overview || editable;
|
||||
}
|
||||
|
||||
@@ -376,7 +378,7 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
{ label: t("E07_Col_RiskArea"), align: "end" },
|
||||
],
|
||||
result.stations.map((r) => [
|
||||
shortLabel(r.label, r.chainage_m),
|
||||
stationLabel(r.chainage_m, inferStationInterval(overview?.stations ?? []), 1),
|
||||
r.fs_min === undefined ? "-" : r.fs_min.toFixed(2),
|
||||
r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1),
|
||||
r.level ? chipCell(LEVEL_COLORS[r.level], t(`E07_Level_${r.level}` as LocaleKey)) : "",
|
||||
@@ -445,11 +447,11 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function analyze(force: boolean): Promise<void> {
|
||||
async function analyze(): Promise<void> {
|
||||
try {
|
||||
const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...read(), force }),
|
||||
body: JSON.stringify(read()),
|
||||
});
|
||||
if (body.status === "cached") {
|
||||
showToast(t("E07_Cached"), "info");
|
||||
@@ -478,8 +480,12 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
const cached = JSON.parse(
|
||||
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
|
||||
) as Settings | null;
|
||||
// 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움
|
||||
write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) });
|
||||
// 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값
|
||||
const last = ov.result?.settings as Settings | undefined;
|
||||
write({
|
||||
...ov.defaults,
|
||||
...(cached ?? { ...last, ...materialValues(last?.material_id ?? ov.defaults.material_id) }),
|
||||
});
|
||||
map.setRoute(ov.line, ov.stations);
|
||||
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
|
||||
if (p?.running) void poll();
|
||||
|
||||
@@ -266,3 +266,15 @@ def test_길_띠_측점표_저장본(project):
|
||||
"s", {**settings, "c": 6}
|
||||
)
|
||||
json.dumps(doc) # 그대로 JSON
|
||||
|
||||
|
||||
def test_지문_E02_물성_엔진_해시(project, monkeypatch):
|
||||
"""광역 물성 = E02 값 — E02 붕적토가 바뀌거나 엔진 파일이 바뀌면 지문이 달라 옛 결과."""
|
||||
settings = router_mod.defaults(project)
|
||||
ground = router_mod.ground_material(project, settings["material_id"])
|
||||
assert ground["id"] == "colluvium" and ground["c"] == settings["c"]
|
||||
assert router_mod.ground_material(project, "없는것") == ground # 모르는 id = 붕적토
|
||||
before = router_mod.fingerprint("s", settings, ground)
|
||||
assert before != router_mod.fingerprint("s", settings, {**ground, "c": ground["c"] + 1})
|
||||
monkeypatch.setattr(router_mod, "engine_hash", lambda: "바뀜")
|
||||
assert before != router_mod.fingerprint("s", settings, ground)
|
||||
|
||||
@@ -10,8 +10,8 @@ import type { LocaleEntry } from "./ui_template_locale_common";
|
||||
export const ui_locales_e06 = {
|
||||
/* --- E06 좌측 --- */
|
||||
E06_Note: [
|
||||
"암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [저장] → [해석]. 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.",
|
||||
"Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, [Save] → [Analyze]. Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).",
|
||||
"암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [해석](넣은 값 저장 + 해석). 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.",
|
||||
"Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, then [Analyze] (saves and analyzes). Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).",
|
||||
],
|
||||
E06_Card_Joints: ["절리군", "Joint sets"],
|
||||
E06_JointsHint: [
|
||||
@@ -61,8 +61,10 @@ export const ui_locales_e06 = {
|
||||
E06_Running: ["해석 중…", "Analyzing…"],
|
||||
E06_Done: ["암반 해석을 마쳤습니다.", "Rock analysis done."],
|
||||
E06_Fresh: ["입력이 같아 지난 결과를 씁니다.", "Inputs unchanged — previous result kept."],
|
||||
E06_Saved: ["저장했습니다.", "Saved."],
|
||||
E06_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
|
||||
E06_Unsaved: [
|
||||
"저장 안 됨(해석 전) — [해석]을 누르면 저장됩니다.",
|
||||
"Not saved (before analysis) — [Analyze] saves.",
|
||||
],
|
||||
E06_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
|
||||
E06_Invalid: ["틀린 칸", "Invalid fields"],
|
||||
E06_Err_Range: ["범위 밖", "Out of range"],
|
||||
|
||||
@@ -44,7 +44,6 @@ export const ui_locales_e07 = {
|
||||
E07_LevelHi: ["측점 높음 문턱 (SI<1 비율 %)", "Station high threshold (SI<1 share %)"],
|
||||
E07_Defaults: ["기본값", "Defaults"],
|
||||
E07_Analyze: ["해석", "Analyze"],
|
||||
E07_Reanalyze: ["다시 해석", "Re-analyze"],
|
||||
E07_Running: ["해석 중", "Analyzing"],
|
||||
E07_Done: ["광역 해석을 마쳤습니다.", "Area analysis finished."],
|
||||
E07_Cached: ["같은 조건의 결과가 있습니다.", "A result with the same inputs exists."],
|
||||
@@ -67,8 +66,8 @@ export const ui_locales_e07 = {
|
||||
E07_Result_Title: ["광역 결과", "Area result"],
|
||||
E07_Result_None: ["아직 해석 결과가 없습니다.", "No result yet."],
|
||||
E07_Result_Stale: [
|
||||
"엔진이 바뀌어 결과가 옛것입니다 — [다시 해석].",
|
||||
"The engine changed — re-analyze.",
|
||||
"입력(E02 물성 · 엔진)이 바뀌어 결과가 옛것입니다 — [해석]을 누르세요.",
|
||||
"Inputs (E02 soil · engine) changed — press [Analyze].",
|
||||
],
|
||||
E07_Result_Note: [
|
||||
"문헌 추정치 · 현장 시험 없음 · 기본값은 바꿀 수 있음. 표준 = 포화비 W = min(R·a / (T·sinθ), 1). SI = c · φ · T/R 범위 안에서 Fs > 1 일 확률(하한 Fs ≥ 1 이면 그 값 · 상한 Fs < 1 이면 0). Fs = 가장 나쁜 쪽(c · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",
|
||||
|
||||
Reference in New Issue
Block a user