/* ============================================================================= * E03_StabSection_UI_Section.ts * E03 가운데 횡단(PLAN 532-3) — 지층 색 · 벽(회색 빗금 · 532-5) · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자 * 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름). * 도형 좌표 = 결과 좌표 그대로 · 묶음 transform(`viewMatrix`) 하나 — 계산은 logic. * 줌 · 팬 = 공용 `attachSvgZoomPan`(휠 = 줌 · 가운데 끌기 = 팬 · 두 번 누름 = 원배율). * ========================================================================== */ import "./E03_StabSection_UI_Walls.css"; 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, focusHome, 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; const MAX_SCALE = 30; /** 벽(옹벽 · 석축) 물성 id — 서버 `WALL_MATERIAL` */ export const WALL_ID = "wall"; export function svgEl( tag: K, attrs: Record = {}, 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 ${m.id === WALL_ID ? "e03-wall-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 의 결과 좌표 길이 // 벽 빗금 — 결과 좌표 패턴(화면 6 px 간격) const hatch = svgEl("pattern", { id: "e03-wall-hatch", patternUnits: "userSpaceOnUse", width: 6 * unit, height: 6 * unit, patternTransform: "rotate(45)", }); hatch.append( svgEl("rect", { width: 6 * unit, height: 6 * unit, class: "e03-wall-hatch__bg" }), svgEl("line", { x1: 0, y1: 0, x2: 0, y2: 6 * unit, "stroke-width": 1.5 * unit, class: "e03-wall-hatch__line", }), ); const defs = svgEl("defs"); defs.append(hatch); world.append(defs); for (const region of view.regions) { const wall = region.material_id === WALL_ID; const ring = svgEl("polygon", { points: pointsText(region.polygon), class: wall ? "e03-wall" : `e03-region ${materialClass(view, region.material_id)}`, ...(wall ? { fill: "url(#e03-wall-hatch)", "data-role": "wall" } : {}), ...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, y: ay, dx: "0.6em", dy: "-0.7em", class: "e03-label e03-label--fs", "data-role": "fs-label", }, `Fs = ${fmt(critical.fs)}`, ), ); if (critical.center) { labels.append( svgEl( "text", { x: ax, y: ay, dx: "0.7em", dy: "0.5em", 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}|${analysis?.condition}|${analysis?.side}|${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: MAX_SCALE, home: focusHome(fit, view, analysis, width, height, MAX_SCALE), initial: zoom, onChange: (state) => { zoom = state; labels.style.setProperty("--e03-label-k", String(1 / state.scale)); // 글자는 확대 안 함 }, }); }; new ResizeObserver(() => last && paint()).observe(root); return { root, render(draw) { last = draw; paint(); }, }; }