diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 19e1e8dbc..bf02980f5 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -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 }; } /** diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 15305baca..485538bb5 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -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` : "") + : ""; }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 19222df41..ab9d0181d 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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)); diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index d63255462..b3cd18558 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -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 = { diff --git a/resources/tester/helper_139_6_line_shift.cjs b/resources/tester/helper_139_6_line_shift.cjs new file mode 100644 index 000000000..97e9207ff --- /dev/null +++ b/resources/tester/helper_139_6_line_shift.cjs @@ -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] })); diff --git a/resources/tester/test_139_6_line_shift.py b/resources/tester/test_139_6_line_shift.py new file mode 100644 index 000000000..ae56ecd91 --- /dev/null +++ b/resources/tester/test_139_6_line_shift.py @@ -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