feat(B06): 고른 설계선 [◀ ▶] = 그 쪽 노견 확장 0.1 m · 노폭 안쪽 금지 · 치수 줄 표기 · 카드 버튼과 같은 창구(139-6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-09 15:33:52 +09:00
co-authored by Claude Opus 5.5
parent 27a193ae57
commit fd7fd9f49a
6 changed files with 271 additions and 3 deletions
@@ -113,6 +113,8 @@ export interface CrossDesignChange {
/** 측구를 둘지 **사용자가 정한 선택**. `null` = 자동 판정.
* ⚠ 결과(`design.ditch_enabled` = 실제 섰나)와 **다른 값**이다(2026-09-09 정리). */
ditch_choice: boolean | null;
/** 설계선 쉬프트(139-6) — 있으면 그 쪽 노견 확장만 바꾸는 변경(다른 칸은 안 씀). */
shoulder_extend?: { side: "left" | "right"; valueM: number };
}
/**
@@ -7,6 +7,7 @@
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다.
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
* 고른 조각의 쪽 노견을 [◀ ▶] 0.1 m 로 넓히고 좁힘(139-6 쉬프트 · 노폭 안쪽 금지 = 0 에서 멈춤).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
@@ -238,6 +239,22 @@ const pointsOf = (element: Element): XY[] => {
.filter((p) => p.length === 2 && p.every(Number.isFinite));
};
/** 설계선 쉬프트(139-6) — 쪽마다 노견 확장(m · ≥ 0). */
export interface ShoulderShift {
valueOf: (side: CrossSide) => number;
set: (side: CrossSide, valueM: number) => void;
}
/** 쉬프트 걸음(m). */
export const SHIFT_STEP_M = 0.1;
/** 화면 ◀(−1) · ▶(+1) 한 걸음 → 그 쪽 노견 확장 새 값(0.1 m 눈금 · 0 아래 금지).
* `kx` = 화면 x / offset 기울기(+offset = 좌 · 화면 왼쪽이면 kx < 0). */
export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, kx: number): number {
const offsetDelta = screenDir * Math.sign(kx) * SHIFT_STEP_M;
const outward = side === "left" ? offsetDelta : -offsetDelta;
return Math.max(0, Math.round((now + outward) * 10) / 10);
}
export interface DesignLinePick {
pieces: DesignPiece[];
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
@@ -255,6 +272,8 @@ export function attachDesignLinePick(input: {
x: (offset: number) => number;
toDisplayY: (elevation: number) => number;
onPick: (index: number) => void;
/** 있으면 고른 조각 쪽 노견을 [◀ ▶] 로 옮김(139-6). */
shift?: ShoulderShift;
}): DesignLinePick {
const { layer, design, x, toDisplayY } = input;
const x0 = x(0);
@@ -300,6 +319,28 @@ 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;
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(노폭 안쪽은 못 감)"),
);
}
return {
pieces,
label,
@@ -307,7 +348,11 @@ export function attachDesignLinePick(input: {
const piece = index === null ? undefined : pieces[index];
hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index));
label.hidden = !piece;
label.textContent = piece ? pieceLabel(design, piece) : "";
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` : "")
: "";
},
};
}
+14 -1
View File
@@ -28,6 +28,7 @@ import {
appendCrossDesignOverlay,
appendPavementOverlay,
appendRockBoundaryOverlay,
type CrossDesignChange,
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome";
@@ -572,12 +573,24 @@ export function createCrossSectionCard(
}
raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5)
// 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2).
const lineDesign = section.design;
linePick = attachDesignLinePick({
layer: plotLayer,
design: section.design,
design: lineDesign,
x,
toDisplayY,
onPick: pickLine,
// 고른 선 쪽 노견 확장(139-6) — 카드 버튼과 같은 창구(세션 · 브라우저 재계산)
shift: onDesignChange
? {
valueOf: (side) => lineDesign[`shoulder_extend_${side}_m`] ?? 0,
set: (side, valueM) =>
onDesignChange(section.chainage_m, {
...(lineDesign as unknown as CrossDesignChange),
shoulder_extend: { side, valueM },
}),
}
: undefined,
});
setLineActive = linePick.setActive;
setLineActive(pickedLineAt(section.station_id));
+10 -1
View File
@@ -9,7 +9,7 @@
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
* ========================================================================== */
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import { writeCrossDesignChoice, writeShoulderExtend } from "./B06_Section_Cross_Design_Session";
import { showToast } from "@ui/ui_template_elements";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import {
@@ -57,6 +57,15 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
);
if (!target) return;
// 설계선 쉬프트(139-6) — 그 쪽 노견 확장만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구)
const shift = change.shoulder_extend;
if (shift) {
writeShoulderExtend(projectId, currentRouteId, chainageM, shift.side, shift.valueM);
if (target.design) target.design[`shoulder_extend_${shift.side}_m`] = shift.valueM;
await reconcileStaleDesigns({ force: true });
return;
}
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
if (target.design) {
target.design = {
@@ -0,0 +1,151 @@
/* 139-6 고른 설계선 [◀ ▶] 노견 확장 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서
* 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_139_6_line_shift.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._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),
};
}
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 }));
}
}
globalThis.document = {
createElement: (t) => new Node(t),
createElementNS: (_n, t) => new Node(t),
};
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;
}
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 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 line = (pts) => {
const el = new Node("polyline");
el.setAttribute("class", "b06-chart__design-cross");
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
el.setAttribute(pick.DESIGN_LINE_ATTR, "");
return el;
};
const layer = new Node("g");
// 좌 성토 사면 · 노면 · 우 성토 사면
layer.append(
line([
[6, 97],
[2, 100],
]),
line([
[2, 100],
[-2, 100],
]),
line([
[-2, 100],
[-6, 97],
]),
);
const values = { left: 0, right: 0 };
const handle = pick.attachDesignLinePick({
layer,
design,
x,
toDisplayY: y,
onPick: () => undefined,
shift: {
valueOf: (side) => values[side],
set: (side, v) => (values[side] = v),
},
});
const [left, right] = handle.label.children.filter((c) => c.tagName === "button");
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 log = [];
handle.setActive(pieceAt(4)); // 좌 사면
left.click(); // 화면 왼쪽 = 좌측 바깥 → +0.1
left.click();
log.push({ ...values });
right.click(); // 안쪽 → 0.1
right.click();
right.click(); // 0 아래 금지
log.push({ ...values });
handle.setActive(pieceAt(-4)); // 우 사면
left.click(); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤
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] }));
+48
View File
@@ -0,0 +1,48 @@
"""139-6 고른 설계선 [◀ ▶] — 고른 조각 쪽 노견 확장 0.1 m 걸음 · 노폭 안쪽 금지(0 에서 멈춤).
화면 TS(`_Cross_Design_Pick`)를 흉내 DOM 으로 돌린 값(`helper_139_6_line_shift.cjs`).
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_139_6_line_shift.cjs"
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:
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
def test_치수_줄에_노견_확장_표기(out: dict) -> None:
assert "노견 +0.1m" in out["text"]
assert out["buttons"] == ["◀", "▶"]
def test_페이지가_같은_창구로_세션_재계산() -> None:
sync = (ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts").read_text(encoding="utf-8")
assert (
"writeShoulderExtend(projectId, currentRouteId, chainageM, shift.side, shift.valueM)"
in sync
)
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "patch.shoulder_extend_left_m = sides.left" in persist