- 빌더: 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>
300 lines
12 KiB
Python
300 lines
12 KiB
Python
"""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>"
|
||
)
|