/* ============================================================================= * E03_StabSection_UI_Plan.ts * E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이 * 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점. * 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`. * 확대 · 이동 = 공용 `attachSvgZoomPan`(휠 · 가운데 끌기 · 두 번 누름 원배율 · 요구 390) · 측점 글 솎음 * = logic `labelStride` · `planLabelKeys` · 배율 · 폭은 바깥 `PlanView` 가 쥠(다시 지어도 살아남음). * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { stationLabel } from "../common_util/common_util_station"; import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { bandSegments, labelStride, planFit, planLabelKeys, verdictOf, type Condition, type Point, type StationRow, } from "./logic/E03_StabSection_Logic"; import { card } from "./E03_StabSection_UI_Parts"; import { svgEl } from "./E03_StabSection_UI_Section"; const H = 96; const PAD = 10; const LABEL_GAP = 64; // 측점 글 사이 최소 화면 간격(px) /** 띠 배율 · 그때 폭 — 폭이 바뀌면 원배율로 */ export interface PlanView { zoom?: ZoomPanState; width?: number; } export function buildPlan( rows: StationRow[], condition: Condition, active: string | null, interval: number, state: PlanView, onSelect: (key: string) => void, ): HTMLElement { const box = card("e03-plan"); const head = el("div", { className: "e03-card__head" }); head.append(el("h3", { className: "e03-card__title", text: t("E03_Plan") })); const legend = el("div", { className: "e03-legend", attrs: { "data-role": "plan-legend" } }); const counts = { ok: 0, ng: 0, none: 0 }; rows.forEach((r) => (counts[verdictOf(r, condition)] += 1)); for (const v of ["ng", "ok", "none"] as const) { if (!counts[v]) continue; const item = createBadge( `${t(`E03_Verdict_${v}` as LocaleKey)} ${counts[v]}`, v === "ok" ? "success" : v === "ng" ? "danger" : "neutral", { kind: "lamp" }, ); item.classList.add("e03-verdict", `e03-verdict--${v}`); legend.append(item); } head.append(legend); box.append(head); const host = el("div"); box.append(host); const ro = new ResizeObserver(() => draw()); const draw = (): void => { if (!host.isConnected) return void ro.disconnect(); // 다시 지은 띠 — 옛 것은 손 뗌 const W = Math.max(host.clientWidth, 300); const segs = bandSegments(rows, condition); const all: Point[] = segs.flatMap((s) => s.points); const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" }); host.replaceChildren(svg); if (!all.length) return; const fit = planFit(all, W, H, PAD); const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; const layer = svgEl("g", { "data-role": "plan-layer" }) as SVGGElement; for (const seg of segs) { layer.append( svgEl("polyline", { points: seg.points.map((p) => scr(p).join(",")).join(" "), class: `e03-plan__seg e03-plan__seg--${seg.verdict}`, "data-verdict": seg.verdict, }), ); } const centers = rows .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) .sort((a, b) => a.chainage_m - b.chainage_m); const at = (r: StationRow): Point => scr([r.center![0]!, r.center![1]!]); // 측점 간격 하나의 화면 길이(원배율) — 중심 이은 길이 ÷ 누가 길이 × 간격 const span = centers.length > 1 ? centers[centers.length - 1].chainage_m - centers[0].chainage_m : 0; let len = 0; for (let i = 1; i < centers.length; i += 1) { const [a, b] = [at(centers[i - 1]), at(centers[i])]; len += Math.hypot(b[0] - a[0], b[1] - a[1]); } const intervalPx = span > 0 ? (len / span) * interval : 0; const maxScale = Math.min(500, Math.max(8, Math.ceil(240 / Math.max(intervalPx, 1e-3)))); const picked = centers.find((r) => r.key === active); const pick = picked ? svgEl("circle", { cx: at(picked)[0], cy: at(picked)[1], r: 6, class: "e03-plan__pick", "data-key": picked.key, }) : null; const labels = svgEl("g", { class: "e03-plan__labels" }) as SVGGElement; layer.append(labels, ...(pick ? [pick] : [])); svg.append(layer); let stride = 0; const apply = (s: ZoomPanState): void => { state.zoom = s; labels.style.setProperty("--e03-label-k", String(1 / s.scale)); // 글 · 표시는 확대 안 함 pick?.setAttribute("r", String(6 / s.scale)); const next = intervalPx > 0 ? labelStride(intervalPx * s.scale, LABEL_GAP) : 100; if (next === stride) return; stride = next; const keep = new Set(planLabelKeys(centers, interval, stride, active)); labels.replaceChildren( ...centers .filter((r) => keep.has(r.key)) .map((r) => svgEl( "text", { x: at(r)[0], y: at(r)[1], dy: "-0.9em", class: "e03-label e03-plan__label", "data-key": r.key, }, stationLabel(r.chainage_m, interval, 1), ), ), ); }; const initial = state.width === W ? state.zoom : undefined; state.width = W; attachSvgZoomPan({ svg, layer, widthPx: W, heightPx: H, pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true, maxScale, initial, onChange: apply, }); apply(initial ?? { scale: 1, tx: 0, ty: 0 }); svg.addEventListener("click", (ev) => { const box2 = svg.getBoundingClientRect(); const px = ((ev.clientX - box2.left) / box2.width) * W; const py = ((ev.clientY - box2.top) / box2.height) * H; const s = state.zoom ?? { scale: 1, tx: 0, ty: 0 }; let best: StationRow | null = null; let dist = Infinity; for (const r of centers) { const [x, y] = at(r); const d = Math.hypot(s.scale * x + s.tx - px, s.scale * y + s.ty - py); if (d < dist) [best, dist] = [r, d]; } if (best && dist < 30) onSelect(best.key); }); }; ro.observe(host); return box; }