feat(B06): 보라 선 치수 두 줄 · 사면 이동 패널(151-1 · 151-3)

- 치수 글 두 줄(사면길이 · 수평 / 높이 · 구배 · 이동) · 좁은 카드는 면적표 아래 높이로
- [◀ ▶] 지움 → 공용 조정창 꼴 이동 패널(◀ ↺ ▶ · 방향키 ←→ · 0.1 m · ✕ = 고름 풂)
- 시험 — test_137_design_pick · test_139_6_line_shift(패널 · 방향키 · ↺ · 숨김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
This commit is contained in:
2026-10-09 19:08:22 +09:00
co-authored by Claude Opus 5.5
parent 19b167caff
commit ba53c7726d
7 changed files with 186 additions and 55 deletions
+60 -28
View File
@@ -4,15 +4,22 @@
*
* 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을
* 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다.
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 두 줄로 적는다.
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
* 고른 조각의 쪽 노견을 [◀ ▶] 0.1 m 로 넓히고 좁힘(139-6 쉬프트 · 노폭 안쪽 금지 = 0 에서 멈춤).
* 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 ·
* 노폭 안쪽 금지 = 0 에서 멈춤).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
import { sideRole } from "@util/common_util_cross_design_geometry";
import {
bindArrowKeys,
buildPanelShell,
dpadButton,
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
const SVG_NS = "http://www.w3.org/2000/svg";
const DESIGN_CLASS = "b06-chart__design-cross";
@@ -205,12 +212,13 @@ export function pieceMeasure(
return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade };
}
/** 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배(줄바꿈 글자로 가름). */
export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string {
const { zone } = pieceZone(design, piece);
const m = pieceMeasure(piece, zone);
return (
`${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` +
`수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m · ${m.grade}`
`수평 ${m.horizontalM.toFixed(2)}m\n높이 ${m.heightM.toFixed(2)}m · ${m.grade}`
);
}
@@ -239,7 +247,7 @@ const pointsOf = (element: Element): XY[] => {
.filter((p) => p.length === 2 && p.every(Number.isFinite));
};
/** 설계선 쉬프트(139-6) — 쪽마다 노견 확장(m · ≥ 0). */
/** 설계선 쉬프트(139-6) — 쪽마다 사면 이동(m · ≥ 0 · 노견째 · 151-2). */
export interface ShoulderShift {
valueOf: (side: CrossSide) => number;
set: (side: CrossSide, valueM: number) => void;
@@ -247,7 +255,7 @@ export interface ShoulderShift {
/** 쉬프트 걸음(m). */
export const SHIFT_STEP_M = 0.1;
/** 화면 ◀(−1) · ▶(+1) 한 걸음 → 그 쪽 노견 확장 새 값(0.1 m 눈금 · 0 아래 금지).
/** 화면 ◀(−1) · ▶(+1) 한 걸음 → 그 쪽 사면 이동 새 값(0.1 m 눈금 · 0 아래 금지).
* `kx` = 화면 x / offset 기울기(+offset = 좌 · 화면 왼쪽이면 kx < 0). */
export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, kx: number): number {
const offsetDelta = screenDir * Math.sign(kx) * SHIFT_STEP_M;
@@ -259,6 +267,8 @@ export interface DesignLinePick {
pieces: DesignPiece[];
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
label: HTMLElement;
/** 사면 이동 패널(151-3) — 카드가 그림 칸에 붙임 · `shift` 없거나 고름 없으면 숨김. */
panel: HTMLElement;
setActive: (index: number | null) => void;
}
@@ -272,7 +282,7 @@ export function attachDesignLinePick(input: {
x: (offset: number) => number;
toDisplayY: (elevation: number) => number;
onPick: (index: number) => void;
/** 있으면 고른 조각 쪽 노견을 [◀ ▶] 로 옮김(139-6). */
/** 있으면 고른 조각 쪽 사면을 이동 패널로 옮김(139-6 · 151-3). */
shift?: ShoulderShift;
}): DesignLinePick {
const { layer, design, x, toDisplayY } = input;
@@ -319,40 +329,62 @@ export function attachDesignLinePick(input: {
const label = document.createElement("span");
label.className = "b06-cross-card__line-dims";
label.hidden = true;
const text = document.createElement("span");
label.append(text);
let side: CrossSide | null = null;
let active: number | null = null;
const { shift } = input;
if (shift) {
const button = (glyph: string, dir: -1 | 1, tip: string): HTMLButtonElement => {
const el = document.createElement("button");
el.type = "button";
el.className = "b06-cross-card__zoom-btn b06-cross-card__line-shift";
el.textContent = glyph;
el.title = tip;
el.addEventListener("click", (event) => {
event.stopPropagation();
if (side) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
});
return el;
};
label.append(
button("◀", -1, "고른 선 쪽 노견을 화면 왼쪽으로 0.1 m(노폭 안쪽은 못 감)"),
button("▶", 1, "고른 선 쪽 노견을 화면 오른쪽으로 0.1 m(노폭 안쪽은 못 감)"),
);
}
// 사면 이동 패널(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 step = (dir: -1 | 1) => () => {
if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
};
move.controls.append(
dpadButton(
"◀",
"left",
"고른 사면을 화면 왼쪽으로 0.1 m(노견째 · 노폭 안쪽은 못 감)",
step(-1),
),
dpadButton("↺", "reset", "이 쪽 이동 0", () => {
if (side && shift) shift.set(side, 0);
}),
dpadButton(
"▶",
"right",
"고른 사면을 화면 오른쪽으로 0.1 m(노견째 · 노폭 안쪽은 못 감)",
step(1),
),
);
shell.root.append(readout, move.row);
const arrows = bindArrowKeys(shell.root, () => [move.controls]);
return {
pieces,
label,
panel: shell.root,
setActive: (index) => {
const piece = index === null ? undefined : pieces[index];
hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index));
label.hidden = !piece;
active = piece ? index : null;
side = piece ? pieceZone(design, piece).side : null;
const extend = side && shift ? shift.valueOf(side) : 0;
text.textContent = piece
? pieceLabel(design, piece) + (extend > 0 ? ` · 노견 +${extend.toFixed(1)}m` : "")
label.textContent = piece
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
: "";
const open = !!(piece && shift);
shell.root.classList.toggle("is-hidden", !open);
shell.title.textContent = side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)";
readout.textContent = `이동 ${extend.toFixed(1)}m`;
arrows.detach();
if (open) arrows.attach();
},
};
}
+1
View File
@@ -662,6 +662,7 @@ export function createCrossSectionCard(
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);
@@ -223,14 +223,39 @@
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 같은 줄. */
/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */
.b06-cross-card__line-dims {
display: flex;
align-items: center;
padding: 0 var(--spacing-4);
align-self: center;
padding: 1px var(--spacing-4);
color: var(--color-royal-amethyst);
font-size: 0.75rem;
white-space: nowrap;
line-height: 1.25;
text-align: right;
white-space: pre-line;
}
/* 좁은 카드(3열 등)는 막대 옆에 두면 왼쪽 면적표를 덮음 — 표 아래 높이로 내려 오른쪽에 붙임(151-1). */
.b06-cross-card__chart-wrap {
container-type: inline-size;
}
@container (max-width: 620px) {
.b06-cross-card__zoom {
overflow: visible;
}
.b06-cross-card__line-dims {
position: absolute;
top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));
right: -1px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
.b06-cross-card__line-dims + .b06-cross-card__zoom-btn {
border-left: none;
}
}
.b06-cross-card__line-dims[hidden] {
@@ -112,6 +112,8 @@ class FakeNode {
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
createElementNS: (_ns, tag) => new FakeNode(tag),
addEventListener: () => {}, // 조정창 공용 부품(사면 이동 패널 · 151-3)이 불러올 때 닮
removeEventListener: () => {},
};
function loadTs(file) {
+60 -12
View File
@@ -1,5 +1,5 @@
/* 139-6 고른 설계선 [◀ ▶] 노견 확장 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서
* 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_139_6_line_shift.py`.
/* 139-6 고른 설계선 사면 이동 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서
* 트랜스파일해 흉내 DOM 으로 돌림 · 151-3 이동 패널(◀ ↺ ▶ · 방향키 ←→). 짝 = `test_139_6_line_shift.py`.
*/
const fs = require("fs");
const path = require("path");
@@ -15,6 +15,7 @@ class Node {
this.attrs = {};
this.listeners = {};
this.hidden = false;
this.isConnected = true;
this._text = "";
this._cls = new Set();
const self = this;
@@ -24,6 +25,16 @@ class Node {
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));
}
@@ -56,10 +67,19 @@ class Node {
(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);
@@ -115,37 +135,65 @@ layer.append(
]),
);
const values = { left: 0, right: 0 };
const picks = [];
const handle = pick.attachDesignLinePick({
layer,
design,
x,
toDisplayY: y,
onPick: () => undefined,
onPick: (i) => picks.push(i),
shift: {
valueOf: (side) => values[side],
set: (side, v) => (values[side] = v),
},
});
const [left, right] = handle.label.children.filter((c) => c.tagName === "button");
const panel = handle.panel;
const slot = (name) => panel.querySelector(`.b06-structure-panel__btn--${name}`);
const close = panel.querySelector(".b06-structure-panel__close");
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 log = [];
handle.setActive(pieceAt(4)); // 좌 사면
left.click(); // 화면 왼쪽 = 좌측 바깥 → +0.1
left.click();
const titleLeft = panel.descendants.find((n) =>
n._cls.has("b06-structure-panel__title"),
).textContent;
slot("left").click(); // 화면 왼쪽 = 좌측 바깥 → +0.1
key("ArrowLeft"); // 방향키 = 십자
log.push({ ...values });
right.click(); // 안쪽 → 0.1
right.click();
right.click(); // 0 아래 금지
key("ArrowRight"); // 안쪽 → 0.1
slot("right").click();
key("ArrowRight"); // 0 아래 금지
log.push({ ...values });
handle.setActive(pieceAt(-4)); // 우 사면
left.click(); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤
right.click(); // 우측 바깥 → +0.1
key("ArrowLeft"); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤
slot("right").click(); // 우측 바깥 → +0.1
log.push({ ...values });
handle.setActive(pieceAt(-4));
const text = handle.label.textContent;
process.stdout.write(JSON.stringify({ log, text, buttons: [left.textContent, right.textContent] }));
const readout = panel.descendants.find((n) => n._cls.has("b06-structure-panel__value")).textContent;
slot("reset").click(); // ↺ = 그 쪽 0
const afterReset = { ...values };
close.click(); // ✕ = 같은 조각 다시 고름(카드가 풂)
handle.setActive(null);
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),
}),
);
+14 -2
View File
@@ -57,8 +57,9 @@ def test_치수_사면길이_수평_높이_구배(out) -> None:
assert abs(fill["lengthM"] - 13**0.5) < 1e-9
assert out["measure"]["road"]["grade"] == "2.0%" # 노면 = %
assert out["measure"]["flatSlope"] == "0.0%"
assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m · 높이 2.00m · 1:1.50"
assert out["label"]["road"] == "길이 2.50m · 수평 2.50m · 높이 0.05m · 2.0%"
# 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배
assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m\n높이 2.00m · 1:1.50"
assert out["label"]["road"] == "길이 2.50m · 수평 2.50m\n높이 0.05m · 2.0%"
def test_구역_도로_절토_성토(out) -> None:
@@ -100,10 +101,21 @@ def test_카드_배선_하나만() -> None:
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)
css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css")
assert ".b06-chart__design-hit.is-active {" in css
def test_치수_두_줄_좁은_카드는_표_아래로() -> None:
"""151-1 — 줄바꿈 글자를 살리고 오른쪽 맞춤 · 좁은 카드(3열 등)는 표 높이 아래로 내려 표를 안 덮음."""
css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css")
dims = css.split(".b06-cross-card__line-dims {", 1)[1].split("}", 1)[0]
assert "white-space: pre-line;" in dims and "text-align: right;" in dims
assert "container-type: inline-size;" in css
narrow = css.split("@container (max-width: 620px) {", 1)[1]
assert "top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));" in narrow
def test_폴리곤_자르기는_공용_한_벌() -> None:
b07 = _src("B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts")
assert "function clipSegment(" not in b07 and "function pointInPolygon(" not in b07
+19 -8
View File
@@ -1,4 +1,4 @@
"""139-6 고른 설계선 [◀ ▶] — 고른 조각 쪽 노견 확장 0.1 m 걸음 · 노폭 안쪽 금지(0 에서 멈춤).
"""139-6 · 151-3 고른 설계선 사면 이동 패널 — 고른 조각 쪽만 0.1 m 걸음 · 노폭 안쪽 금지(0 에서 멈춤).
화면 TS(`_Cross_Design_Pick`)를 흉내 DOM 으로 돌린 값(`helper_139_6_line_shift.cjs`).
"""
@@ -26,16 +26,27 @@ def out() -> dict:
return json.loads(done.stdout)
def test_화면_방향대로_그_쪽_노견만(out: dict) -> None:
def test_화면_방향대로_그_쪽만_단추와_방향키(out: dict) -> None:
first, back, right = out["log"]
assert first == {"left": 0.2, "right": 0} # 좌 사면 고름 · ◀ 두 번 = 좌 바깥 0.2
assert back == {"left": 0, "right": 0} # ▶ 세 번 = 0 에서 멈춤(노폭 안쪽 금지)
assert right == {"left": 0, "right": 0.1} # 우 사면 · ◀ = 안쪽 0 · ▶ = 바깥 0.1
assert first == {"left": 0.2, "right": 0} # 좌 사면 · ◀ 단추 + ← 키 = 좌 바깥 0.2
assert back == {"left": 0, "right": 0} # → 키 · ▶ · → 키 = 0 에서 멈춤(노폭 안쪽 금지)
assert right == {"left": 0, "right": 0.1} # 우 사면 · ← = 안쪽 0 · ▶ = 바깥 0.1
def test_치수_줄에_노견_확장_표기(out: dict) -> None:
assert "노견 +0.1m" in out["text"]
assert out["buttons"] == ["◀", "▶"]
def test_치수_줄과_패널에_이동_표기(out: dict) -> None:
assert "이동 +0.1m" in out["text"] and "노견 +" not in out["text"]
assert out["readout"] == "이동 0.1m"
assert out["titleLeft"] == "사면 이동 (좌)"
def test_단추는_패널로_옮김_재설정_닫기(out: dict) -> None:
"""151-3 — 치수 칸 [◀ ▶] 지움 · 패널 ◀ ↺ ▶ · ↺ = 그 쪽 0 · ✕ = 고름 풂 · 숨으면 방향키 안 먹음."""
assert out["labelButtons"] == 0
assert out["slots"] == ["◀", "↺", "▶"]
assert out["afterReset"] == {"left": 0, "right": 0}
assert out["picks"] == [out["pieceRight"]]
assert out["hiddenAtStart"] is True and out["hiddenAfter"] is True
assert out["afterHidden"] == {"left": 0, "right": 0}
def test_페이지가_같은_창구로_세션_재계산() -> None: