시험 고침: 534-3 양식 시험의 시트 목록 · 쪽 수 기대(목차 · 부록 더함) · E05 시험 시트 수 7 → 9 · PDF = report_pdf(목차 쪽 번호 = 실제 · 꼬리말 · 머리말) · 부록 절편 · 측점표 쪽 나눔 · 옛 작업본 갱신 시험 덧. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
387 lines
18 KiB
Python
387 lines
18 KiB
Python
"""E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦.
|
|
|
|
section_svg(…) → 대표 단면(712 × 640) — 원지반 · 공사 뒤 지표 · 지층(무늬) · 성토(점) ·
|
|
우기 지하수위 · 조건별 임계 활동면(선 모양) · 노폭 · 비탈 높이 치수 ·
|
|
표고 · 거리 눈금 · 축척 막대 · 안전율 상자 · 지층 범례
|
|
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, 640
|
|
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": "지진"}
|
|
SLIP_DASH = {"wet": "7 4", "seismic": "2 3"} # 건기 · 우기 임계면이 겹쳐도 둘 다 보이게
|
|
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 _legend_rows(items: list[tuple[str, str]], width: float, size: int) -> list[list[int]]:
|
|
"""범례 항목 [(종류, 글)] → 줄마다 항목 번호(폭을 넘으면 다음 줄)."""
|
|
rows: list[list[int]] = [[]]
|
|
used = 0.0
|
|
for i, (_, label) in enumerate(items):
|
|
w = 30 + _text_w(label, size)
|
|
if rows[-1] and used + w > width:
|
|
rows.append([])
|
|
used = 0.0
|
|
rows[-1].append(i)
|
|
used += w
|
|
return rows
|
|
|
|
|
|
def _text_w(text: str, size: int) -> float:
|
|
"""글 폭 근사(px) — 한글 · 기호 = 글자 크기 · 그 밖 = 0.6 배."""
|
|
return sum(size if ord(ch) > 0x2000 else 0.6 * size for ch in text)
|
|
|
|
|
|
PATTERNS = (
|
|
"<pattern id='rockHatch' width='7' height='7' patternUnits='userSpaceOnUse' "
|
|
"patternTransform='rotate(45)'><rect width='7' height='7' fill='#E2DACB'/>"
|
|
"<line x1='0' y1='0' x2='0' y2='7' stroke='#7A6A55' stroke-width='1'/></pattern>"
|
|
"<pattern id='fillDots' width='6' height='6' patternUnits='userSpaceOnUse'>"
|
|
"<rect width='6' height='6' fill='#FBF5E8'/><circle cx='3' cy='3' r='0.9' fill='#8D6E63'/>"
|
|
"</pattern>"
|
|
"<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>"
|
|
)
|
|
STRATA_FILL = {"soil": "#F3E9D8", "rock": "url(#rockHatch)", "fill": "url(#fillDots)"}
|
|
|
|
|
|
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,
|
|
strata: list[dict[str, str]] | None = None,
|
|
fs_box: list[dict[str, Any]] | None = None,
|
|
) -> str:
|
|
"""대표 단면 SVG(흑백 인쇄에도 읽힘 — 임계면 = 선 모양 · 지층 = 무늬).
|
|
|
|
ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] ·
|
|
road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1, label?}] 비탈 높이 치수 ·
|
|
strata = [{kind: soil | rock | fill, text}] 지층 범례 · fs_box = [{condition, F, required, ok}]
|
|
안전율 상자 · title = 빈 글이면 안 씀(보고서 쪽 제목이 있음).
|
|
"""
|
|
pad_l, pad_r, pad_t, pad_b = 58, 18, 30 if title else 22, 88
|
|
pts = [*ground, *surface, *(p for s in slips for p in s["points"])]
|
|
if not pts:
|
|
return _empty(SECTION_W, SECTION_H, title or "대표 단면")
|
|
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]
|
|
|
|
def area(line: list[tuple[float, float]], y: float) -> str:
|
|
"""선과 y 사이를 닫은 다각형 점 글."""
|
|
body = screen(line)
|
|
return " ".join(f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], y), (body[0][0], y)])
|
|
|
|
bottom, top = sz(z_lo), pad_t
|
|
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"<defs>{PATTERNS}",
|
|
]
|
|
if surface:
|
|
out.append(f"<clipPath id='below'><polygon points='{area(surface, bottom)}'/></clipPath>")
|
|
if ground:
|
|
out.append(f"<clipPath id='above'><polygon points='{area(ground, top)}'/></clipPath>")
|
|
out += ["</defs>", f"<rect width='{SECTION_W}' height='{SECTION_H}' fill='#ffffff'/>"]
|
|
if title:
|
|
out.append(_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='#E6E6E6' stroke-width='1'/>"
|
|
)
|
|
out.append(_text(pad_l - 6, y + 4, f"{z:g}", 11, "end", fill="#444444"))
|
|
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 + 15
|
|
while x <= x_hi:
|
|
out.append(_text(sx(x), base_y, f"{x:g}", 11, fill="#444444"))
|
|
x += step_x
|
|
out.append(_text(pad_l - 6, pad_t - 8, "EL.(m)", 10, "end", fill="#444444"))
|
|
out.append(
|
|
_text(SECTION_W - pad_r, base_y + 15, "offset (m) · 좌 +", 10, "end", fill="#444444")
|
|
)
|
|
# 축척 막대(가로 = 세로)
|
|
bar = _nice_step((x_hi - x_lo) / 5, 1)
|
|
bx, by, half = pad_l + 4, base_y + 12, bar * scale / 2
|
|
out.append(
|
|
f"<rect x='{bx:.1f}' y='{by - 5:.1f}' width='{half:.1f}' height='4' fill='{INK}'/>"
|
|
f"<rect x='{bx + half:.1f}' y='{by - 5:.1f}' width='{half:.1f}' height='4' "
|
|
f"fill='#ffffff' stroke='{INK}' stroke-width='0.8'/>"
|
|
)
|
|
out.append(_text(bx + 2 * half + 6, by, f"{bar:g} m · 가로 = 세로 축척", 10, "start", fill=INK))
|
|
# 지반 · 지층 — 공사 뒤 지표 아래 = 몸체 · 원지반 위 = 성토(점) · 암(빗금)
|
|
if surface:
|
|
out.append(f"<polygon points='{area(surface, bottom)}' fill='{STRATA_FILL['soil']}'/>")
|
|
if ground:
|
|
out.append(
|
|
f"<polygon points='{area(surface, bottom)}' fill='{STRATA_FILL['fill']}' "
|
|
"clip-path='url(#above)'/>"
|
|
)
|
|
if rock:
|
|
clip = " clip-path='url(#below)'" if surface else ""
|
|
out.append(f"<polygon points='{area(rock, bottom)}' fill='{STRATA_FILL['rock']}'{clip}/>")
|
|
out.append(_poly(screen(rock), stroke="#6D4C41", stroke_width="1", stroke_dasharray="6 3"))
|
|
out.append(_poly(screen(ground), stroke="#6D4C41", stroke_width="1.3", stroke_dasharray="3 2"))
|
|
out.append(_poly(screen(surface), stroke=INK, stroke_width="2.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 - 6:.1f},{b - 9:.1f} {a + 6:.1f},{b - 9:.1f} "
|
|
f"{a:.1f},{b:.1f}' fill='none' stroke='#1565C0' stroke-width='1.4'/>"
|
|
)
|
|
# 임계 활동면
|
|
for slip in slips:
|
|
color = CONDITION_COLORS.get(slip["condition"], NG)
|
|
dash = SLIP_DASH.get(slip["condition"])
|
|
extra = {"stroke_dasharray": dash} if dash else {}
|
|
out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.6", **extra))
|
|
# 치수 — 노폭 · 비탈 높이
|
|
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.9' marker-start='url(#t)' marker-end='url(#t)'/>"
|
|
)
|
|
out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 11, 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.9' marker-start='url(#t)' marker-end='url(#t)'/>"
|
|
)
|
|
label = dim.get("label") or f"H {abs(dim['z1'] - dim['z0']):.2f} m"
|
|
# 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침) · 그림 밖으로 나가면 반대쪽
|
|
left = a < SECTION_W / 2
|
|
if not left and a + 4 + _text_w(label, 11) > SECTION_W - 2:
|
|
left = True
|
|
if left and a - 4 - _text_w(label, 11) < 2:
|
|
left = False
|
|
anchor = "end" if left else "start"
|
|
out.append(_text(a - 4 if left else a + 4, (y0 + y1) / 2, label, 11, anchor, fill=INK))
|
|
if fs_box:
|
|
out.append(_fs_box(fs_box, surface, sx, sz, pad_l, pad_t, SECTION_W - pad_r))
|
|
# 범례 — 선 · 표시 · 지층 무늬(폭을 넘으면 다음 줄)
|
|
items: list[tuple[str, str]] = [("#6D4C41|3 2", "원지반"), (f"{INK}|", "공사 뒤 지표")]
|
|
if rock:
|
|
items.append(("#6D4C41|6 3", "지층 경계"))
|
|
for slip in slips:
|
|
name = CONDITION_NAMES.get(slip["condition"], slip["condition"])
|
|
f = slip.get("F")
|
|
color = CONDITION_COLORS.get(slip["condition"], NG)
|
|
label = f"{name} 임계면 Fs {f:.2f}" if f else name
|
|
items.append((f"{color}|{SLIP_DASH.get(slip['condition'], '')}", label))
|
|
if wet:
|
|
items.append(("▽", "우기 지하수위 = 지표"))
|
|
for layer in strata or []:
|
|
items.append((f"@{layer['kind']}", layer["text"]))
|
|
ly = SECTION_H - 46
|
|
for row in _legend_rows(items, SECTION_W - pad_l - pad_r, 11):
|
|
lx = float(pad_l)
|
|
for i in row:
|
|
kind, label = items[i]
|
|
if kind.startswith("@"):
|
|
out.append(
|
|
f"<rect x='{lx:.1f}' y='{ly - 9:.1f}' width='22' height='12' "
|
|
f"fill='{STRATA_FILL[kind[1:]]}' stroke='#6D4C41' stroke-width='0.8'/>"
|
|
)
|
|
elif kind == "▽":
|
|
out.append(
|
|
f"<polygon points='{lx + 5:.1f},{ly - 9:.1f} {lx + 17:.1f},{ly - 9:.1f} "
|
|
f"{lx + 11:.1f},{ly + 1:.1f}' fill='none' stroke='#1565C0' stroke-width='1.4'/>"
|
|
)
|
|
else:
|
|
color, dash = kind.split("|")
|
|
dash_attr = f" stroke-dasharray='{dash}'" if dash else ""
|
|
out.append(
|
|
f"<line x1='{lx:.1f}' y1='{ly - 3:.1f}' x2='{lx + 22:.1f}' y2='{ly - 3:.1f}' "
|
|
f"stroke='{color}' stroke-width='2.4'{dash_attr}/>"
|
|
)
|
|
out.append(_text(lx + 27, ly + 1, label, 11, "start", fill=INK))
|
|
lx += 30 + _text_w(label, 11)
|
|
ly += 17
|
|
out.append("</svg>")
|
|
return "".join(out)
|
|
|
|
|
|
def _fs_box(rows, surface, sx, sz, left: float, top: float, right: float) -> str:
|
|
"""안전율 상자 — 하늘 쪽(지표가 낮은 끝) 위 모서리 · 조건 · Fs · 기준 · 판정."""
|
|
width, line = 236, 19
|
|
height = 24 + line * len(rows)
|
|
# surface = offset 오름차순 · 화면 왼끝 = 큰 offset = 끝 점
|
|
low_left = bool(surface) and sz(surface[-1][1]) > sz(surface[0][1])
|
|
x = left + 6 if low_left else right - width - 6
|
|
y = top + 6
|
|
out = [
|
|
f"<rect x='{x:.1f}' y='{y:.1f}' width='{width}' height='{height}' fill='#ffffff' "
|
|
f"stroke='{INK}' stroke-width='1'/>",
|
|
f"<line x1='{x:.1f}' y1='{y + 22:.1f}' x2='{x + width:.1f}' y2='{y + 22:.1f}' "
|
|
f"stroke='{INK}' stroke-width='0.6'/>",
|
|
]
|
|
for cx, head, anchor in ((8, "조건", "start"), (78, "Fs", "end"), (132, "기준", "end")):
|
|
out.append(_text(x + cx, y + 16, head, 11, anchor, font_weight="bold", fill=INK))
|
|
out.append(_text(x + 196, y + 16, "판정", 11, font_weight="bold", fill=INK))
|
|
for k, r in enumerate(rows):
|
|
yy = y + 22 + line * (k + 1) - 5
|
|
name = CONDITION_NAMES.get(r["condition"], r["condition"])
|
|
f = "-" if r.get("F") is None else f"{r['F']:.2f}"
|
|
verdict = "-" if r.get("ok") is None else "O.K" if r["ok"] else "N.G"
|
|
color = INK if r.get("ok") is None else OK if r["ok"] else NG
|
|
out.append(_text(x + 8, yy, name, 11, "start", fill=INK))
|
|
out.append(_text(x + 78, yy, f, 11, "end", font_weight="bold", fill=INK))
|
|
out.append(_text(x + 132, yy, f"≥ {r['required']:.2f}", 11, "end", fill=INK))
|
|
out.append(_text(x + 196, yy, verdict, 11, font_weight="bold", fill=color))
|
|
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>"
|
|
)
|