Files
Aislo/E03_StabSection/E03_StabSection_UI_Section.ts
T
eomsangdonandClaude Opus 5.5 8348e6b922 fix(532-3): E03 일꾼 stderr 파일로(파이프 막힘으로 해석 멈춤) · 처음 보기 = 그 비탈 확대 · 화면 높이 고정 · 자유물체도 반력 화살 · 목록 줄임
- 일꾼 stderr 를 파이프로 두면 자식 경고가 쌓여 8/57 에서 멈춤 → 임시 파일로
- 횡단 처음 보기 = 탐색 범위 · 임계면 · 격자 중심을 판 가운데(두 번 누름 = 이 보기 · 줌 아웃 = 모형 전체)
- 본문 높이 = 화면 높이(커지며 넘치던 것) · 고른 줄 표시 밝음/어두움 같이 · 3D 보기 창 맨 위
- 자유물체도 N · U · S = 바닥으로 들어오는 화살 · 글 = 화살 바깥 끝
- 목록 측점 0+000 꼴 · 좌우 깎/쌓 · 해석 없음 = -

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:01:50 +09:00

338 lines
12 KiB
TypeScript
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.
/* =============================================================================
* E03_StabSection_UI_Section.ts
* E03 가운데 횡단(PLAN 532-3) — 지층 색 · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자
* 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름).
* 도형 좌표 = 결과 좌표 그대로 · 묶음 transform(`viewMatrix`) 하나 — 계산은 logic.
* 줌 · 팬 = 공용 `attachSvgZoomPan`(휠 = 줌 · 가운데 끌기 = 팬 · 두 번 누름 = 원배율).
* ========================================================================== */
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;
export function svgEl<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number> = {},
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 ${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 의 결과 좌표 길이
for (const region of view.regions) {
const ring = svgEl("polygon", {
points: pointsText(region.polygon),
class: `e03-region ${materialClass(view, region.material_id)}`,
...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 + 8, y: ay - 10, class: "e03-label e03-label--fs", "data-role": "fs-label" },
`Fs = ${fmt(critical.fs)}`,
),
);
if (critical.center) {
labels.append(
svgEl(
"text",
{ x: ax + 8, y: ay + 6, 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),
});
};
new ResizeObserver(() => last && paint()).observe(root);
return {
root,
render(draw) {
last = draw;
paint();
},
};
}