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:
@@ -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` : "")
|
||||
: "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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] }));
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user