diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 4cdc67dd1..777744dba 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -158,17 +158,23 @@ export function appendCardHeader( card.append(header); } -/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선·암 절토 경사 제어. */ +/** 하단 정보행 — 왼쪽 = 선 공용 칸(370-3 · 안 고르면 암 경계선) · 오른쪽 = (암 지반일 때) 암 절토 경사. */ export function appendCardFooter( card: HTMLElement, section: CrossSection, rockBoundary?: RockBoundaryControl, cutSlope?: CutSlopeControl, + /** 설계선 고름 공용 칸(370-3 · `_Cross_Line_Cell`) — 있으면 왼쪽 칸(안 고르면 암 · 고르면 그 선). */ + lineCell?: HTMLElement, ): void { // 중심고는 그래프 Y축 라벨 상자로 옮김(318-2 · `_Cross_Axes`) — 하단 띠는 암 제어만 · 비어도 띠 높이 그대로 const footer = document.createElement("footer"); - // 암 경계선 제어는 하단 정보 행 왼쪽(318-2) — 암 지반만. - if (rockBoundary && section.design?.geometry_preset === "rock") { + if (lineCell) { + lineCell.classList.add("b06-cross-card__rockb"); + footer.append(lineCell); + } + // 암 경계선 제어는 하단 정보 행 왼쪽(318-2) — 암 지반만 · 설계선 고름이 없는 카드. + else if (rockBoundary && section.design?.geometry_preset === "rock") { const rockControl = buildRockBoundaryControl(section, rockBoundary); rockControl.classList.add("b06-cross-card__rockb"); footer.append(rockControl); diff --git a/B06_Section/B06_Section_UI_Cross_Center.ts b/B06_Section/B06_Section_UI_Cross_Center.ts index e97f7a88c..1eddda6e2 100644 --- a/B06_Section/B06_Section_UI_Cross_Center.ts +++ b/B06_Section/B06_Section_UI_Cross_Center.ts @@ -5,6 +5,7 @@ * · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만) * · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로 * · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄 + * · 한 축 끌기(`bindAxisDrag`)는 암 경계선 세로 끌기(370-3)도 씀 * · 좌우 = 노선 편집기가 등록한 host(`setRouteShiftHost` · B05 `_RouteEdit_Shift`) — B06 을 바로 열어 * 편집기가 없으면 종단 보기를 숨긴 채 세움(`setRouteShiftOpener` · 페이지가 닮 · 알림도 페이지 것) * ========================================================================== */ @@ -107,26 +108,14 @@ export function bindCenterShift(input: { if (!deltaM) return preview(0); void shiftRouteAt(chainageM, deltaM).then((moved) => moved || preview(0)); }; - hit.addEventListener("pointerdown", (event) => { - const ctm = layer.getScreenCTM(); - if (event.button !== 0 || !active() || !ctm) return; - event.preventDefault(); - event.stopPropagation(); // 카드 팬으로 안 번짐 - const startX = event.clientX; - let deltaM = 0; - const move = (next: PointerEvent): void => { - deltaM = snapStep((next.clientX - startX) / (ctm.a * kx)); - preview(deltaM); - }; - const up = (): void => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - window.removeEventListener("pointercancel", up); - send(deltaM); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - window.addEventListener("pointercancel", up); + bindAxisDrag({ + hit, + layer, + axis: "x", + pxPerM: kx, + active, + preview, + send, }); let pending = 0; let timer = 0; @@ -144,6 +133,50 @@ export function bindCenterShift(input: { }; } +/** + * 고른 판정을 한 축으로 끌기(356-3 중심점 가로 · 370-3 암 경계선 세로) — 고른 뒤에만(안 고른 것을 끌면 카드 + * 팬 그대로) · 걸음 0.1 m · 끄는 동안 `preview` · 놓을 때 움직였으면 한 번 `send`(안 움직이면 `preview(0)`) · + * 놓은 자리 click 은 한 번 막음(같은 것 다시 누름 = 풂으로 안 번짐). `pxPerM` = 그 축 화면 px / m(layer 좌표). + */ +export function bindAxisDrag(input: { + hit: SVGElement; + layer: SVGGraphicsElement; + axis: "x" | "y"; + pxPerM: number; + active: () => boolean; + preview: (deltaM: number) => void; + send: (deltaM: number) => void; +}): void { + const { hit, layer, axis, pxPerM, active } = input; + const at = (event: PointerEvent): number => (axis === "x" ? event.clientX : event.clientY); + hit.addEventListener("pointerdown", (event) => { + const ctm = layer.getScreenCTM(); + if (event.button !== 0 || !active() || !ctm) return; + event.preventDefault(); + event.stopPropagation(); // 카드 팬으로 안 번짐 + const start = at(event); + const scale = (axis === "x" ? ctm.a : ctm.d) * pxPerM; + let deltaM = 0; + const move = (next: PointerEvent): void => { + deltaM = snapStep((at(next) - start) / scale); + input.preview(deltaM); + }; + const up = (): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", up); + if (!deltaM) return input.preview(0); + const swallow = (click: Event): void => click.stopPropagation(); + window.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); + input.send(deltaM); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", up); + }); +} + /** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */ export function centerPreviewText(deltaM: number): string { if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우"; diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 2fc78b270..5b43ca31a 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -28,6 +28,7 @@ export { } from "./B06_Section_UI_Cross_Design_Surface"; export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line"; import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail"; +import { buildLineCell, rockCellState, type RockCellSpec } from "./B06_Section_UI_Cross_Line_Cell"; /** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */ interface ReflowCard { @@ -205,112 +206,29 @@ function toggle( return { wrap, button }; } -/** - * 길게 누르는 동안 같은 동작을 반복한다(B05 종단 선 제어 패턴 재사용). 0.5초 유지하면 - * 0.1초 간격(0.1m씩) 반복이 시작된다. 버튼이 재빌드로 사라져도 반복이 끊기지 않도록 - * 타이머·종료 감지를 버튼이 아니라 window 이벤트로 들고 있는다. - */ -function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } { - let delayTimer = 0; - let repeatTimer = 0; - function stop(): void { - window.clearTimeout(delayTimer); - window.clearInterval(repeatTimer); - delayTimer = 0; - repeatTimer = 0; - window.removeEventListener("pointerup", stop); - window.removeEventListener("pointercancel", stop); - window.removeEventListener("blur", stop); - } - function start(action: () => void): void { - stop(); - delayTimer = window.setTimeout(() => { - repeatTimer = window.setInterval(action, 100); - }, 500); - window.addEventListener("pointerup", stop); - window.addEventListener("pointercancel", stop); - window.addEventListener("blur", stop); - } - return { start, stop }; -} - -/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). E-7: X축 행 배치용 export. */ +/** 암 경계선 상/하/리셋 컨트롤 — 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)의 「안 고름」 꼴. 설계선 고름이 + * 없는 카드(설계 없음)에서 띠 왼쪽 칸으로 씀. */ export function buildRockBoundaryControl( section: CrossSection, control: RockBoundaryControl, ): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b06-design__seg b06-design__rockb"; - const legendEl = document.createElement("span"); - legendEl.className = "b06-design__seg-legend"; - legendEl.textContent = L("B06_Design_RockBoundary_Legend"); - wrap.append(legendEl); + const cell = buildLineCell(); + cell.show(rockCellState(rockCellSpec(section, control))); + return cell.root; +} - const group = document.createElement("div"); - group.className = "b06-design__seg-buttons"; - const readout = document.createElement("span"); - readout.className = "b06-design__rockb-readout"; - const currentOffset = control.offsetFor(section); - readout.textContent = `${currentOffset >= 0 ? "+" : ""}${currentOffset.toFixed(1)}m`; - - const repeater = createHoldRepeater(); - const makeButton = ( - label: string, - title: string, - className: string, - onClick: () => void, - hold = false, - ): HTMLButtonElement => { - const button = document.createElement("button"); - button.type = "button"; - button.className = `b06-design__rockb-btn ${className}`; - button.textContent = label; - button.title = hold ? `${title}\n(길게 누르면 0.5초 뒤부터 연속 조정)` : title; - if (hold) { - // 포인터로 누르면 즉시 1회 반응하고 반복을 예약한다. 이어지는 click은 중복이라 삼킨다. - let swallowClick = false; - button.addEventListener("pointerdown", (event) => { - event.stopPropagation(); - swallowClick = true; - onClick(); - repeater.start(onClick); - }); - button.addEventListener("click", (event) => { - event.stopPropagation(); - if (swallowClick) { - swallowClick = false; - return; - } - onClick(); - }); - } else { - button.addEventListener("click", onClick); - } - return button; +/** 암 경계선 칸 제원 — 공용 칸(안 고름 · 암 고름)이 같이 씀. */ +export function rockCellSpec(section: CrossSection, control: RockBoundaryControl): RockCellSpec { + return { + legend: L("B06_Design_RockBoundary_Legend"), + offsetM: control.offsetFor(section), + stepM: control.stepM, + upTitle: L("B06_Design_RockBoundary_Up"), + downTitle: L("B06_Design_RockBoundary_Down"), + resetTitle: L("B06_Design_RockBoundary_Reset"), + adjust: (deltaM) => control.adjust(section.chainage_m, deltaM), + reset: () => control.reset(section.chainage_m), }; - - group.append( - makeButton( - "▲", - `${L("B06_Design_RockBoundary_Up")} (+${control.stepM}m)`, - "is-up", - () => control.adjust(section.chainage_m, control.stepM), - true, - ), - makeButton( - "▼", - `${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`, - "is-down", - () => control.adjust(section.chainage_m, -control.stepM), - true, - ), - makeButton("↺", L("B06_Design_RockBoundary_Reset"), "is-reset", () => - control.reset(section.chainage_m), - ), - readout, - ); - wrap.append(group); - return wrap; } /** diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index b486ff4e9..a6a4c892d 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -4,16 +4,18 @@ * * 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을 * 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정 - * 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 두 줄로 적는다. + * 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)에 + * 제목 · 축에 맞는 버튼 · 치수 한 줄을 적는다(안 고르면 암 경계선 칸 · 떠 있는 선 조정창 · 우측 위 라벨 걷음). * 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다. * 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다. - * 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 · + * 고른 조각 쪽 사면을 노견째 ◀ ↺ ▶ · 방향키 · 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"; @@ -21,14 +23,20 @@ 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 { - arrowsBelongToProfile, - bindArrowKeys, - buildPanelShell, - dpadButton, - makeRow, -} from "./B06_Section_UI_Cross_Panel_Base"; -import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center"; + 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"; @@ -36,6 +44,9 @@ 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"; /** 보라 선을 덮는 벽 · 본체 폴리곤. */ @@ -119,7 +130,7 @@ 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"; +export type MoveAxis = "x" | "y" | "depth" | "center" | "rock"; /** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */ export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number => @@ -360,15 +371,20 @@ function tellBermHost(chainageM: number | null): void { 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; - if (!onDesignChange || !design) return { stationId: section.station_id }; + // 암 경계선 = 암 지반만(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), @@ -376,6 +392,7 @@ export function designLineMoves( }); return { stationId: section.station_id, + rock, center: section.chainage_m, // 중심점 좌우 = 이 측점 노선(356-3) shift: { valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0, @@ -396,10 +413,8 @@ export function designLineMoves( export interface DesignLinePick { pieces: DesignPiece[]; - /** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */ - label: HTMLElement; - /** 사면 이동 패널(151-3) — 카드가 그림 칸에 붙임 · `shift` 없거나 고름 없으면 숨김. */ - panel: HTMLElement; + /** 아래 띠 왼쪽 공용 칸(370-3) — 카드가 띠에 붙임 · 고른 선 제목 · 버튼 · 값 한 줄(안 고르면 암). */ + cell: HTMLElement; setActive: (index: number | null) => void; } @@ -423,6 +438,8 @@ export function attachDesignLinePick(input: { 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); @@ -453,10 +470,12 @@ export function attachDesignLinePick(input: { ((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); @@ -484,6 +503,18 @@ export function attachDesignLinePick(input: { 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(); // 카드 고름 · 면적 띠로 번지지 않음 @@ -492,23 +523,10 @@ export function attachDesignLinePick(input: { layer.insertBefore(hit, firstStructureHit ?? null); }); - const label = document.createElement("span"); - label.className = "b06-cross-card__line-dims"; - label.hidden = true; + const cell = buildLineCell(); let side: CrossSide | null = null; let axis: MoveAxis = "x"; - let active: number | null = null; - const { shift, berm } = input; - - // 사면 이동 패널(151-3) — B06 공용 조정창 꼴(머리 · 「이동」 줄 십자 · 방향키 ←→). - // ✕ = 같은 조각 다시 고름(카드가 풂) · ↺ = 그 쪽 0. - const shell = buildPanelShell(() => { - if (active !== null) input.onPick(active); - }); - const readout = document.createElement("div"); - readout.className = "b06-structure-panel__value"; - const move = makeRow("이동"); - move.controls.classList.add("b06-structure-panel__buttons"); + const { shift, berm, rock } = input; const centerShift = center && input.center !== undefined ? bindCenterShift({ @@ -517,9 +535,25 @@ export function attachDesignLinePick(input: { chainageM: input.center, kx, active: () => axis === "center", - preview: (deltaM) => (readout.textContent = centerPreviewText(deltaM)), + 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; @@ -533,16 +567,12 @@ export function attachDesignLinePick(input: { axis === "center" ? `이 측점 노선을 화면 ${screen}으로 0.1 m` : `고른 사면을 화면 ${screen}으로 0.1 m(노견째 · 노폭 안쪽은 못 감)`; - move.controls.append( - dpadButton("◀", "left", stepTitle("왼쪽"), step(-1)), - dpadButton("↺", "reset", "이 쪽 이동 0", () => { - if (side && shift) shift.set(side, 0); - }), - dpadButton("▶", "right", stepTitle("오른쪽"), step(1)), - ); - // 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤) - const lift = makeRow("높이"); - lift.controls.classList.add("b06-structure-panel__buttons"); + 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 @@ -557,23 +587,23 @@ export function attachDesignLinePick(input: { if (dir > 0 && edgeZ !== undefined && edgeZ - next < toeZ(side) + SHIFT_STEP_M) return; if (next !== now) berm.set(side, next); }; - lift.controls.append( - dpadButton("▲", "up", "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", deepen(-1)), - dpadButton("↺", "reset", "이 쪽 소단 = 구간 기본 깊이", () => { - if (side && berm) berm.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M); - }), - dpadButton("▼", "down", "고른 소단을 0.1 m 아래로", deepen(1)), - ); - shell.root.append(readout, move.row, lift.row); - const arrows = bindArrowKeys(shell.root, () => [move.controls, lift.controls]); - // 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감. - // 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김. + 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 (!shell.root.isConnected) { + if (!cell.root.isConnected) { document.removeEventListener("keydown", blockOffAxis); return; } - if (shell.root.classList.contains("is-hidden") || arrowsBelongToProfile()) 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"; @@ -583,61 +613,55 @@ export function attachDesignLinePick(input: { return { pieces, - label, - panel: shell.root, + 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 || !!toeSide; + const chosen = !!piece || onCenter || onRock || !!toeSide; hits.forEach((hit, i) => hit.classList.toggle("is-active", chosen && i === index)); - label.hidden = !chosen; - active = chosen ? index : null; side = piece ? pieceZone(design, piece).side : toeSide; - axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x"; + 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; - label.textContent = onCenter - ? "중심점" - : toe - ? `교차점 ${sideName} · 노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}` - : piece - ? pieceLabel(design, piece) + moved - : ""; + 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 || (!!toeSide && !!shift) || !!(piece && (axis === "depth" ? berm : shift)); - shell.root.classList.toggle("is-hidden", !open); - // 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단) - // 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3) - const sideways = axis === "x" || axis === "center"; - move.row.classList.toggle("is-hidden", !sideways); - move.controls.classList.toggle("is-hidden", !sideways); - move.controls.children[1]?.classList.toggle("is-hidden", axis === "center"); - const [toLeft, , toRight] = move.controls.children as unknown as HTMLElement[]; - [toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")]; - lift.row.classList.toggle("is-hidden", axis !== "depth"); - lift.controls.classList.toggle("is-hidden", axis !== "depth"); - shell.title.textContent = - axis === "center" - ? "중심점" - : toeSide - ? `교차점 이동 ${sideName}` - : axis === "y" - ? "노면 높이" - : axis === "depth" - ? `소단 높이 ${sideName}` - : `사면 이동 ${sideName}`; - readout.textContent = - axis === "center" - ? centerPreviewText(0) - : axis === "y" - ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" - : axis === "depth" && side - ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` - : `이동 ${extendText(extend)}m`; + onCenter || + onRock || + (!!toeSide && !!shift) || + !!(piece && (axis === "depth" ? berm : shift)); berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2) arrows.detach(); document.removeEventListener("keydown", blockOffAxis); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts index f76ffb8cf..0ef41df87 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts @@ -53,15 +53,17 @@ function targetsOf(svg: Element, design: CrossDesign | undefined, key: string): const ppm = Number(svg.getAttribute("data-px-per-m")); if (!design || !center || !(ppm > 0)) return []; const x0 = Number(center.getAttribute("x1")); - return [...svg.querySelectorAll(".b06-chart__design-hit")].filter((hit) => { - const xs = (hit.getAttribute("points") ?? "") - .trim() - .split(/\s+/) - .map((pair) => Number(pair.split(",")[0])); - const mid = (xs[0] + xs[xs.length - 1]) / 2; - // x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm - return lineZone(design, (x0 - mid) / ppm) === target.zone; - }); + return [...svg.querySelectorAll(".b06-chart__design-hit:not(.b06-chart__rock-hit)")].filter( + (hit) => { + const xs = (hit.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => Number(pair.split(",")[0])); + const mid = (xs[0] + xs[xs.length - 1]) / 2; + // x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm + return lineZone(design, (x0 - mid) / ppm) === target.zone; + }, + ); } /** 그림 복제 안 강조 — 묻어 온 `.is-active` 를 지우고 `key` 대상만 켬(null = 모두 끔). */ diff --git a/B06_Section/B06_Section_UI_Cross_Line_Cell.ts b/B06_Section/B06_Section_UI_Cross_Line_Cell.ts new file mode 100644 index 000000000..3e7f6d6ee --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Line_Cell.ts @@ -0,0 +1,172 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Line_Cell.ts + * 횡단 카드 아래 띠 왼쪽 **공용 칸**(370-3) — 고른 선의 제목 · 축에 맞는 버튼 · 값 한 줄. + * + * · 안 고르면 암 경계선 ▲▼↺ · 값(암 지반만 · 토사는 빈 칸 · 띠 높이 그대로) + * · 고르면 그 선(노면 · 사면 · 소단 · 중심점 · 교차점 · 암) — `_Cross_Design_Pick` 이 상태를 넣음 + * · 버튼 다섯 칸(◀ ▲ ↺ ▼ ▶)은 한 벌 · 상태마다 보임 · 툴팁 · 동작만 바꿈 · 방향키는 보이는 슬롯만 찾음 + * · ▲▼ 길게 누름 = 0.5 초 뒤 0.1 초마다 반복(암 · B05 종단 선 제어 꼴) + * ========================================================================== */ + +export type CellSlot = "left" | "up" | "reset" | "down" | "right"; +const SLOTS: Array<[CellSlot, string]> = [ + ["left", "◀"], + ["up", "▲"], + ["reset", "↺"], + ["down", "▼"], + ["right", "▶"], +]; + +export interface CellButton { + title: string; + act: () => void; + /** 길게 누르면 반복. */ + hold?: boolean; +} +export interface LineCellState { + title: string; + buttons: Partial>; + value: string; +} +export interface LineCell { + root: HTMLElement; + /** 버튼 줄 — 방향키 묶음(`bindArrowKeys`)이 슬롯 버튼을 찾는 그릇. */ + buttons: HTMLElement; + /** null = 빈 칸(자리는 남음). */ + show: (state: LineCellState | null) => void; + /** 값 글만 바꿈(끄는 동안 미리 보기). */ + setValue: (text: string) => void; +} + +/** + * 길게 누르는 동안 같은 동작을 반복한다(B05 종단 선 제어 패턴 재사용). 0.5초 유지하면 + * 0.1초 간격 반복이 시작된다. 버튼이 재빌드로 사라져도 반복이 끊기지 않도록 + * 타이머·종료 감지를 버튼이 아니라 window 이벤트로 들고 있는다. + */ +function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } { + let delayTimer = 0; + let repeatTimer = 0; + function stop(): void { + window.clearTimeout(delayTimer); + window.clearInterval(repeatTimer); + delayTimer = 0; + repeatTimer = 0; + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + window.removeEventListener("blur", stop); + } + function start(action: () => void): void { + stop(); + delayTimer = window.setTimeout(() => { + repeatTimer = window.setInterval(action, 100); + }, 500); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + window.addEventListener("blur", stop); + } + return { start, stop }; +} + +/** 버튼 칸 표시(보임과 무관 · 시험이 찾음) — 슬롯 클래스(`b06-structure-panel__btn--*`)는 보일 때만. */ +export const CELL_BTN = "b06-cross-card__cell-btn--"; +const HOLD_HINT = "\n(길게 누르면 0.5초 뒤부터 연속 조정)"; + +export function buildLineCell(): LineCell { + const root = document.createElement("div"); + root.className = "b06-design__seg b06-design__rockb b06-cross-card__linecell"; + root.addEventListener("click", (event) => event.stopPropagation()); // 카드 고름 · 팬으로 안 번짐 + const legend = document.createElement("span"); + legend.className = "b06-design__seg-legend"; + const group = document.createElement("div"); + group.className = "b06-design__seg-buttons"; + const readout = document.createElement("span"); + readout.className = "b06-design__rockb-readout"; + const repeater = createHoldRepeater(); + let current: LineCellState | null = null; + const run = (slot: CellSlot) => current?.buttons[slot]?.act(); + const buttons = SLOTS.map(([slot, text]) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `b06-design__rockb-btn ${CELL_BTN}${slot}`; + if (slot === "reset") button.classList.add("is-reset"); + button.textContent = text; + // 길게 누름 — 누르면 즉시 1회 · 반복 예약 · 이어지는 click 은 삼킴 + let swallowClick = false; + button.addEventListener("pointerdown", (event) => { + if (!current?.buttons[slot]?.hold) return; + event.stopPropagation(); + swallowClick = true; + run(slot); + repeater.start(() => run(slot)); + }); + button.addEventListener("click", (event) => { + event.stopPropagation(); + if (swallowClick) { + swallowClick = false; + return; + } + run(slot); + }); + group.append(button); + return [slot, button] as const; + }); + group.append(readout); + root.append(legend, group); + const cell: LineCell = { + root, + buttons: group, + show: (state) => { + current = state; + root.classList.toggle("is-empty", !state); + legend.textContent = state?.title ?? ""; + readout.textContent = state?.value ?? ""; + readout.title = state?.value ?? ""; + for (const [slot, button] of buttons) { + const spec = state?.buttons[slot]; + button.hidden = !spec; + // 방향키 묶음은 슬롯 클래스로 찾음 — 숨은 버튼은 슬롯을 떼어 키를 안 삼킴(노면 ▲▼ = 종단도) + button.classList.toggle(`b06-structure-panel__btn--${slot}`, !!spec); + button.title = spec ? spec.title + (spec.hold ? HOLD_HINT : "") : ""; + } + }, + setValue: (text) => { + readout.textContent = text; + readout.title = text; + }, + }; + cell.show(null); + return cell; +} + +/** 암 경계선 칸 상태(안 고름 · 암 고름 둘 다) — ▲ = 위로 · ▼ = 아래로 · ↺ = 기본. */ +export interface RockCellSpec { + legend: string; + offsetM: number; + stepM: number; + upTitle: string; + downTitle: string; + resetTitle: string; + adjust: (deltaM: number) => void; + reset: () => void; +} +export const rockValueText = (offsetM: number): string => + `${offsetM >= 0 ? "+" : ""}${offsetM.toFixed(1)}m`; +export function rockCellState(rock: RockCellSpec): LineCellState { + return { + title: rock.legend, + value: rockValueText(rock.offsetM), + buttons: { + up: { + title: `${rock.upTitle} (+${rock.stepM}m)`, + act: () => rock.adjust(rock.stepM), + hold: true, + }, + down: { + title: `${rock.downTitle} (-${rock.stepM}m)`, + act: () => rock.adjust(-rock.stepM), + hold: true, + }, + reset: { title: rock.resetTitle, act: rock.reset }, + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 9cea10b17..c4acae6b0 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -32,6 +32,7 @@ import { appendCrossDesignOverlay, appendPavementOverlay, appendRockBoundaryOverlay, + rockCellSpec, type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome"; @@ -202,6 +203,7 @@ export function createCrossSectionCard( const culvertState = emptyCardCulvertState(); /** 보라 선 고름(137-2) — 고름은 페이지 한 칸(`_Cross_Design_Pick`) · 강조는 그린 뒤 붙음. */ let setLineActive: (index: number | null) => void = () => undefined; + let linePick: DesignLinePick | null = null; const dropLine = (): void => { setPickedLine(section.station_id, null); setLineActive(null); @@ -413,7 +415,6 @@ export function createCrossSectionCard( const toDisplayY = (elevation: number): number => y(elevationMid + (elevation - elevationMid) * exaggeration); appendGroundProfile(plotLayer, drawSamples, x, toDisplayY); - let linePick: DesignLinePick | null = null; if (section.design) { // 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다. @@ -607,8 +608,12 @@ export function createCrossSectionCard( x, toDisplayY, onPick: pickLine, - // 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) — 카드 버튼과 같은 창구 - ...designLineMoves(section, onDesignChange), + // 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) · 암(370-3) — 카드 버튼과 같은 창구 + ...designLineMoves( + section, + onDesignChange, + rockBoundary && rockCellSpec(section, rockBoundary), + ), }); setLineActive = linePick.setActive; setLineActive(pickedLineAt(section.station_id)); @@ -679,9 +684,7 @@ export function createCrossSectionCard( fit: () => stationWidth.request(section.chainage_m, crossHalfWidth ?? maxOffset), }; const zoomBar = buildZoomControls(zoomPan, widthActions); - if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2) chartWrap.append(svg, readout.root, zoomBar, panel.root); - if (linePick) chartWrap.append(linePick.panel); // 사면 이동 패널(151-3) for (const panel of bodies.panels) chartWrap.append(panel.root); if (activeArea) { setBandActive(activeArea); @@ -690,6 +693,6 @@ export function createCrossSectionCard( card.append(chartWrap); } - appendCardFooter(card, section, rockBoundary, cutSlope); + appendCardFooter(card, section, rockBoundary, cutSlope, linePick?.cell); // 왼쪽 = 선 공용 칸(370-3) return card; } diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index a141ae442..7f059e483 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -634,6 +634,38 @@ html.is-bm-route .b06-section__notice-bar { padding: 1px 4px; } +/* 선 공용 칸(370-3) — 고른 선 제목 · 버튼 · 값 한 줄(넘치면 말줄임) · 빈 칸도 자리(띠 높이) 그대로. */ +.b06-cross-card__linecell { + flex: 0 1 auto; + min-width: 0; +} + +.b06-cross-card__linecell.is-empty { + visibility: hidden; +} + +.b06-cross-card__linecell .b06-design__seg-buttons { + min-width: 0; +} + +.b06-cross-card__linecell .b06-design__rockb-readout { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.b06-cross-card__linecell .b06-design__rockb-btn { + flex: 0 0 auto; + border-left: none; +} + +.b06-cross-card__linecell + .b06-design__rockb-btn:not([hidden]) + ~ .b06-design__rockb-btn:not([hidden]) { + border-left: 1px solid var(--color-border); +} + /* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */ .b06-cross-card__cutslope { margin-left: auto; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 5c3207fd4..0fe12440b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -205,6 +205,12 @@ cursor: pointer; } +/* 암 경계선 고름(370-3) — 점선 위 투명 판정 선 · 고르면 경고색 띠 · 세로 끌기. */ +.b06-chart__design-hit.b06-chart__rock-hit.is-active { + stroke: color-mix(in srgb, var(--color-warning) 40%, transparent); + cursor: ns-resize; +} + /* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ .b06-chart__carriageway-tick { stroke: var(--color-royal-amethyst); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index 778418876..ac2a795b3 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -223,7 +223,7 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } -/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 경사비율 / 수평 · 높이). */ +/* 고른 선 치수(137-2) — 상세 모달 줌 막대 왼쪽(179-1) · 두 줄(151-1) · 카드는 아래 띠 공용 칸(370-3). */ .b06-cross-card__line-dims { align-self: center; padding: 1px var(--spacing-4); diff --git a/resources/tester/helper_137_design_pick.cjs b/resources/tester/helper_137_design_pick.cjs index b30e6e80e..c45d0e9f6 100644 --- a/resources/tester/helper_137_design_pick.cjs +++ b/resources/tester/helper_137_design_pick.cjs @@ -288,7 +288,12 @@ const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-c const stopped = hits[0].click(); handle.setActive(1); const activeOn = hits.map((h) => h.classList.contains("is-active")); -const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent }; +// 아래 띠 공용 칸(370-3) — 빈 칸 = is-empty · 값 = 버튼 줄 끝 글 +const cellNow = () => ({ + hidden: handle.cell.classList.contains("is-empty"), + text: handle.cell.children[1].children.at(-1).textContent, +}); +const labelOn = cellNow(); handle.setActive(null); out.real = { designLines, @@ -303,7 +308,7 @@ out.real = { activeCount: activeOn.filter(Boolean).length, activeIndex: activeOn.indexOf(true), labelOn, - labelOff: { hidden: handle.label.hidden, text: handle.label.textContent }, + labelOff: cellNow(), zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(), }; diff --git a/resources/tester/helper_139_6_line_shift.cjs b/resources/tester/helper_139_6_line_shift.cjs index 4d146fc17..a8284f2e8 100644 --- a/resources/tester/helper_139_6_line_shift.cjs +++ b/resources/tester/helper_139_6_line_shift.cjs @@ -147,21 +147,21 @@ const handle = pick.attachDesignLinePick({ set: (side, v) => (values[side] = v), }, }); -const panel = handle.panel; -const slot = (name) => panel.querySelector(`.b06-structure-panel__btn--${name}`); -const close = panel.querySelector(".b06-structure-panel__close"); +// 아래 띠 공용 칸(370-3) — 제목 · 버튼 · 값 한 줄 +const cell = handle.cell; +const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`); +const cellTitle = () => cell.children[0].textContent; +const cellValue = () => cell.children[1].children.at(-1).textContent; const pieceAt = (offset) => handle.pieces.findIndex( (p) => Math.min(p.from.offset, p.to.offset) <= offset && offset <= Math.max(p.from.offset, p.to.offset), ); -const hiddenAtStart = panel.classList.contains("is-hidden"); +const hiddenAtStart = cell.classList.contains("is-empty"); const log = []; handle.setActive(pieceAt(4)); // 좌 사면 -const titleLeft = panel.descendants.find((n) => - n._cls.has("b06-structure-panel__title"), -).textContent; +const titleLeft = cellTitle(); slot("left").click(); // 화면 왼쪽 = 좌측 바깥 → +0.1 key("ArrowLeft"); // 방향키 = 십자 log.push({ ...values }); @@ -174,26 +174,25 @@ key("ArrowLeft"); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤 slot("right").click(); // 우측 바깥 → +0.1 log.push({ ...values }); handle.setActive(pieceAt(-4)); -const text = handle.label.textContent; -const readout = panel.descendants.find((n) => n._cls.has("b06-structure-panel__value")).textContent; +const text = cellValue(); +const slots = ["left", "up", "reset", "down", "right"] + .map((n) => slot(n)) + .filter((b) => !b.hidden) + .map((b) => b.textContent); slot("reset").click(); // ↺ = 그 쪽 0 const afterReset = { ...values }; -close.click(); // ✕ = 같은 조각 다시 고름(카드가 풂) handle.setActive(null); -key("ArrowRight"); // 숨은 패널은 방향키 안 먹음 +key("ArrowRight"); // 빈 칸은 방향키 안 먹음 process.stdout.write( JSON.stringify({ log, text, - readout, titleLeft, afterReset, afterHidden: { ...values }, picks, - pieceRight: pieceAt(-4), hiddenAtStart, - hiddenAfter: panel.classList.contains("is-hidden"), - labelButtons: handle.label.children.length, - slots: ["left", "reset", "right"].map((n) => slot(n).textContent), + hiddenAfter: cell.classList.contains("is-empty"), + slots, }), ); diff --git a/resources/tester/helper_193_1_line_axis.cjs b/resources/tester/helper_193_1_line_axis.cjs index e2e9d71eb..4be2feca6 100644 --- a/resources/tester/helper_193_1_line_axis.cjs +++ b/resources/tester/helper_193_1_line_axis.cjs @@ -173,13 +173,10 @@ function card(sectionMode, ditch) { Math.min(p.from.offset, p.to.offset) <= offset && offset <= Math.max(p.from.offset, p.to.offset), ); - const slot = (name) => handle.panel.querySelector(`.b06-structure-panel__btn--${name}`); - const title = () => - handle.panel.descendants.find((n) => n._cls.has("b06-structure-panel__title")).textContent; - const rowHidden = () => - handle.panel.descendants - .find((n) => n._cls.has("b06-structure-panel__struct")) - ._cls.has("is-hidden"); + // 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(◀▲↺▼▶ 보임만 바뀜) · 값 한 줄 + const slot = (name) => handle.cell.querySelector(`.b06-cross-card__cell-btn--${name}`); + const title = () => handle.cell.children[0].textContent; + const rowHidden = () => ["left", "reset", "right"].every((name) => slot(name).hidden); return { design, handle, values, at, slot, title, rowHidden }; } @@ -236,7 +233,7 @@ const out = {}; same: JSON.stringify(before) === JSON.stringify(c.values), title: c.title(), rowHidden: c.rowHidden(), - label: c.handle.label.textContent, + label: c.handle.cell.children[1].children.at(-1).textContent, }; // 고름 풀면 아무 키도 안 막음 c.handle.setActive(null); diff --git a/resources/tester/helper_194_1_berm_pick.cjs b/resources/tester/helper_194_1_berm_pick.cjs index 96b25c34a..d0f8a57e2 100644 --- a/resources/tester/helper_194_1_berm_pick.cjs +++ b/resources/tester/helper_194_1_berm_pick.cjs @@ -176,15 +176,17 @@ function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], s leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl), road: find((p) => mid(p) > 0 && mid(p) < 2), }; + // 아래 띠 공용 칸(370-3) — 버튼 다섯 칸 한 벌(보임만 바뀜) · 제목 · 값 한 줄 const slot = (name) => - handle.panel.descendants.find( - (n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden, - ); - const rows = () => - handle.panel.descendants - .filter((n) => n._cls.has("b06-structure-panel__struct")) - .map((n) => n._cls.has("is-hidden")); - const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent; + handle.cell.descendants.find((n) => n._cls.has(`b06-cross-card__cell-btn--${name}`)); + const rows = () => [ + slot("left").hidden && slot("right").hidden, + slot("up").hidden && slot("down").hidden, + ]; + const text = (what) => + what === "title" + ? handle.cell.children[0].textContent + : handle.cell.children[1].children.at(-1).textContent; return { design, handle, values, sets, focus, at, slot, rows, text }; } @@ -212,13 +214,10 @@ const out = {}; const afterSide = { values: { ...c.values }, sets: c.sets.length }; const upTaken = press("ArrowUp"); press("ArrowDown"); - c.handle.panel.descendants - .filter((n) => n._cls.has("b06-structure-panel__btn--reset")) - .at(-1) - .click(); + c.slot("reset").click(); out.berm = { - title: c.text("b06-structure-panel__title"), - readout: c.text("b06-structure-panel__value"), + title: c.text("title"), + readout: c.text("value"), moveHidden, liftHidden, leftBlocked, @@ -232,7 +231,7 @@ const out = {}; const before = c.sets.length; out.berm.slopeUpBlocked = press("ArrowUp"); out.berm.slopeSets = c.sets.length - before; - out.berm.slopeTitle = c.text("b06-structure-panel__title"); + out.berm.slopeTitle = c.text("title"); out.berm.focusAfter = c.focus.at(-1); // 노면 = ▲▼ 는 종단도로 넘김(안 막음) c.handle.setActive(c.at.road); diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index d69a3482d..e176501a4 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -321,6 +321,12 @@ const out = {}; soil_exists: soil !== null, soil_children: soil?.childElementCount ?? null, }; + // 370-3 — 설계선 고름 공용 칸이 오면 그 칸이 왼쪽(암 제어를 따로 안 만듦) + const cell = new FakeNode("div"); + cell.className = "linecell"; + const cellCard = new FakeNode("article"); + appendCardFooter(cellCard, { design: { geometry_preset: "rock" } }, {}, {}, cell); + out.footer.cell_classes = cellCard.querySelector("footer").children.map((kid) => kid.className); } // ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ─────────── diff --git a/resources/tester/helper_370_2_toe_stop.cjs b/resources/tester/helper_370_2_toe_stop.cjs index 88bafda41..3d64dda20 100644 --- a/resources/tester/helper_370_2_toe_stop.cjs +++ b/resources/tester/helper_370_2_toe_stop.cjs @@ -260,18 +260,23 @@ out.toe = { }; toes[1].click(); handle.setActive(picks[0]); -const right = handle.panel.children.find((k) => - k.classList?.contains("b06-structure-panel__struct"), -); -const buttons = right.children[1].children; -const rightButton = x(-1) > x(0) ? buttons[2] : buttons[0]; // 화면 → 바깥(우 = −offset) -rightButton.click(); +// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(끝 = 값 글) +const cellOf = (h) => ({ + title: h.cell.children[0].textContent, + value: h.cell.children[1].children.at(-1).textContent, + button: (slot) => + h.cell.children[1].children.find((k) => + k.classList.contains(`b06-cross-card__cell-btn--${slot}`), + ), +}); +const cell = cellOf(handle); +cell.button(x(-1) > x(0) ? "right" : "left").click(); // 화면 → 바깥(우 = −offset) out.toePick = { picked: picks, active: toes[1].classList.contains("is-active"), - label: handle.label.textContent, - title: handle.panel.children[0].children[0].textContent, - panelOpen: !handle.panel.classList.contains("is-hidden"), + value: cell.value, + title: cell.title, + panelOpen: !handle.cell.classList.contains("is-empty"), sent: changes.map((c) => c.shoulder_extend), }; @@ -295,6 +300,6 @@ out.inherit = { afterIndex, }; after.handle.setActive(afterIndex); -out.inherit.label = after.handle.label.textContent; +out.inherit.label = cellOf(after.handle).title; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_370_3_rock_cell.cjs b/resources/tester/helper_370_3_rock_cell.cjs new file mode 100644 index 000000000..bce0124f2 --- /dev/null +++ b/resources/tester/helper_370_3_rock_cell.cjs @@ -0,0 +1,310 @@ +/* PLAN 370-3 단위검증 헬퍼 — B06 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸. + * `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_370_3_rock_cell.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +class Node { + constructor(tag) { + this.tagName = tag; + this.children = []; + this.attrs = {}; + this.listeners = {}; + this.hidden = false; + this.isConnected = true; + this._text = ""; + this._cls = new Set(); + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._cls.add(c)), + toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)), + contains: (c) => self._cls.has(c), + }; + } + get descendants() { + return this.children.flatMap((c) => [c, ...c.descendants]); + } + /** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */ + querySelector(sel) { + return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null; + } + getClientRects() { + return this._cls.has("is-hidden") ? [] : [{}]; + } + set className(v) { + this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + this.children.push(...kids); + } + insertBefore(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this.attrs[n] = String(v); + if (n === "class") this.className = v; + } + getAttribute(n) { + return this.attrs[n] ?? null; + } + hasAttribute(n) { + return n in this.attrs; + } + addEventListener(t, fn) { + (this.listeners[t] ??= []).push(fn); + } + click() { + (this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined })); + } +} +const docListeners = {}; +globalThis.document = { + createElement: (t) => new Node(t), + createElementNS: (_n, t) => new Node(t), + addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn), + removeEventListener: (t, fn) => { + docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn); + }, +}; +const key = (name) => + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => undefined }), + ); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const req = (r) => + r.startsWith("@util/") + ? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`)) + : loadTs(path.join(path.dirname(full), `${r}.ts`)); + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} +globalThis.Element = Node; + +const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts")); +const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽 +const y = (e) => 900 - (e - 100) * 12; +const line = (pts, structure = false) => { + const el = new Node("polyline"); + el.setAttribute("class", "b06-chart__design-cross"); + el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" ")); + if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, ""); + return el; +}; +/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */ +/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */ +const press = (name) => { + let prevented = false; + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => (prevented = true) }), + ); + return prevented; +}; +// 끌기 — window 듣개(놓을 때 click 한 번 막음 포함) +const winListeners = {}; +globalThis.window = { + addEventListener: (t, fn) => (winListeners[t] ??= []).push(fn), + removeEventListener: (t, fn) => { + winListeners[t] = (winListeners[t] ?? []).filter((f) => f !== fn); + }, + setTimeout: () => 0, + clearTimeout: () => undefined, + setInterval: () => 0, + clearInterval: () => undefined, +}; +globalThis.setTimeout = () => 0; +const fire = (type, event) => [...(winListeners[type] ?? [])].forEach((fn) => fn(event)); + +/** 암 측점 카드 — 좌 사면 · 노면 · 우 사면 + 암 경계선 점선 두 토막(결측으로 끊김). */ +function card(rockOn = true) { + docListeners.keydown = []; + const design = { + road_edges: { + left: { offset_m: 2, elevation_m: 100 }, + right: { offset_m: -2, elevation_m: 100 }, + }, + ditch: null, + ditch_side: "left", + ditch_enabled: false, + section_mode: "both_fill", + }; + const layer = new Node("g"); + layer.getScreenCTM = () => ({ a: 1, d: 1 }); + layer.append( + line([ + [6, 97], + [2, 100], + ]), + line([ + [2, 100], + [-2, 100], + ]), + line([ + [-2, 100], + [-6, 97], + ]), + ); + const rockLine = (pts) => { + const el = new Node("polyline"); + el.setAttribute("class", "b06-chart__rock-boundary"); + el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" ")); + el.style = { setProperty: (k, v) => (el.styleSet = v) }; + return el; + }; + const rocks = [ + rockLine([ + [8, 96], + [1, 98], + ]), + rockLine([ + [-1, 98], + [-8, 96], + ]), + ]; + layer.append(...rocks); + const offsets = []; + const resets = []; + const rock = { + legend: "암 경계선", + offsetM: -0.5, + stepM: 0.1, + upTitle: "위로", + downTitle: "아래로", + resetTitle: "기본", + adjust: (d) => offsets.push(Math.round(d * 100) / 100), + reset: () => resets.push(true), + }; + const values = { left: 0, right: 0 }; + const picks = []; + const handle = pick.attachDesignLinePick({ + layer, + design, + x, + toDisplayY: y, + onPick: (i) => picks.push(i), + shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) }, + stationId: "R1", + ...(rockOn && { rock }), + }); + const cell = handle.cell; + const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`); + const state = () => ({ + empty: cell.classList.contains("is-empty"), + title: cell.children[0].textContent, + value: cell.children[1].children.at(-1).textContent, + slots: ["left", "up", "reset", "down", "right"].filter((n) => !slot(n).hidden), + arrowSlots: ["left", "up", "reset", "down", "right"].filter((n) => + slot(n).classList.contains(`b06-structure-panel__btn--${n}`), + ), + }); + const hits = layer.children.filter((k) => k.classList.contains("b06-chart__design-hit")); + const rockHit = hits.find((k) => k.classList.contains("b06-chart__rock-hit")); + if (rockHit) rockHit.style = { setProperty: (k, v) => (rockHit.styleSet = v) }; + return { handle, cell, slot, state, hits, rockHit, rocks, offsets, resets, values, picks }; +} + +const out = {}; + +// ① 안 고름 — 공용 칸 = 암 ▲▼↺ · 값 · 방향키는 안 먹음(종단 계획고로 그대로) +{ + const c = card(); + c.handle.setActive(null); + out.idle = { ...c.state(), upPassed: !press("ArrowUp") }; + c.slot("up").click(); + c.slot("down").click(); + c.slot("reset").click(); + out.idle.offsets = c.offsets; + out.idle.resets = c.resets.length; +} + +// ② 판정 선 — 점선 위 path 하나(끊긴 두 토막 · M 둘) · 보라 선 판정 클래스 · 키 rock +{ + const c = card(); + const index = c.hits.indexOf(c.rockHit); + c.rockHit.click(); + out.hit = { + found: !!c.rockHit, + tag: c.rockHit?.tagName, + moves: (c.rockHit?.getAttribute("d").match(/M/g) ?? []).length, + last: index === c.hits.length - 1, + picked: c.picks, + }; + // ③ 고름 — 제목 · ▲▼↺ · ◀▶ 막음 · ▲▼ = 암만(종단으로 안 샘) + c.handle.setActive(index); + pick.setPickedLine("R1", index); + out.picked = { + ...c.state(), + active: c.rockHit.classList.contains("is-active"), + leftBlocked: press("ArrowLeft"), + rightBlocked: press("ArrowRight"), + upTaken: press("ArrowUp"), + downTaken: press("ArrowDown"), + sideValues: { ...c.values }, + key: pick.pickedLineAt("R1") === index, + }; + out.picked.offsets = c.offsets; + // ④ 세로 끌기 — 위로 0.34 m(화면 px = m × 12) → 0.3 m 한 번 · 끄는 동안 점선 · 판정 선만 옮겨 보임 + let stopped = false; + c.rockHit.listeners.pointerdown[0]({ + button: 0, + clientX: 0, + clientY: 500, + preventDefault: () => undefined, + stopPropagation: () => (stopped = true), + }); + fire("pointermove", { clientX: 0, clientY: 500 - 0.34 * 12 }); + const during = { rock: c.rocks[0].styleSet, hit: c.rockHit.styleSet, value: c.state().value }; + const before = c.offsets.length; + fire("pointerup", {}); + out.drag = { + stopped, + during, + sent: c.offsets.slice(before), + clickSwallowed: (winListeners.click ?? []).length === 1, + }; + // 풀면 다시 암 칸 · 방향키 안 먹음 + c.handle.setActive(null); + out.released = { ...c.state(), upPassed: !press("ArrowUp") }; + // 사면을 고르면 그 선 칸(제목 · ◀↺▶ · 값 한 줄) + const slope = c.handle.pieces.findIndex((p) => Math.max(p.from.offset, p.to.offset) > 3); + c.handle.setActive(slope); + out.slope = c.state(); + pick.setPickedLine("R1", null); +} + +// ⑤ 안 고른 끌기는 안 감(카드 팬) · 토사(암 없음) = 빈 칸 · 판정 선 없음 +{ + const c = card(); + c.handle.setActive(null); + let stopped = false; + c.rockHit.listeners.pointerdown[0]({ + button: 0, + clientY: 0, + preventDefault: () => undefined, + stopPropagation: () => (stopped = true), + }); + out.idleDrag = { stopped }; + const soil = card(false); + soil.handle.setActive(null); + out.soil = { ...soil.state(), rockHit: !!soil.rockHit }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py index fb46749b6..5ab75f6f0 100644 --- a/resources/tester/test_137_design_pick.py +++ b/resources/tester/test_137_design_pick.py @@ -100,8 +100,9 @@ def test_카드_배선_하나만() -> None: assert "if (activeRevet === null) revetOffset?.select(section.chainage_m, null);" in view assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적 assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림 - assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽 - assert "chartWrap.append(linePick.panel);" in view # 사면 이동 패널(151-3) + # 치수 · 이동 = 아래 띠 왼쪽 공용 칸(370-3 · 옛 [+ − ⤢] 왼쪽 치수 · 떠 있는 이동 패널 걷음) + assert "linePick.label" not in view and "linePick.panel" not in view + assert "appendCardFooter(card, section, rockBoundary, cutSlope, linePick?.cell);" in view css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") assert ".b06-chart__design-hit.is-active {" in css diff --git a/resources/tester/test_139_6_line_shift.py b/resources/tester/test_139_6_line_shift.py index 2430c9a19..7ac959341 100644 --- a/resources/tester/test_139_6_line_shift.py +++ b/resources/tester/test_139_6_line_shift.py @@ -33,18 +33,17 @@ def test_화면_방향대로_그_쪽만_단추와_방향키(out: dict) -> None: assert right == {"left": 0, "right": 0.1} # 우 사면 · ← = 안쪽 0 · ▶ = 바깥 0.1 -def test_치수_줄과_패널에_이동_표기(out: dict) -> None: +def test_공용_칸_값에_이동_표기(out: dict) -> None: + """370-3 — 치수 · 이동은 아래 띠 공용 칸 값 한 줄(옛 우측 위 치수 · 패널 읽기 줄).""" assert "이동 +0.1m" in out["text"] and "노견 +" not in out["text"] - assert out["readout"] == "이동 0.1m" + assert out["text"].startswith("길이 ") and "\n" not in out["text"] assert out["titleLeft"] == "사면 이동 (좌)" -def test_단추는_패널로_옮김_재설정_닫기(out: dict) -> None: - """151-3 — 치수 칸 [◀ ▶] 지움 · 패널 ◀ ↺ ▶ · ↺ = 그 쪽 0 · ✕ = 고름 풂 · 숨으면 방향키 안 먹음.""" - assert out["labelButtons"] == 0 +def test_단추는_공용_칸_재설정(out: dict) -> None: assert out["slots"] == ["◀", "↺", "▶"] assert out["afterReset"] == {"left": 0, "right": 0} - assert out["picks"] == [out["pieceRight"]] + assert out["picks"] == [] # 칸 누름은 고름으로 안 번짐 assert out["hiddenAtStart"] is True and out["hiddenAfter"] is True assert out["afterHidden"] == {"left": 0, "right": 0} diff --git a/resources/tester/test_179_1_line_dims_left.py b/resources/tester/test_179_1_line_dims_left.py index 7d9406081..e1de5b0cd 100644 --- a/resources/tester/test_179_1_line_dims_left.py +++ b/resources/tester/test_179_1_line_dims_left.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- -"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험).""" +"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험). + +370-3 — 카드 치수는 아래 띠 공용 칸으로 옮김 · 치수 칸 꼴은 상세 모달(`_Detail_Select`)이 씀. +""" import os import re @@ -22,9 +25,15 @@ def test_좁은_카드_내림_규칙_없음(): ] -def test_치수_칸은_줌_막대_맨_앞(): +def test_카드_치수는_아래_띠_공용_칸(): + """370-3 — 카드의 고른 선 치수는 줌 막대 앞 라벨을 걷고 아래 띠 왼쪽 공용 칸 값 한 줄로(상세 모달 치수는 그대로).""" view = _src("B06_Section/B06_Section_UI_Cross_View.ts") - assert "zoomBar.prepend(linePick.label);" in view + assert "linePick.label" not in view and "zoomBar.prepend(" not in view + assert "linePick?.cell" in view + assert "b06-cross-card__line-dims" not in _src( + "B06_Section/B06_Section_UI_Cross_Design_Pick.ts" + ) + assert "b06-cross-card__line-dims" in _src("B06_Section/B06_Section_UI_Cross_Detail_Select.ts") zoom = ( _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css") .split(".b06-cross-card__zoom {", 1)[1] diff --git a/resources/tester/test_194_1_berm_depth.py b/resources/tester/test_194_1_berm_depth.py index b524a947c..c8765cb14 100644 --- a/resources/tester/test_194_1_berm_depth.py +++ b/resources/tester/test_194_1_berm_depth.py @@ -298,7 +298,8 @@ def test_이동_셋_분리(ui: dict) -> None: @node_only def test_소단_고름_위아래만(ui: dict) -> None: berm = ui["berm"] - assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m" + assert berm["title"] == "소단 높이 (좌)" + assert berm["readout"].startswith("도로 아래 1.0m · 길이 ") # 값 한 줄(370-3 공용 칸) assert berm["moveHidden"] is True and berm["liftHidden"] is False assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘 assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감 @@ -332,7 +333,7 @@ def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None: assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3} assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음 assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시 - assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음 + assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 없음 selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text( encoding="utf-8" ) diff --git a/resources/tester/test_318_cross_card.py b/resources/tester/test_318_cross_card.py index 456589481..146d0ae88 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -95,6 +95,11 @@ def test_footer_without_center_text(result: dict) -> None: assert footer["rock_first"].split()[-1] == "b06-cross-card__rockb" # 암 경계 = 왼쪽 첫 칸 assert footer["rock_classes"][-1].split()[-1] == "b06-cross-card__cutslope" assert footer["soil_exists"] and footer["soil_children"] == 0 # 토사도 띠는 남음(빈 띠) + # 370-3 — 선 공용 칸 = 왼쪽 첫 칸(암 꼴 클래스) · 암 절토 경사 = 오른쪽 그대로 + assert footer["cell_classes"] == [ + "linecell b06-cross-card__rockb", + "cutslope b06-cross-card__cutslope", + ] def test_center_wired_into_card_and_modal() -> None: diff --git a/resources/tester/test_356_b06_card_center.py b/resources/tester/test_356_b06_card_center.py index e3150e9fa..672cd09f2 100644 --- a/resources/tester/test_356_b06_card_center.py +++ b/resources/tester/test_356_b06_card_center.py @@ -82,11 +82,9 @@ def test_배선(): pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8") assert "center: section.chainage_m," in pick # designLineMoves → 카드 고름 끝 조각 assert 'if (axis === "center") return centerShift?.step(dir);' in pick - # 364-7 덧 — 중심점 ◀▶ 툴팁 = 노선 좌우 · ↺ · 소단 ▲▼ 숨김 + # 364-7 덧 — 중심점 ◀▶ 툴팁 = 노선 좌우 · ↺ 없음 · 소단 ▲▼ 없음(370-3 공용 칸 = 축에 맞는 버튼만) assert "`이 측점 노선을 화면 ${screen}으로 0.1 m`" in pick - assert '[toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")];' in pick - assert 'move.controls.children[1]?.classList.toggle("is-hidden", axis === "center");' in pick - assert 'lift.row.classList.toggle("is-hidden", axis !== "depth");' in pick + assert 'title: "중심점", value: centerPreviewText(0), buttons: sideways(false)' in pick order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8") assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_370_2_toe_stop.py b/resources/tester/test_370_2_toe_stop.py index 46d6d2689..e40accd7e 100644 --- a/resources/tester/test_370_2_toe_stop.py +++ b/resources/tester/test_370_2_toe_stop.py @@ -63,8 +63,8 @@ def test_교차점_손잡이_쪽마다_늘_보임(out) -> None: def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None: got = out["toePick"] assert got["active"] is True and got["panelOpen"] is True - assert got["label"].startswith("교차점 (우) · 노선에서 ") - assert got["title"] == "교차점 이동 (우)" + assert got["title"] == "교차점 이동 (우)" # 아래 띠 공용 칸(370-3) + assert got["value"].startswith("노선에서 ") assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right" low, high = out["stop"]["crossing"] assert abs(got["sent"][0]["valueM"] - out["stop"]["value"]) < 1e-9 # ◀▶ 도 멈춤 자리 @@ -76,4 +76,4 @@ def test_절에서_성으로_넘어가도_고름_유지(out) -> None: assert inherit["cutIndex"] >= 0 and inherit["before"] == inherit["cutIndex"] assert inherit["farMode"] == "left_cut" # 우 = 성토로 넘어감 assert inherit["afterIndex"] is not None - assert inherit["label"].startswith("교차점 (우)") + assert inherit["label"] == "교차점 이동 (우)" diff --git a/resources/tester/test_370_3_rock_cell.py b/resources/tester/test_370_3_rock_cell.py new file mode 100644 index 000000000..c1e579be8 --- /dev/null +++ b/resources/tester/test_370_3_rock_cell.py @@ -0,0 +1,93 @@ +"""PLAN 370-3 — B06 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸(요구 240-22). + +짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts`(rock 축 · 판정 선) · +`B06_Section/B06_Section_UI_Cross_Line_Cell.ts`(공용 칸) · `B06_Section_UI_Cross_Center.ts`(`bindAxisDrag`). +실제 TS 를 `helper_370_3_rock_cell.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_370_3_rock_cell.cjs" +B06 = ROOT / "B06_Section" + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_안_고르면_공용_칸은_암_버튼과_값(out: dict) -> None: + idle = out["idle"] + assert idle["title"] == "암 경계선" and idle["value"] == "-0.5m" + assert idle["slots"] == ["up", "reset", "down"] + assert idle["upPassed"] is True # 안 고르면 ▲▼ = 종단 계획고 그대로 + assert idle["offsets"] == [0.1, -0.1] and idle["resets"] == 1 + + +def test_암_경계선_판정_선(out: dict) -> None: + hit = out["hit"] + assert hit["found"] and hit["tag"] == "path" and hit["moves"] == 2 # 끊긴 두 토막 한 판정 + assert hit["last"] and hit["picked"] == [3] # 조각 셋 뒤 · 누르면 고름 + + +def test_암_고름은_위아래만_암_키는_암만(out: dict) -> None: + got = out["picked"] + assert got["active"] and got["key"] # 키 rock 으로 기억 + assert got["title"] == "암 경계선" and got["slots"] == ["up", "reset", "down"] + assert got["leftBlocked"] and got["rightBlocked"] # ◀▶ 막음 + assert got["upTaken"] and got["downTaken"] # ▲▼ = 암(종단으로 안 샘) + assert got["sideValues"] == {"left": 0, "right": 0} + assert got["offsets"][:2] == [0.1, -0.1] + + +def test_세로_끌기는_놓을_때_한_번(out: dict) -> None: + drag = out["drag"] + assert drag["stopped"] is True # 카드 팬으로 안 번짐 + assert drag["during"]["rock"] == drag["during"]["hit"] # 점선 · 판정 선 같이 + dy = float(drag["during"]["rock"].split()[1].removesuffix("px")) + assert abs(dy - (-0.3 * 12)) < 1e-9 # 위로 0.3 m(화면 px = m × 12) + assert drag["during"]["value"] == "-0.2m — 놓으면 반영" + assert drag["sent"] == [0.3] # 0.34 m → 걸음 0.1 맞춤 · 한 번 + assert drag["clickSwallowed"] is True + assert out["idleDrag"]["stopped"] is False # 안 고르면 끌기 아님 + + +def test_풀면_암_칸_사면_고르면_그_선_칸(out: dict) -> None: + assert out["released"]["title"] == "암 경계선" and out["released"]["upPassed"] + slope = out["slope"] + assert slope["title"] == "사면 이동 (좌)" and slope["slots"] == ["left", "reset", "right"] + assert slope["value"].startswith("길이 ") and "\n" not in slope["value"] + assert slope["arrowSlots"] == slope["slots"] # 숨은 버튼은 방향키 슬롯 없음 + + +def test_토사는_빈_칸_판정_선_없음(out: dict) -> None: + soil = out["soil"] + assert soil["empty"] is True and soil["slots"] == [] and soil["rockHit"] is False + + +def test_배선_떠_있는_선_조정창_걷음() -> None: + pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8") + assert "buildPanelShell" not in pick and "b06-cross-card__line-dims" not in pick + view = (B06 / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + assert "rockBoundary && rockCellSpec(section, rockBoundary)" in view + chrome = (B06 / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8") + assert "if (lineCell) {" in chrome + detail = (B06 / "B06_Section_UI_Cross_Detail_Pick.ts").read_text(encoding="utf-8") + assert ".b06-chart__design-hit:not(.b06-chart__rock-hit)" in detail # 상세 표 강조에 안 섞임 + css = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8") + assert ".b06-cross-card__linecell.is-empty {" in css and "text-overflow: ellipsis;" in css