- 길: 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>
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* 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();
|
||
},
|
||
};
|
||
}
|