/* ============================================================================= * B06_Section_UI_Cross_Design_Pick.ts * 횡단 카드 **보라 요소 고름**(설계선 · 구조물 트림 선 · 137-2). * * 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을 * 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정 * 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)에 * 제목 · 축에 맞는 버튼 · 치수 한 줄을 적는다(안 고르면 암 경계선 칸 · 떠 있는 선 조정창 · 우측 위 라벨 걷음). * 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다. * 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다. * 고른 조각 쪽 사면을 노견째 ◀ ↺ ▶ · 방향키 · 0.1 m 로 옮김(139-6 쉬프트 · 151-3 · * 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 · * 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고). * 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬. * 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2). * 교차점 손잡이(370-2) — 쪽마다 설계선 ∩ 원지반 원(키 `toe:left|right` · 늘 보임 · ◀▶) · ◀▶ 가 노견 끝 = * 원지반 자리를 지나면 한 번 섬(`extendStop`) · 사면 키가 절 ↔ 성으로 바뀌면 같은 쪽 손잡이가 이어받음. * 암 경계선(370-3) — 점선 위 판정 선(키 `rock`) · ▲▼↺ · ◀▶ 막음 · 방향키 = 암만 · 세로 끌기(놓을 때 한 번). * ========================================================================== */ import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design"; import { clipSegment, type XY } from "../A00_Common/b_polygon_clip"; import { sideRole } from "@util/common_util_cross_design_geometry"; import { BERM_DEFAULT_DEPTH_M } from "@util/common_util_cross_berm"; import { arrowsBelongToProfile, bindArrowKeys } from "./B06_Section_UI_Cross_Panel_Base"; import { bindAxisDrag, bindCenterShift, centerHit, centerPreviewText, } from "./B06_Section_UI_Cross_Center"; import { buildLineCell, rockCellState, rockValueText, type LineCellState, type RockCellSpec, } from "./B06_Section_UI_Cross_Line_Cell"; import { extendStop, toePoints } from "./logic/B06_Section_UI_Cross_Fit"; const SVG_NS = "http://www.w3.org/2000/svg"; const DESIGN_CLASS = "b06-chart__design-cross"; const HIT_CLASS = "b06-chart__design-hit"; /** 교차점 손잡이 원(370-2) — 중심점 판정 원 클래스에 덧붙임(고르기 차례 같음). */ const TOE_CLASS = "b06-chart__toe-hit"; /** 암 경계선 점선 · 그 위 판정 선(370-3 · 판정 선은 보라 선 판정 클래스에 덧붙임). */ const ROCK_LINE_CLASS = "b06-chart__rock-boundary"; export const ROCK_HIT_CLASS = "b06-chart__rock-hit"; /** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */ export const DESIGN_LINE_ATTR = "data-design-line"; /** 보라 선을 덮는 벽 · 본체 폴리곤. */ export const OCCLUDER_CLASSES = [ "b06-chart__culvert-revet", "b06-chart__culvert-basin", "b06-chart__ford-slab", "b06-chart__box-barrel", ]; /** 같은 직선으로 보는 꺾임 허용(m) — 설계선 표고는 0.1 mm 반올림이라 넉넉하다. */ const STRAIGHT_TOL_M = 0.005; /** 가리고 남은 조각이 이보다 짧으면 버린다(m) — 누를 수도 없다. */ const MIN_PIECE_M = 0.02; export interface CrossPoint { offset: number; elevation: number; } /** 그려진 보라 선 한 줄(m 좌표). `structure` = 구조물 트림 선(설계선 오버레이 밖). */ export interface DesignRun { points: CrossPoint[]; structure: boolean; } /** 직선 조각 — 두 끝(m) · 구조물 트림 선인가. */ export interface DesignPiece { from: CrossPoint; to: CrossPoint; structure: boolean; } export type CrossZone = "road" | "cut" | "fill"; export type CrossSide = "left" | "right"; type ZoneDesign = Pick< CrossDesign, "road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode" > & Partial>; /** * offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + * 구조물 트림 선. 사면 역할은 단면유형(`sideRole`)으로 · 쪽 = +offset 좌. */ export function crossZoneAt( design: ZoneDesign, offsetM: number, structure = false, ): { zone: CrossZone; side: CrossSide } { const side: CrossSide = offsetM >= 0 ? "left" : "right"; const edges = design.road_edges; const hi = edges ? Math.max(edges.left.offset_m, edges.right.offset_m) : 0; const lo = edges ? Math.min(edges.left.offset_m, edges.right.offset_m) : 0; if (structure) return { zone: "fill", side }; if (offsetM >= lo - 1e-6 && offsetM <= hi + 1e-6) return { zone: "road", side }; const ditch = design.ditch; const ditchWidth = design.ditch_enabled === false || !ditch || ditch.type === "none" ? 0 : ditch.type === "standard" ? ditch.top_width_m : ditch.width_m; if (design.ditch_side === "left" && offsetM > hi && offsetM <= hi + ditchWidth + 1e-6) return { zone: "cut", side }; if (design.ditch_side === "right" && offsetM < lo && offsetM >= lo - ditchWidth - 1e-6) return { zone: "cut", side }; let roles: [string, string] = ["fill", "fill"]; try { roles = sideRole(design.section_mode); } catch { // 모르는 단면유형 = 성토로 봄 } return { zone: (side === "left" ? roles[0] : roles[1]) === "cut" ? "cut" : "fill", side }; } /** 조각 구역 = 가운데 자리의 구역. */ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure); /** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) · * 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */ export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis => isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x"; /** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1) · * center = 중심점(▲▼ 종단 계획고 · ◀▶ 노선 좌우 · 356-3). */ export type MoveAxis = "x" | "y" | "depth" | "center" | "rock"; /** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */ export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number => design[`berm_depth_${side}_m`] ?? design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M; /** 소단 수평 단인가 — 성토측 · 폭 = 소단 폭 · 안쪽 끝 표고 = 노면 끝 − 깊이(194-1). */ export function isBermPiece(design: ZoneDesign, piece: DesignPiece): boolean { const berm = design.berm; if (!berm || piece.structure) return false; const { zone, side } = pieceZone(design, piece); const edge = design.road_edges?.[side]; if (zone !== "fill" || !edge) return false; const rise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m; const low = Math.min(piece.from.elevation, piece.to.elevation); const high = Math.max(piece.from.elevation, piece.to.elevation); return ( Math.abs(Math.abs(piece.to.offset - piece.from.offset) - berm.width_m) < 0.05 && Math.abs(low - (edge.elevation_m - bermDepthOf(design, side))) < 0.02 && high - low <= rise + 0.01 ); } /** 조각 키 = 갈래 + 쪽 + 차례(가운데에서 바깥으로) — 다시 그려도 안 바뀜(194-2). */ export function pieceKeys(design: ZoneDesign, pieces: DesignPiece[]): string[] { const mid = (p: DesignPiece) => Math.abs(p.from.offset + p.to.offset) / 2; const kinds = pieces.map((p) => { const { zone, side } = pieceZone(design, p); return `${isBermPiece(design, p) ? "berm" : zone}${p.structure ? "+s" : ""}:${side}`; }); return pieces.map((p, i) => { const rank = pieces.filter( (q, j) => kinds[j] === kinds[i] && (mid(q) < mid(p) || (mid(q) === mid(p) && j < i)), ).length; return `${kinds[i]}:${rank}`; }); } const gap = (a: CrossPoint, b: CrossPoint): number => Math.hypot(a.offset - b.offset, a.elevation - b.elevation); /** 점열이 한 직선 위에서 한 방향으로만 가는가(되접힘 없음). */ function isStraight(points: CrossPoint[]): boolean { const a = points[0]; const b = points[points.length - 1]; const length = gap(a, b); if (length < 1e-9) return false; const ux = (b.offset - a.offset) / length; const uy = (b.elevation - a.elevation) / length; let along = -STRAIGHT_TOL_M; for (const p of points) { const dx = p.offset - a.offset; const dy = p.elevation - a.elevation; if (Math.abs(ux * dy - uy * dx) > STRAIGHT_TOL_M) return false; const t = ux * dx + uy * dy; if (t < along - STRAIGHT_TOL_M) return false; along = Math.max(along, t); } return true; } /** 그려진 선들 → 같은 방향 토막을 합친 직선 조각(설계선 · 구조물 선은 따로 합침). */ export function mergeStraightPieces(runs: DesignRun[]): DesignPiece[] { const chains: Array<{ points: CrossPoint[]; structure: boolean }> = []; for (const run of runs) { for (let i = 1; i < run.points.length; i += 1) { const start = run.points[i - 1]; const end = run.points[i]; if (gap(start, end) < 1e-6) continue; const last = chains[chains.length - 1]; if ( last && last.structure === run.structure && gap(last.points[last.points.length - 1], start) < 1e-3 && isStraight([...last.points, end]) ) last.points.push(end); else chains.push({ points: [start, end], structure: run.structure }); } } // 차례가 떨어진 토막도 끝이 닿고 한 직선이면 합친다(방향이 거꾸로 그려진 선 포함). const joined = (p: CrossPoint[], q: CrossPoint[]): CrossPoint[] | null => { for (const a of [p, [...p].reverse()]) for (const b of [q, [...q].reverse()]) if (gap(a[a.length - 1], b[0]) < 1e-3 && isStraight([...a, ...b.slice(1)])) return [...a, ...b.slice(1)]; return null; }; let merged = true; while (merged) { merged = false; for (let i = 0; i < chains.length && !merged; i += 1) for (let j = i + 1; j < chains.length && !merged; j += 1) { if (chains[i].structure !== chains[j].structure) continue; const points = joined(chains[i].points, chains[j].points); if (!points) continue; chains[i] = { points, structure: chains[i].structure }; chains.splice(j, 1); merged = true; } } return chains.map((chain) => ({ from: chain.points[0], to: chain.points[chain.points.length - 1], structure: chain.structure, })); } /** 벽 · 본체 폴리곤(m)에 가린 부분을 뺀 **보이는 조각**만. */ export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): DesignPiece[] { const polygons = occluders .filter((polygon) => polygon.length >= 3) .map((polygon) => polygon.map((p) => [p.offset, p.elevation] as XY)); const out: DesignPiece[] = []; for (const piece of pieces) { let runs: XY[][] = [ [ [piece.from.offset, piece.from.elevation], [piece.to.offset, piece.to.elevation], ], ]; for (const polygon of polygons) runs = runs.flatMap(([a, b]) => clipSegment(a, b, polygon, "outside")); for (const [a, b] of runs) { const from = { offset: a[0], elevation: a[1] }; const to = { offset: b[0], elevation: b[1] }; if (gap(from, to) >= MIN_PIECE_M) out.push({ from, to, structure: piece.structure }); } } return out; } /** 치수 — 길이 · 수평 · 높이(m) · 경사비율(사면 1:n · 노면 %). */ export function pieceMeasure( piece: DesignPiece, zone: CrossZone, ): { lengthM: number; horizontalM: number; heightM: number; grade: string } { const horizontalM = Math.abs(piece.to.offset - piece.from.offset); const heightM = Math.abs(piece.to.elevation - piece.from.elevation); const grade = zone === "road" ? horizontalM < 1e-6 ? "—" : `${((heightM / horizontalM) * 100).toFixed(1)}%` : heightM < 1e-4 ? "0.0%" : `1:${(horizontalM / heightM).toFixed(2)}`; return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade }; } /** 두 줄(151-1 · 195-1) — 1줄 = 길이 · 경사비율 · 2줄 = 수평 · 높이(줄바꿈 글자로 가름). */ export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string { const { zone } = pieceZone(design, piece); const m = pieceMeasure(piece, zone); return ( `길이 ${m.lengthM.toFixed(2)}m · 경사비율 ${m.grade}\n` + `수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m` ); } /* ── 페이지 한 칸 고름 — 측점 + 조각 키(카드를 다시 그려도 되살림 · 137-3) ─────────── * 번호가 아니라 키(`pieceKeys`)로 기억 — 옮겨 다시 그리면 조각 차례가 바뀌어도 같은 조각(194-2). * 키를 안 적은 측점(카드 밖 시험)은 번호 그대로. */ let picked: { stationId: string; key: string } | null = null; const keysByStation = new Map(); export function pickedLineAt(stationId: string): number | null { if (picked?.stationId !== stationId) return null; const keys = keysByStation.get(stationId); let index = keys ? keys.indexOf(picked.key) : Number(picked.key); // 사면이 절 ↔ 성으로 넘어가 키가 바뀌거나 길이 0 으로 사라지면 같은 쪽 교차점 손잡이(370-2) const slope = /^(?:cut|fill)(?:\+s)?:(left|right):/.exec(picked.key); if (index < 0 && keys && slope) index = keys.indexOf(`toe:${slope[1]}`); return index >= 0 ? index : null; } export function setPickedLine(stationId: string, index: number | null): void { if (index !== null) { const keys = keysByStation.get(stationId); picked = { stationId, key: keys ? (keys[index] ?? "") : String(index) }; } else if (picked?.stationId === stationId) picked = null; } /* ── 카드 배선 ─────────────────────────────────────────────────────────── */ const pointsOf = (element: Element): XY[] => { if (element.tagName.toLowerCase() === "line") { const n = (name: string) => Number(element.getAttribute(name)); return [ [n("x1"), n("y1")], [n("x2"), n("y2")], ]; } return (element.getAttribute("points") ?? "") .trim() .split(/\s+/) .map((pair) => pair.split(",").map(Number) as XY) .filter((p) => p.length === 2 && p.every(Number.isFinite)); }; /** 설계선 쉬프트(139-6) — 쪽마다 사면 이동(m · ≥ 0 · 노견째 · 151-2). */ export interface ShoulderShift { valueOf: (side: CrossSide) => number; set: (side: CrossSide, valueM: number) => void; /** 한 걸음 now → next 사이 노견 끝 = 원지반 자리가 있으면 그 값(370-2 · 없으면 next). */ stopAt?: (side: CrossSide, now: number, next: number) => number; } /** 이동값 글 — 0.1 m 눈금이면 한 자리 · 멈춤 자리(370-2)면 두 자리. */ const extendText = (valueM: number): string => valueM.toFixed(Math.abs(valueM * 10 - Math.round(valueM * 10)) < 1e-6 ? 1 : 2); /** 쉬프트 걸음(m). */ export const SHIFT_STEP_M = 0.1; /** 화면 ◀(−1) · ▶(+1) 한 걸음 → 그 쪽 사면 이동 새 값(0.1 m 눈금 · 0 아래 금지). * `kx` = 화면 x / offset 기울기(+offset = 좌 · 화면 왼쪽이면 kx < 0). */ export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, kx: number): number { const offsetDelta = screenDir * Math.sign(kx) * SHIFT_STEP_M; const outward = side === "left" ? offsetDelta : -offsetDelta; return Math.max(0, Math.round((now + outward) * 10) / 10); } /** 소단 높이(194-1) — 쪽마다 깊이(노면 끝 아래 m · ≥ 0) · 고름 켬/끔을 좌측 소단 상자에 알림. */ export interface BermDepthControl { set: (side: CrossSide, depthM: number) => void; focus: (on: boolean) => void; } /** 소단 고름 → 좌측 패널 소단 상자 켬(194-2) — 페이지가 단다(측점 · null = 끔). */ let bermHost: ((chainageM: number | null) => void) | null = null; let bermHostAt: number | null = null; export function setBermPickHost(host: ((chainageM: number | null) => void) | null): void { bermHost = host; bermHostAt = null; } function tellBermHost(chainageM: number | null): void { if (chainageM === bermHostAt) return; // 다시 그린 카드의 되살림은 한 번만 bermHostAt = chainageM; bermHost?.(chainageM); } /** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */ export function designLineMoves( section: Pick, onDesignChange?: (chainageM: number, change: CrossDesignChange) => void, /** 암 경계선 칸 제원(`rockCellSpec`) — 암 지반일 때만 씀(370-3). */ rockSpec?: RockCellSpec, ): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number; toes?: ReturnType; rock?: RockCellSpec; } { const design = section.design; // 암 경계선 = 암 지반만(370-3 · 띠 칸과 같은 창구) const rock = design?.geometry_preset === "rock" ? rockSpec : undefined; if (!onDesignChange || !design) return { stationId: section.station_id, rock }; const send = (change: Partial) => onDesignChange(section.chainage_m, { ...(design as unknown as CrossDesignChange), ...change, }); return { stationId: section.station_id, rock, center: section.chainage_m, // 중심점 좌우 = 이 측점 노선(356-3) shift: { valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0, set: (side, valueM) => send({ shoulder_extend: { side, valueM } }), stopAt: (side, now, next) => extendStop(section.samples, design.road_edges, side, now, next), }, toes: toePoints(section), // 교차점 손잡이(370-2) berm: { set: (side, valueM) => send({ berm_depth: { side, valueM } }), // 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움 focus: (on) => { if (on) tellBermHost(section.chainage_m); else if (bermHostAt === section.chainage_m) tellBermHost(null); }, }, }; } export interface DesignLinePick { pieces: DesignPiece[]; /** 아래 띠 왼쪽 공용 칸(370-3) — 카드가 띠에 붙임 · 고른 선 제목 · 버튼 · 값 한 줄(안 고르면 암). */ cell: HTMLElement; setActive: (index: number | null) => void; } /** * 그려진 보라 선에 고름을 단다 — 벽 · 관 등을 다 그린 **뒤**(가림 · 판정면 차례) 부른다. * `x` · `toDisplayY` = 카드 좌표 함수(1차식 — 역산해 m 로 되돌림). */ export function attachDesignLinePick(input: { layer: SVGElement; design: ZoneDesign; x: (offset: number) => number; toDisplayY: (elevation: number) => number; onPick: (index: number) => void; /** 있으면 고른 조각 쪽 사면을 이동 패널로 옮김(139-6 · 151-3). */ shift?: ShoulderShift; /** 있으면 고른 소단을 ▲▼ 로 옮김(194-1). */ berm?: BermDepthControl; /** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */ stationId?: string; /** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */ center?: number; /** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */ toes?: ReturnType; /** 있으면 암 경계선 고름(키 `rock` · ▲▼↺ · 세로 끌기) · 안 고른 때 공용 칸 = 암(370-3). */ rock?: RockCellSpec; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; const x0 = x(0); const kx = x(1) - x0; const y0 = toDisplayY(0); const ky = toDisplayY(1) - y0; const toM = ([px, py]: XY): CrossPoint => ({ offset: (px - x0) / kx, elevation: (py - y0) / ky }); const kids = [...layer.children]; const runs: DesignRun[] = kids .filter((el) => el.classList.contains(DESIGN_CLASS)) .map((el) => ({ points: pointsOf(el).map(toM), structure: !el.hasAttribute(DESIGN_LINE_ATTR), })); const occluders = kids .filter( (el) => el.tagName.toLowerCase() === "polygon" && OCCLUDER_CLASSES.some((name) => el.classList.contains(name)), ) .map((el) => pointsOf(el).map(toM)); const pieces = visiblePieces(mergeStraightPieces(runs), occluders); const edges = design.road_edges; const centerZ = input.center === undefined || !edges || edges.left.offset_m === edges.right.offset_m ? null : edges.right.elevation_m - ((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) / (edges.left.offset_m - edges.right.offset_m); const toeSides = (["left", "right"] as const).filter((at) => input.toes?.[at]); const rockLines = input.rock ? kids.filter((el) => el.classList.contains(ROCK_LINE_CLASS)) : []; const keys = [ ...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"]), ...toeSides.map((at) => `toe:${at}`), ...(rockLines.length ? ["rock"] : []), ]; if (input.stationId) keysByStation.set(input.stationId, keys); // 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1). const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); const hits: SVGElement[] = pieces.map((piece) => { const hit = document.createElementNS(SVG_NS, "polyline"); hit.setAttribute( "points", [piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), ); hit.setAttribute("class", HIT_CLASS); return hit; }); // 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`) const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ)); if (center) hits.push(center); // 교차점 손잡이(370-2) — 쪽마다 원 · 늘 보임 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례) for (const at of toeSides) { const toe = input.toes?.[at] as { offset_m: number; elevation_m: number }; const hit = centerHit(x(toe.offset_m), toDisplayY(toe.elevation_m)); hit.classList.add(TOE_CLASS); hit.setAttribute("r", "5"); hits.push(hit); } // 암 경계선(370-3) — 점선(`appendRockBoundaryOverlay`) 위 판정 선 하나(끊긴 토막도 한 path) · 키 rock let rockHit: SVGElement | null = null; if (rockLines.length) { rockHit = document.createElementNS(SVG_NS, "path"); const path = (el: Element) => `M${pointsOf(el) .map(([px, py]) => `${px},${py}`) .join(" L")}`; rockHit.setAttribute("d", rockLines.map(path).join(" ")); rockHit.setAttribute("class", `${HIT_CLASS} ${ROCK_HIT_CLASS}`); hits.push(rockHit); } hits.forEach((hit, index) => { hit.addEventListener("click", (event) => { event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음 input.onPick(index); }); layer.insertBefore(hit, firstStructureHit ?? null); }); // 암 판정 선은 보라 선 판정 아래 — 사면 발치처럼 겹친 자리는 설계선이 먼저 골라짐 if (rockHit && hits[0] !== rockHit) layer.insertBefore(rockHit, hits[0]); const cell = buildLineCell(); let side: CrossSide | null = null; let axis: MoveAxis = "x"; const { shift, berm, rock } = input; const centerShift = center && input.center !== undefined ? bindCenterShift({ hit: center, layer: layer as SVGGraphicsElement, chainageM: input.center, kx, active: () => axis === "center", preview: (deltaM) => cell.setValue(centerPreviewText(deltaM)), }) : null; // 암 경계선 세로 끌기(370-3) — 고른 뒤에만 · 걸음 0.1 m · 끄는 동안 점선만 옮겨 보이고 놓을 때 한 번 if (rockHit && rock) bindAxisDrag({ hit: rockHit, layer: layer as SVGGraphicsElement, axis: "y", pxPerM: ky, active: () => axis === "rock", preview: (deltaM) => { for (const el of [rockHit, ...rockLines]) (el as SVGElement).style.setProperty("translate", deltaM ? `0px ${deltaM * ky}px` : ""); const next = rockValueText(Math.min(rock.offsetM + deltaM, 0)); cell.setValue(deltaM ? `${next} — 놓으면 반영` : rockValueText(rock.offsetM)); }, send: (deltaM) => rock.adjust(deltaM), }); const step = (dir: -1 | 1) => () => { if (axis === "center") return centerShift?.step(dir); if (!side || !shift) return; // 노견 끝 = 원지반(절도 성도 아닌 점)을 지나면 그 자리에 한 번 섬(370-2) const now = shift.valueOf(side); const next = shiftedExtend(now, side, dir, kx); shift.set(side, shift.stopAt?.(side, now, next) ?? next); }; // 툴팁 = 고른 것의 실제 동작(364-7 · 사면 = 노견째 이동 · 중심점 = 그 측점 노선) const stepTitle = (screen: "왼쪽" | "오른쪽"): string => axis === "center" ? `이 측점 노선을 화면 ${screen}으로 0.1 m` : `고른 사면을 화면 ${screen}으로 0.1 m(노견째 · 노폭 안쪽은 못 감)`; const sideways = (reset: boolean): LineCellState["buttons"] => ({ left: { title: stepTitle("왼쪽"), act: step(-1) }, ...(reset && { reset: { title: "이 쪽 이동 0", act: () => side && shift?.set(side, 0) } }), right: { title: stepTitle("오른쪽"), act: step(1) }, }); // 소단 높이(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤) const toeZ = (at: CrossSide): number => Math.min( ...pieces .filter((p) => !p.structure && pieceZone(design, p).side === at) .flatMap((p) => [p.from.elevation, p.to.elevation]), ); const deepen = (dir: -1 | 1) => () => { if (!side || !berm) return; const now = bermDepthOf(design, side); const next = Math.max(0, Math.round((now + dir * SHIFT_STEP_M) * 10) / 10); const edgeZ = design.road_edges?.[side]?.elevation_m; if (dir > 0 && edgeZ !== undefined && edgeZ - next < toeZ(side) + SHIFT_STEP_M) return; if (next !== now) berm.set(side, next); }; const lift: LineCellState["buttons"] = { up: { title: "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", act: deepen(-1) }, reset: { title: "이 쪽 소단 = 구간 기본 깊이", act: () => side && berm?.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M), }, down: { title: "고른 소단을 0.1 m 아래로", act: deepen(1) }, }; const arrows = bindArrowKeys(cell.root, () => [cell.buttons]); // 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감 · // 암 ◀▶ 막음(370-3). 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김. const blockOffAxis = (event: KeyboardEvent): void => { if (!cell.root.isConnected) { document.removeEventListener("keydown", blockOffAxis); return; } if (arrowsBelongToProfile()) return; const focus = event.target as HTMLElement | null; if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return; const vertical = event.key === "ArrowUp" || event.key === "ArrowDown"; const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight"; if (axis === "x" ? vertical : horizontal) event.preventDefault(); }; return { pieces, cell: cell.root, setActive: (index) => { const key = index === null ? undefined : keys[index]; const onCenter = !!centerShift && key === "center"; const onRock = !!rockHit && key === "rock"; const toeSide = key?.startsWith("toe:") ? (key.slice(4) as CrossSide) : null; const piece = index === null || index >= pieces.length ? undefined : pieces[index]; const chosen = !!piece || onCenter || onRock || !!toeSide; hits.forEach((hit, i) => hit.classList.toggle("is-active", chosen && i === index)); side = piece ? pieceZone(design, piece).side : toeSide; axis = onRock ? "rock" : onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x"; const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0; const sideName = side === "left" ? "(좌)" : "(우)"; const moved = extend > 0 ? ` · 이동 +${extendText(extend)}m` : ""; const toe = toeSide ? input.toes?.[toeSide] : null; const dims = piece ? pieceLabel(design, piece).replace("\n", " · ") : ""; // 아래 띠 공용 칸(370-3) — 안 고르면 암(암 지반만) · 고르면 그 선 제목 · 버튼 · 값 한 줄 const state: LineCellState | null = onRock || (!chosen && rock) ? rockCellState(rock as RockCellSpec) : onCenter ? { title: "중심점", value: centerPreviewText(0), buttons: sideways(false) } : toe ? { title: `교차점 이동 ${sideName}`, value: `노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`, buttons: shift ? sideways(true) : {}, } : !piece ? null : axis === "y" ? { title: "노면 높이", value: `${dims} · ▲▼ = 종단 계획고`, buttons: {} } : axis === "depth" && side ? { title: `소단 높이 ${sideName}`, value: `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m · ${dims}`, buttons: berm ? lift : {}, } : { title: `사면 이동 ${sideName}`, value: dims + moved, buttons: shift ? sideways(true) : {}, }; cell.show(state); const open = onCenter || onRock || (!!toeSide && !!shift) || !!(piece && (axis === "depth" ? berm : shift)); berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2) arrows.detach(); document.removeEventListener("keydown", blockOffAxis); if (open) { arrows.attach(); document.addEventListener("keydown", blockOffAxis); } }, }; }