Files
Aislo/E03_StabSection/E03_StabSection_UI_Section.ts
T
eomsangdonandClaude Opus 5.5 ecd027f028 feat(532-3): E03 2D 측점 해석 길 · 화면 — 파생 저장본(측점 지문) · 진행률 · 임계면 · 등안전율 · 절편 자유물체도 · 평면 띠 · 3D 색칠
- 길: POST analyze2d(뒤에서 · 일꾼 프로세스 병렬 · 바뀐 측점만) · GET progress · result2d · section · corridor3d · PUT representative
- 입력 = E01 복사본(측점 · 횡단 · DTM 넓힘) + E02 지반 조건 · 엔진 = sub4 analyze_station · 벽 = 다음 일감(532-4)
- 화면: 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면) · 횡단(지층 · 지하수선 · 임계면 · 후보 · 등안전율 지도 · 중심 격자 · 절편) · 결과 · 절편표 · 자유물체도 · 절편력 그래프 · 평면 띠 · 3D 보기
- 계산 = logic/E03_StabSection_Logic.ts · 시험 test_532_3_e03_section.py

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

335 lines
11 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,
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;
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}|${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: 20,
initial: zoom,
onChange: (state) => (zoom = state),
});
};
new ResizeObserver(() => last && paint()).observe(root);
return {
root,
render(draw) {
last = draw;
paint();
},
};
}