/* ============================================================================= * E06_StabRock_UI_Stereonet.ts * E06 가운데 스테레오넷(SVG · 하반구) — 서버가 내려 준 투영 좌표(반지름 1 · x = 동 · y = 북)를 * 그리기만 함(계산 = E06_StabRock_Stereonet.py). 보기 = 전체 · 평면 · 쐐기 · 전도. * 평면 · 전도 = 극점 + 극점 마찰원(φ) · 쐐기 = 교선 + 교선 마찰원 · 위험 영역 = 모드 색 · * 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조. * ========================================================================== */ import { el } from "@ui/ui_template_elements"; 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( 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; } 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): 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; } export function netLegend(mode: NetMode): HTMLElement { const box = el("div", { className: "e06-legend" }); 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"]); for (const [cls, key] of items) { const row = el("span", { className: "e06-legend__row" }); row.append( el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }), el("span", { text: t(key) }), ); box.append(row); } return box; }