Files
Aislo/E05_StabReport/E05_StabReport_Figure.py
T
eomsangdonandClaude Opus 5.5 e7afaa6664 feat(534-3): E05 안정성평가 보고서 — 빌더 · storage · 길 · 화면
- 빌더: E02 지반 · E03 2D 요약 · 측점별 저장본 · 대표 단면 · E04 3D 요약 → 프로그램 몫 시트(입력조건 · 측점표 · 대표단면 · 3D요약) · 사용자 몫(표지 · 개요)은 처음 한 번 · N.G 서식 10 · O.K 서식 11 직접 · 해석 지문
- 그림: 서버 SVG(지층 · 암 경계 · 우기 지하수위 · 임계면 · 노폭 · 비탈 높이 · 평면 위험 띠) → PNG(공용 svg_png) · fig/<측점 키>.png
- storage: 작업본 report.json · meta(지문 · 시트 서명) · 초기값 initial_snapshot · 갱신 덮임 목록 · 옛 판 _지난/
- 길: GET(없으면 처음 만듦) · PUT 판 409 + 서버 재계산 · refresh · reset · xlsx · pdf(POST 화면 책 · GET 작업본) · 그림
- 화면: 내장 엑셀(시트 탭 · 그림 겹판) · 캐시 · [저장] · [초기화] · [보고서 갱신] · 해석 바뀜 띠 · [엑셀] · [PDF] · [PDF 미리보기]
- 시험 test_534_3_e05_report 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:34:40 +09:00

