Files
Aislo/E06_StabRock/E06_StabRock_UI_Stereonet.ts

205 lines
7.4 KiB
TypeScript

/* =============================================================================
* E06_StabRock_UI_Stereonet.ts
* E06 가운데 스테레오넷(SVG · 하반구) — 서버가 내려 준 투영 좌표(반지름 1 · x = 동 · y = 북)를
* 그리기만 함(계산 = E06_StabRock_Stereonet.py). 보기 = 전체 · 평면 · 쐐기 · 전도.
* 평면 · 전도 = 극점 + 극점 마찰원(φ) · 쐐기 = 교선 + 교선 마찰원 · 위험 영역 = 모드 색 ·
* 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조.
* ========================================================================== */
import { createLegend } from "@ui/ui_template_flow_legend";
import { t, type LocaleKey } from "@ui/ui_template_locale";
const SVG_NS = "http://www.w3.org/2000/svg";
const SIZE = 440;
const R = 190;
const C = SIZE / 2;
export type NetMode = "all" | "planar" | "wedge" | "toppling";
type XY = [number, number];
export interface NetData {
projection: string;
slope: { pole: XY; great_circle: XY[] };
planes: { id: string; pole: XY; great_circle: XY[] }[];
intersections: { ids: [string, string]; point: XY }[];
friction_pole_r: number;
friction_line_r: number;
daylight_envelope: XY[];
zones: Record<"planar" | "wedge" | "toppling", XY[] | null>;
}
export interface NetMarks {
planar: string[];
toppling: string[];
wedge: string[]; // "J1|J2"
}
function svg<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;
}
const sx = (p: XY): number => C + R * p[0];
const sy = (p: XY): number => C - R * p[1];
const path = (pts: XY[], close = false): string =>
pts.map((p, i) => `${i ? "L" : "M"}${sx(p).toFixed(1)},${sy(p).toFixed(1)}`).join("") +
(close ? "Z" : "");
/** 절리 색 번호(0~5) — 표 · 그림 같은 차례 */
export const jointTone = (index: number): number => index % 6;
export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVGSVGElement {
const root = svg("svg", {
viewBox: `0 0 ${SIZE} ${SIZE}`,
class: "e06-net",
"data-role": "stereonet",
"data-projection": net.projection,
"data-mode": mode,
});
const clip = svg("clipPath", { id: "e06-net-clip" });
clip.append(svg("circle", { cx: C, cy: C, r: R }));
const defs = svg("defs");
defs.append(clip);
root.append(defs, svg("circle", { cx: C, cy: C, r: R, class: "e06-net__rim" }));
const body = svg("g", { "clip-path": "url(#e06-net-clip)" });
root.append(body);
const show = (m: Exclude<NetMode, "all">): boolean => mode === "all" || mode === m;
for (const m of ["planar", "wedge", "toppling"] as const) {
const zone = net.zones[m];
if (zone && show(m))
body.append(
svg("path", {
d: path(zone, true),
class: `e06-net__zone e06-net__zone--${m}`,
"data-zone": m,
}),
);
}
// 마찰원
if (show("planar") || show("toppling"))
body.append(
svg("circle", {
cx: C,
cy: C,
r: R * net.friction_pole_r,
class: "e06-net__friction",
"data-role": "friction-pole",
}),
);
if (show("wedge"))
body.append(
svg("circle", {
cx: C,
cy: C,
r: R * net.friction_line_r,
class: "e06-net__friction e06-net__friction--line",
"data-role": "friction-line",
}),
);
if (show("planar"))
body.append(
svg("path", {
d: path(net.daylight_envelope),
class: "e06-net__envelope",
"data-role": "daylight",
}),
);
// 대원
net.planes.forEach((p, i) =>
body.append(
svg("path", {
d: path(p.great_circle),
class: `e06-net__gc e06-tone-${jointTone(i)}`,
"data-id": p.id,
}),
),
);
body.append(
svg("path", { d: path(net.slope.great_circle), class: "e06-net__slope", "data-role": "slope" }),
);
// 극점(평면 · 전도 보기) · 교선(쐐기 보기)
if (mode !== "wedge")
net.planes.forEach((p, i) => {
const risky = marks.planar.includes(p.id) || marks.toppling.includes(p.id);
const g = svg("g", {
class: `e06-net__pole e06-tone-${jointTone(i)}${risky ? " is-risk" : ""}`,
"data-pole": p.id,
});
g.append(svg("circle", { cx: sx(p.pole), cy: sy(p.pole), r: 5 }));
g.append(svg("text", { x: sx(p.pole) + 7, y: sy(p.pole) - 6 }, p.id));
root.append(g);
});
if (mode === "all" || mode === "wedge")
for (const line of net.intersections) {
const key = line.ids.join("|");
const risky = marks.wedge.includes(key);
const [x, y] = [sx(line.point), sy(line.point)];
const g = svg("g", { class: `e06-net__line${risky ? " is-risk" : ""}`, "data-line": key });
g.append(svg("path", { d: `M${x},${y - 6}L${x + 5.5},${y + 4}L${x - 5.5},${y + 4}Z` }));
g.append(svg("text", { x: x + 7, y: y + 12 }, line.ids.join("·")));
root.append(g);
}
// 사면 극점 · 방위
root.append(
svg("rect", {
x: sx(net.slope.pole) - 4,
y: sy(net.slope.pole) - 4,
width: 8,
height: 8,
class: "e06-net__slope-pole",
}),
);
for (const [label, x, y] of [
["N", C, C - R - 8],
["E", C + R + 10, C + 4],
["S", C, C + R + 18],
["W", C - R - 10, C + 4],
] as const)
root.append(svg("text", { x, y, class: "e06-net__dir", "text-anchor": "middle" }, label));
root.append(svg("line", { x1: C - 6, y1: C, x2: C + 6, y2: C, class: "e06-net__tick" }));
root.append(svg("line", { x1: C, y1: C - 6, x2: C, y2: C + 6, class: "e06-net__tick" }));
return root;
}
/** 범례 — 범례 부품(선 · 점선 · 칸 · 점) · 삼각(교선)만 걸이 모양 */
const LEGEND_MARK: Record<string, { color: string; kind: "chip" | "line" | "dash" | "dot" }> = {
slope: { color: "var(--color-text)", kind: "line" },
friction: { color: "var(--color-text-secondary)", kind: "dash" },
planar: { color: "color-mix(in srgb, var(--e06-planar) 35%, transparent)", kind: "chip" },
daylight: { color: "var(--e06-planar)", kind: "dash" },
wedge: { color: "color-mix(in srgb, var(--e06-wedge) 35%, transparent)", kind: "chip" },
line: { color: "var(--e06-wedge)", kind: "chip" },
toppling: { color: "color-mix(in srgb, var(--e06-toppling) 35%, transparent)", kind: "chip" },
pole: { color: "var(--e06-pole)", kind: "dot" },
};
export function netLegend(mode: NetMode): HTMLElement {
const items: [string, LocaleKey][] = [
["slope", "E06_Lg_Slope"],
["friction", "E06_Lg_Friction"],
];
if (mode === "all" || mode === "planar")
items.push(["planar", "E06_Lg_Planar"], ["daylight", "E06_Lg_Daylight"]);
if (mode === "all" || mode === "wedge")
items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]);
if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]);
if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]);
const box = createLegend(
items.map(([cls, key]) => ({ label: t(key), ...LEGEND_MARK[cls] })),
{ vertical: true },
);
box.classList.add("e06-legend");
box.querySelectorAll(".ui-legend__mark").forEach((mark, i) => {
if (items[i][0] === "line") mark.classList.add("e06-legend__mark--line");
});
return box;
}