feat(B06 횡단): 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸 (370-3)

- 새 B06_Section_UI_Cross_Line_Cell.ts — 아래 띠 왼쪽 공용 칸(제목 · ◀▲↺▼▶ 한 벌 · 값 한 줄 말줄임 · 길게 누름 반복) · 숨은 버튼은 방향키 슬롯 뗌
- Design_Pick — 암 점선 위 판정 선(키 rock · ▲▼↺ · ◀▶ 막음 · 방향키 = 암만) · 떠 있는 선 조정창 · 우측 위 치수 라벨 걷음 · 안 고르면 암 칸 · 고르면 그 선(노면 · 사면 · 소단 · 중심점 · 교차점 · 암)
- Center.ts bindAxisDrag(한 축 끌기 · 놓을 때 한 번 · click 한 번 막음) — 중심점 · 암 세로 끌기 같이 씀
- buildRockBoundaryControl = 공용 칸 「안 고름」 꼴(Cross_Design) · Card_Chrome 띠 왼쪽 = 공용 칸 · Detail_Pick 표 강조에 암 판정 선 뺌
- CSS(Cross.css 공용 칸 · Areas.css 암 고름 띠 · Controls.css 주석) · 시험 test_370_3 새로 · 137 · 139_6 · 179_1 · 193_1 · 194_1 · 318 · 356 · 370_2 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-17 12:14:43 +09:00
co-authored by Claude Opus 5.5
parent 6cac1b7cd4
commit fe025b6db2
25 changed files with 930 additions and 307 deletions
@@ -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);
+53 -20
View File
@@ -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 "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우";
+18 -100
View File
@@ -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;
}
/**
+121 -97
View File
@@ -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<CrossSection, "station_id" | "chainage_m" | "design" | "samples">,
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
/** 암 경계선 칸 제원(`rockCellSpec`) — 암 지반일 때만 씀(370-3). */
rockSpec?: RockCellSpec,
): {
stationId: string;
shift?: ShoulderShift;
berm?: BermDepthControl;
center?: number;
toes?: ReturnType<typeof toePoints>;
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<CrossDesignChange>) =>
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<typeof toePoints>;
/** 있으면 암 경계선 고름(키 `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);
@@ -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 = 모두 끔). */
@@ -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<Record<CellSlot, CellButton>>;
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 },
},
};
}
+9 -6
View File
@@ -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;
}
@@ -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;
@@ -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);
@@ -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);
+7 -2
View File
@@ -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(),
};
+15 -16
View File
@@ -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,
}),
);
+5 -8
View File
@@ -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);
+14 -15
View File
@@ -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);
@@ -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) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ───────────
+15 -10
View File
@@ -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));
+310
View File
@@ -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));
+3 -2
View File
@@ -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
+5 -6
View File
@@ -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}
+12 -3
View File
@@ -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]
+3 -2
View File
@@ -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"
)
+5
View File
@@ -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:
+2 -4
View File
@@ -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")
+3 -3
View File
@@ -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"] == "교차점 이동 (우)"
+93
View File
@@ -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