300 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦.
section_svg(…) → 대표 단면(712 × 533 · 4 : 3) — 원지반 · 공사 뒤 지표 · 암 경계 ·
우기 지하수위 ·
조건별 임계 활동면 · 노폭 · 비탈 높이 치수 · 표고 · 거리 눈금
band_svg(…) → 평면 위험 띠(684 × 320) — 노선 · 측점 2D 판정 색 · 3D 구간 띠
좌표 = B06 횡단 offset(좌 +) · 화면 왼쪽 = +offset
(B06 횡단도 관례 · `B06_Section_UI_Cross_View.ts`).
값 셈 없음 — 해석 결과(E03 · E04 저장본)를 그리기만.
"""
from __future__ import annotations
import math
from html import escape
from typing import Any
SECTION_W, SECTION_H = 712, 533
BAND_W, BAND_H = 684, 320
OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222"
CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"}
CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
FONT = "font-family='Malgun Gothic, sans-serif'"
def _nice_step(span: float, count: int) -> float:
raw = max(span, 1e-6) / max(count, 1)
base = 10 ** math.floor(math.log10(raw))
return next(m * base for m in (1, 2, 5, 10) if m * base >= raw)
def _poly(points: list[tuple[float, float]], **attrs: str) -> str:
if len(points) < 2:
return ""
d = " ".join(f"{x:.1f},{y:.1f}" for x, y in points)
extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items())
return f"<polyline points='{d}' fill='none' {extra}/>"
def _text(x: float, y: float, text: str, size: int = 11, anchor: str = "middle", **attrs) -> str:
extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items())
return (
f"<text x='{x:.1f}' y='{y:.1f}' font-size='{size}' text-anchor='{anchor}' {FONT} "
f"{extra}>{escape(text)}</text>"
)
def _interp(line: list[tuple[float, float]], x: float) -> float | None:
for (x0, z0), (x1, z1) in zip(line, line[1:]):
if min(x0, x1) - 1e-9 <= x <= max(x0, x1) + 1e-9:
return z0 if x1 == x0 else z0 + (z1 - z0) * (x - x0) / (x1 - x0)
return None
def section_svg(
*,
title: str,
ground: list[tuple[float, float]],
surface: list[tuple[float, float]],
rock: list[tuple[float, float]] | None,
slips: list[dict[str, Any]],
road: tuple[float, float] | None,
heights: list[dict[str, Any]],
wet: bool,
) -> str:
"""대표 단면 SVG.
ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] ·
road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1}] 비탈 높이 치수.
"""
pad_l, pad_r, pad_t, pad_b = 56, 16, 34, 64
pts = [*ground, *surface, *(p for s in slips for p in s["points"])]
if not pts:
return _empty(SECTION_W, SECTION_H, title)
xs = [p[0] for p in pts]
zs = [p[1] for p in pts]
x_lo, x_hi = min(xs), max(xs)
z_lo, z_hi = min(zs), max(zs)
z_lo -= 0.08 * (z_hi - z_lo + 1)
z_hi += 0.12 * (z_hi - z_lo + 1)
w, h = SECTION_W - pad_l - pad_r, SECTION_H - pad_t - pad_b
scale = min(w / max(x_hi - x_lo, 1e-6), h / max(z_hi - z_lo, 1e-6)) # 가로 · 세로 같은 축척
cx = pad_l + (w - (x_hi - x_lo) * scale) / 2
cz = pad_t + (h - (z_hi - z_lo) * scale) / 2
def sx(x: float) -> float:
return cx + (x_hi - x) * scale # +offset = 화면 왼쪽
def sz(z: float) -> float:
return cz + (z_hi - z) * scale
def screen(line: list[tuple[float, float]]) -> list[tuple[float, float]]:
return [(sx(x), sz(z)) for x, z in line]
out = [
f"<svg xmlns='http://www.w3.org/2000/svg' width='{SECTION_W}' height='{SECTION_H}' "
f"viewBox='0 0 {SECTION_W} {SECTION_H}'>",
f"<rect width='{SECTION_W}' height='{SECTION_H}' fill='#ffffff'/>",
_text(SECTION_W / 2, 20, title, 13, font_weight="bold", fill=INK),
]
# 눈금(표고 · 거리)
step_z = _nice_step(z_hi - z_lo, 6)
z = math.ceil(z_lo / step_z) * step_z
while z <= z_hi:
y = sz(z)
out.append(
f"<line x1='{pad_l}' y1='{y:.1f}' x2='{SECTION_W - pad_r}' y2='{y:.1f}' "
"stroke='#EEEEEE' stroke-width='1'/>"
)
out.append(_text(pad_l - 6, y + 4, f"{z:g}", 10, "end", fill="#666666"))
z += step_z
step_x = _nice_step(x_hi - x_lo, 8)
x = math.ceil(x_lo / step_x) * step_x
base_y = SECTION_H - pad_b + 14
while x <= x_hi:
out.append(_text(sx(x), base_y, f"{x:g}", 10, fill="#666666"))
x += step_x
out.append(_text(pad_l - 6, pad_t - 8, "EL. (m)", 10, "end", fill="#666666"))
out.append(
_text(SECTION_W - pad_r, base_y + 14, "offset (m) · 좌 +", 10, "end", fill="#666666")
)
# 지반 · 지층
if ground:
bottom = sz(z_lo)
body = screen(ground)
out.append(
"<polygon points='"
+ " ".join(
f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], bottom), (body[0][0], bottom)]
)
+ "' fill='#F3E9D8' stroke='none'/>"
)
if rock:
rb = screen(rock)
out.append(
"<polygon points='"
+ " ".join(
f"{a:.1f},{b:.1f}" for a, b in [*rb, (rb[-1][0], sz(z_lo)), (rb[0][0], sz(z_lo))]
)
+ "' fill='#D9D2C5' stroke='none'/>"
)
out.append(_poly(rb, stroke="#795548", stroke_width="1.2", stroke_dasharray="6 3"))
out.append(_poly(screen(ground), stroke="#8D6E63", stroke_width="1.4"))
out.append(_poly(screen(surface), stroke=INK, stroke_width="2"))
if wet and surface: # 우기 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3) — ▽ 표시
for frac in (0.2, 0.8):
px, pz = surface[int(frac * (len(surface) - 1))]
a, b = sx(px), sz(pz) - 4
out.append(
f"<polygon points='{a - 5:.1f},{b - 8:.1f} {a + 5:.1f},{b - 8:.1f} "
f"{a:.1f},{b:.1f}' "
"fill='none' stroke='#1565C0' stroke-width='1.2'/>"
)
# 임계 활동면
for slip in slips:
color = CONDITION_COLORS.get(slip["condition"], NG)
out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2"))
# 치수 — 노폭 · 비탈 높이
if road:
r_off, l_off = road
zr, zl = _interp(surface, r_off), _interp(surface, l_off)
if zr is not None and zl is not None:
y = min(sz(zr), sz(zl)) - 18
a, b = sx(l_off), sx(r_off)
out.append(
f"<line x1='{a:.1f}' y1='{y:.1f}' x2='{b:.1f}' y2='{y:.1f}' stroke='{INK}' "
"stroke-width='0.8' marker-start='url(#t)' marker-end='url(#t)'/>"
)
out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 10, fill=INK))
for dim in heights:
if abs(dim["z1"] - dim["z0"]) < 0.05:
continue
a = sx(dim["x"])
y0, y1 = sz(dim["z0"]), sz(dim["z1"])
out.append(
f"<line x1='{a:.1f}' y1='{y0:.1f}' x2='{a:.1f}' y2='{y1:.1f}' stroke='{INK}' "
"stroke-width='0.8' marker-start='url(#t)' marker-end='url(#t)'/>"
)
left = a < SECTION_W / 2 # 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침)
label = f"H {abs(dim['z1'] - dim['z0']):.2f} m"
out.append(
_text(
a - 4 if left else a + 4,
(y0 + y1) / 2,
label,
10,
"end" if left else "start",
fill=INK,
)
)
out.insert(
2,
"<defs><marker id='t' viewBox='0 0 4 10' refX='2' refY='5' markerWidth='4' "
"markerHeight='10' orient='auto'><path d='M2 0 L2 10' stroke='#222' stroke-width='1'/>"
"</marker></defs>",
)
# 범례
legend = [("#8D6E63", "원지반", ""), (INK, "공사 뒤 지표", "")]
if rock:
legend.append(("#795548", "암 경계", "6 3"))
for slip in slips:
name = CONDITION_NAMES.get(slip["condition"], slip["condition"])
f = slip.get("F")
legend.append(
(
CONDITION_COLORS.get(slip["condition"], NG),
f"{name} 임계면 Fs {f:.2f}" if f else name,
"",
)
)
if wet:
legend.append(("#1565C0", "▽ 우기 지하수위 = 지표", ""))
lx, ly = pad_l, SECTION_H - 22
for color, label, dash in legend:
dash_attr = f" stroke-dasharray='{dash}'" if dash else ""
out.append(
f"<line x1='{lx}' y1='{ly}' x2='{lx + 18}' y2='{ly}' stroke='{color}' "
f"stroke-width='2'{dash_attr}/>"
)
out.append(_text(lx + 22, ly + 4, label, 10, "start", fill=INK))
lx += 34 + sum(10 if ord(ch) > 0x2000 else 6 for ch in label) # 한글 · 기호 폭
out.append("</svg>")
return "".join(out)
def band_svg(
*,
title: str,
line: list[tuple[float, float, float]],
stations: list[dict[str, Any]],
sections: list[dict[str, Any]],
) -> str:
"""평면 위험 띠 SVG.
line = [(x, y, 누가)] 노선 · stations = [{x, y, ok: bool | None, label}] 2D 판정 ·
sections = [{start_m, end_m, ok: bool}] 3D 구간(노선 위 굵은 띠).
"""
pad = 24
xy = [(p[0], p[1]) for p in line] or [(s["x"], s["y"]) for s in stations]
if not xy:
return _empty(BAND_W, BAND_H, title)
xs = [p[0] for p in xy]
ys = [p[1] for p in xy]
w, h = BAND_W - 2 * pad, BAND_H - 2 * pad - 40
scale = min(w / max(max(xs) - min(xs), 1e-6), h / max(max(ys) - min(ys), 1e-6))
ox = pad + (w - (max(xs) - min(xs)) * scale) / 2
oy = pad + 22 + (h - (max(ys) - min(ys)) * scale) / 2
def sp(x: float, y: float) -> tuple[float, float]:
return ox + (x - min(xs)) * scale, oy + (max(ys) - y) * scale # 북 = 위
out = [
f"<svg xmlns='http://www.w3.org/2000/svg' width='{BAND_W}' height='{BAND_H}' "
f"viewBox='0 0 {BAND_W} {BAND_H}'>",
f"<rect width='{BAND_W}' height='{BAND_H}' fill='#ffffff'/>",
_text(BAND_W / 2, 18, title, 13, font_weight="bold", fill=INK),
]
out.append(_poly([sp(x, y) for x, y in xy], stroke="#BDBDBD", stroke_width="3"))
for sec in sections:
part = [sp(x, y) for x, y, ch in line if sec["start_m"] - 1e-6 <= ch <= sec["end_m"] + 1e-6]
color = OK if sec["ok"] else NG
out.append(_poly(part, stroke=color, stroke_width="12", stroke_opacity="0.35"))
for st in stations:
a, b = sp(st["x"], st["y"])
color = MUTED if st["ok"] is None else OK if st["ok"] else NG
out.append(f"<circle cx='{a:.1f}' cy='{b:.1f}' r='3.2' fill='{color}'/>")
for st in (stations[0], stations[-1]) if stations else ():
a, b = sp(st["x"], st["y"])
out.append(_text(a, b - 7, st["label"] or "", 10, fill=INK))
lx, ly = pad, BAND_H - 14
for color, label, width in (
(OK, "2D O.K", 0),
(NG, "2D N.G", 0),
(OK, "3D 구간 O.K", 12),
(NG, "3D 구간 N.G", 12),
):
if width:
out.append(
f"<line x1='{lx}' y1='{ly - 4}' x2='{lx + 20}' y2='{ly - 4}' stroke='{color}' "
f"stroke-width='{width}' stroke-opacity='0.35'/>"
)
else:
out.append(f"<circle cx='{lx + 10}' cy='{ly - 4}' r='3.2' fill='{color}'/>")
out.append(_text(lx + 26, ly, label, 10, "start", fill=INK))
lx += 120
if not sections:
out.append(_text(BAND_W - pad, ly, "3D 해석 전", 10, "end", fill=MUTED))
out.append("</svg>")
return "".join(out)
def _empty(width: int, height: int, title: str) -> str:
return (
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width}' height='{height}'>"
f"<rect width='{width}' height='{height}' fill='#ffffff' stroke='#BDBDBD'/>"
+ _text(width / 2, height / 2, f"{title} — 자료 없음", 13, fill=MUTED)
+ "</svg>"
)