diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 2d436080b..63fc84c16 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -31,6 +31,7 @@ import type { DesignView } from "./design_dock"; import { saveSharedDesign, type DesignPageShared } from "./design_shared"; import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; +import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; @@ -140,6 +141,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, }; guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4) + setRouteShiftOpener(() => shared.ensureView("profile"), showToast); // 중심점 좌우 = 편집기 숨긴 채(356-3) /** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */ const applyView = (view: DesignView): void => { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 224bbfb60..ffd9f085c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -89,6 +89,7 @@ import { } from "./B05_Profile_UI_RouteEdit_History"; import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; +import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -627,6 +628,7 @@ export async function mountRouteEditor( history = createRouteEditHistory(snapshotNow()); history = draftLink.watch(history, options.draft, () => stationSnap); draftLink.restore(options.draft, restoreSnapshot); + bindRouteShift(host, projectId, snapshotNow, applyEdit, () => [plannedLine, curveInfo]); // 356-3 historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts new file mode 100644 index 000000000..ff19d2ce2 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts @@ -0,0 +1,99 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Shift.ts + * 횡단 중심점 좌우 → 노선 편집(356-3) — B06 카드가 부르는 host(`setRouteShiftHost`)를 편집기가 등록. + * + * 한 번 = 노드 옮김 · 끼움 한 번(고름은 `logic/B05_Profile_RouteShift`) → 편집기 한 걸음(applyEdit) → + * 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 그 측점 계획고를 옮기기 전 값으로(바뀐 구간 첫 + * 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume). + * ========================================================================== */ + +import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import { pagePipeline, pipelineGrade } from "../A00_Common/design/design_pipeline_page"; +import { setRouteShiftHost } from "../B06_Section/B06_Section_UI_Cross_Center"; +import { pageHistory } from "./B05_Profile_UI_Profile_History"; +import type { RouteEditHost } from "./B05_Profile_UI_RouteEdit"; +import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; +import type { EditedCurve } from "./logic/B05_Profile_UI_RouteEdit_Curve"; +import { spliceNode } from "./logic/B05_Profile_UI_RouteEdit_Edits"; +import { + emptyEdits, + planElevationAt, + type AlignmentEdits, +} from "./logic/B05_Profile_UI_Profile_Alignment"; +import { pinStationHeight, planRouteShift } from "./logic/B05_Profile_RouteShift"; + +type Vertex = [number, number]; +/** 새 종단을 기다리는 한계(ms) — 넘으면 높이 보정 없이 한 걸음을 닫음. */ +const PROFILE_WAIT_MS = 10000; + +/** 다음 조작의 종단 단계(가장 늦은 차례)가 끝날 때 true · 한계를 넘으면 false. */ +function nextProfile(pipe: DesignPipeline): Promise { + return new Promise((resolve) => { + const done = (ok: boolean): void => { + clearTimeout(timer); + stop(); + resolve(ok); + }; + const timer = setTimeout(() => done(false), PROFILE_WAIT_MS); + const stop = pipe.subscribe((event) => { + if ( + event.kind === "stage" && + event.stage === "profile" && + event.generation === pipe.generation + ) + done(true); + }, "중심점 좌우"); + }); +} + +/** + * 편집기가 자료를 읽은 뒤 부름 — `columns` = 지금 편집값(산 배열 · 끼움 · 옮김이 그 자리를 고침) · + * `apply` = 편집기 한 걸음 · `drawn` = [그린 계획노선, 곡선표]. 편집기가 떠나면 host 를 뗌. + */ +export function bindRouteShift( + host: RouteEditHost, + projectId: string, + columns: () => RouteEditSnapshot, + apply: (message: string) => void, + drawn: () => [Vertex[], EditedCurve[]], +): void { + setRouteShiftHost(async (chainageM, deltaM) => { + const pipe = pagePipeline(projectId); + if (pipe && !pipe.output("profile")) await nextProfile(pipe); // 막 세운 묶음(B06 바로 열기) — 첫 셈 + const now = columns(); + const [line, curves] = drawn(); + const plan = planRouteShift({ + planned: now.planned, + line: line.length ? line : now.planned, + curves, + locked: now.apexLock, + chainageM, + deltaM, + }); + if (!plan) return "노선을 아직 못 읽었습니다."; + if (plan.kind === "locked") return "고정한 노드라 옮기지 않았습니다 — 고정을 먼저 푸세요."; + const profile = pipe?.output("profile"); + const zBefore = profile ? planElevationAt(profile, chainageM) : null; + const album = pageHistory(); + album?.pause(); + try { + const routed = pipe ? nextProfile(pipe) : null; + if (plan.kind === "move") now.planned[plan.index] = plan.to; + else spliceNode(now, plan.index, plan.at); + const side = deltaM > 0 ? "좌" : "우"; + apply(`횡단 중심점 — 측점 노선을 ${side}로 ${Math.abs(deltaM).toFixed(1)}m 옮겼습니다.`); + if (!pipe || zBefore === null || !(await routed)) return null; + const base = pipe.output("planline"); + const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits(); + const pinned = base && pinStationHeight(base, edits, chainageM, zBefore); + if (!pinned) return null; + const graded = nextProfile(pipe); + pipelineGrade(projectId, pinned); + await graded; + return null; + } finally { + album?.resume(); + } + }); + host.onDispose(() => setRouteShiftHost(null)); +} diff --git a/B05_Profile/logic/B05_Profile_RouteShift.ts b/B05_Profile/logic/B05_Profile_RouteShift.ts new file mode 100644 index 000000000..1b60f8505 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_RouteShift.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * B05_Profile_RouteShift.ts + * 횡단 중심점 좌우 이동(356-3) — 측점 자리 노선을 좌측 축으로 Δ m 옮길 노드 편집을 고르고 · 그 측점 + * 계획고를 옮기기 전 값으로 되돌릴 계획고 편집을 냄. + * + * · P = 측점 중심 · L = 좌측 단위 벡터(`stationFrameAt` · +offset = 좌) + * · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김) + * · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김) + * · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움 + * · 높이 = 옮기기 전 계획고 − 옮긴 뒤 계획고 만큼 그 측점 계획고 편집(`adjustStation`) + * + * 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음). + * ========================================================================== */ + +import { + adjustStation, + buildAlignment, + planElevationAt, + type AlignmentBase, + type AlignmentEdits, +} from "./B05_Profile_UI_Profile_Alignment"; +import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame"; + +type Vertex = [number, number]; + +/** 측점 중심에서 이 안의 노드는 끼우지 않고 옮김(m). */ +export const NODE_SNAP_M = 0.5; +/** 이보다 작은 높이 차는 보정 안 함(m). */ +export const PIN_TOLERANCE_M = 5e-4; + +export type RouteShiftPlan = + | { kind: "move"; index: number; to: Vertex } + | { kind: "insert"; index: number; at: Vertex } + | { kind: "locked"; index: number }; + +export interface RouteShiftInput { + /** 노드(꺾임점). */ + planned: ReadonlyArray; + /** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */ + line: ReadonlyArray; + /** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */ + curves: ReadonlyArray; + /** 노드마다 고정. */ + locked: ReadonlyArray; + chainageM: number; + /** 좌(+) · 우(−) 이동(m). */ + deltaM: number; +} + +/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */ +function nearestSegment(nodes: ReadonlyArray, point: Vertex): number { + let best = 0; + let bestGap = Infinity; + for (let i = 0; i < nodes.length - 1; i += 1) { + const [ax, ay] = nodes[i]; + const [vx, vy] = [nodes[i + 1][0] - ax, nodes[i + 1][1] - ay]; + const length2 = vx * vx + vy * vy || 1; + const t = Math.min(1, Math.max(0, ((point[0] - ax) * vx + (point[1] - ay) * vy) / length2)); + const gap = Math.hypot(ax + vx * t - point[0], ay + vy * t - point[1]); + if (gap < bestGap) [best, bestGap] = [i, gap]; + } + return best; +} + +/** 노드 편집 고름 — 노선이 없으면 null. */ +export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null { + const { planned, line, curves, locked, chainageM, deltaM } = input; + const frame = planned.length >= 2 ? stationFrameAt(line, chainageM) : null; + if (!frame) return null; + const { origin, left } = frame; + const shifted = ([x, y]: Vertex): Vertex => [x + left[0] * deltaM, y + left[1] * deltaM]; + const move = (index: number): RouteShiftPlan => + locked[index] + ? { kind: "locked", index } + : { kind: "move", index, to: shifted(planned[index]) }; + const near = planned.findIndex( + ([x, y]) => Math.hypot(x - origin[0], y - origin[1]) <= NODE_SNAP_M, + ); + if (near >= 0) return move(near); + const curve = curves.find((one) => { + const [span] = curveSpans(line, [one]); + return span && chainageM > span[0] && chainageM < span[1]; + }); + if (curve && planned[curve.node_first]) return move(curve.node_first); + return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) }; +} + +/** 그 측점 계획고를 `zBefore` 로 되돌린 계획고 편집 — 차가 없으면 null. */ +export function pinStationHeight( + base: AlignmentBase, + edits: AlignmentEdits, + chainageM: number, + zBefore: number, +): AlignmentEdits | null { + const drift = zBefore - planElevationAt(buildAlignment(base, edits), chainageM); + return Math.abs(drift) < PIN_TOLERANCE_M ? null : adjustStation(base, edits, chainageM, drift); +} diff --git a/B06_Section/B06_Section_UI_Cross_Center.ts b/B06_Section/B06_Section_UI_Cross_Center.ts new file mode 100644 index 000000000..e97f7a88c --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Center.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Center.ts + * 횡단 카드 중심점 고름(356-3) — 보라 선 고름(`_Cross_Design_Pick`)의 끝 조각(키 `center`). + * + * · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만) + * · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로 + * · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄 + * · 좌우 = 노선 편집기가 등록한 host(`setRouteShiftHost` · B05 `_RouteEdit_Shift`) — B06 을 바로 열어 + * 편집기가 없으면 종단 보기를 숨긴 채 세움(`setRouteShiftOpener` · 페이지가 닮 · 알림도 페이지 것) + * ========================================================================== */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 판정 원 클래스 — 고르기 차례가 보라 선 위로 올림. */ +export const CENTER_HIT_CLASS = "b06-chart__center-hit"; +/** 판정 원 반지름(px). */ +const HIT_RADIUS_PX = 8; +/** 좌우 걸음(m). */ +export const CENTER_STEP_M = 0.1; +/** 키를 멈추고 이만큼 지나면 보냄(ms) — 잇단 누름 = 한 번. */ +const KEY_SETTLE_MS = 450; +/** 편집기를 기다리는 한계(ms) — 종단 보기 · 노선 자료 읽기. */ +const HOST_WAIT_MS = 20000; + +/** 측점 · 좌(+)우(−) m → 못 옮긴 까닭(null = 옮김). */ +export type RouteShiftHost = (chainageM: number, deltaM: number) => Promise; + +let host: RouteShiftHost | null = null; +const waiters = new Set<(ready: RouteShiftHost | null) => void>(); +let opener: (() => Promise) | null = null; +type Notice = (text: string, kind: "info" | "error") => void; +let notice: Notice = () => undefined; + +/** 노선 편집기가 단다(떠나면 null). */ +export function setRouteShiftHost(next: RouteShiftHost | null): void { + host = next; + if (!next) return; + waiters.forEach((resolve) => resolve(next)); + waiters.clear(); +} + +/** 편집기를 세우는 길 · 알림 — 페이지가 단다(종단 보기 숨긴 채 세움 · 토스트). */ +export function setRouteShiftOpener(next: (() => Promise) | null, toast?: Notice): void { + opener = next; + if (toast) notice = toast; +} + +async function readyHost(): Promise { + if (host) return host; + const waiting = new Promise((resolve) => { + waiters.add(resolve); + setTimeout(() => (waiters.delete(resolve), resolve(null)), HOST_WAIT_MS); + }); + await opener?.().catch(() => undefined); + return host ?? waiting; +} + +/** 그 측점 노선을 좌우로 — 편집기가 없으면 세워서. 옮겼으면 true. */ +export async function shiftRouteAt(chainageM: number, deltaM: number): Promise { + const ready = await readyHost(); + if (!ready) { + notice("노선 편집기를 못 열어 좌우로 옮기지 못했습니다.", "error"); + return false; + } + const problem = await ready(chainageM, deltaM); + if (problem) notice(problem, "info"); + return !problem; +} + +/** 판정 원(중심 십자 자리). */ +export function centerHit(cx: number, cy: number): SVGCircleElement { + const hit = document.createElementNS(SVG_NS, "circle"); + hit.setAttribute("cx", String(cx)); + hit.setAttribute("cy", String(cy)); + hit.setAttribute("r", String(HIT_RADIUS_PX)); + hit.setAttribute("class", CENTER_HIT_CLASS); + return hit; +} + +const snapStep = (valueM: number): number => + Math.round(Math.round(valueM / CENTER_STEP_M) * CENTER_STEP_M * 10) / 10; + +export interface CenterShift { + /** 화면 ◀(−1) · ▶(+1) 한 걸음 — 멈추면 모아 한 번 보냄. */ + step: (screenDir: -1 | 1) => void; +} + +/** + * 좌우 이동 배선 — `kx` = 화면 x / offset(m) 기울기 · `active` = 중심점을 골랐나 · `preview` = 미리 볼 + * 이동(m · 0 = 지움). 끌기는 고른 뒤에만(안 고른 원을 끌면 카드 팬 그대로). + */ +export function bindCenterShift(input: { + hit: SVGElement; + layer: SVGGraphicsElement; + chainageM: number; + kx: number; + active: () => boolean; + preview: (deltaM: number) => void; +}): CenterShift { + const { hit, layer, chainageM, kx, active } = input; + const preview = (deltaM: number): void => { + const shift = deltaM ? `${deltaM * kx}px 0px` : ""; + for (const el of [hit, layer.querySelector("g.b06-chart__center")]) + el?.style.setProperty("translate", shift); + input.preview(deltaM); + }; + const send = (deltaM: number): void => { + 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); + }); + let pending = 0; + let timer = 0; + return { + step: (screenDir) => { + pending = snapStep(pending + screenDir * Math.sign(kx) * CENTER_STEP_M); + preview(pending); + clearTimeout(timer); + timer = window.setTimeout(() => { + const deltaM = pending; + pending = 0; + send(deltaM); + }, KEY_SETTLE_MS); + }, + }; +} + +/** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */ +export function centerPreviewText(deltaM: number): string { + if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우"; + return `노선 ${deltaM > 0 ? "좌" : "우"} ${Math.abs(deltaM).toFixed(1)}m — 놓으면 반영`; +} diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 308d96a8c..217f576d1 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -26,6 +26,7 @@ import { dpadButton, makeRow, } from "./B06_Section_UI_Cross_Panel_Base"; +import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center"; const SVG_NS = "http://www.w3.org/2000/svg"; const DESIGN_CLASS = "b06-chart__design-cross"; @@ -111,8 +112,9 @@ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => * 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */ export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis => isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x"; -/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */ -export type MoveAxis = "x" | "y" | "depth"; +/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1) · + * center = 중심점(▲▼ 종단 계획고 · ◀▶ 노선 좌우 · 356-3). */ +export type MoveAxis = "x" | "y" | "depth" | "center"; /** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */ export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number => @@ -345,7 +347,7 @@ function tellBermHost(chainageM: number | null): void { export function designLineMoves( section: Pick, onDesignChange?: (chainageM: number, change: CrossDesignChange) => void, -): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } { +): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number } { const design = section.design; if (!onDesignChange || !design) return { stationId: section.station_id }; const send = (change: Partial) => @@ -355,6 +357,7 @@ export function designLineMoves( }); return { stationId: section.station_id, + center: section.chainage_m, // 중심점 좌우 = 이 측점 노선(356-3) shift: { valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0, set: (side, valueM) => send({ shoulder_extend: { side, valueM } }), @@ -395,6 +398,8 @@ export function attachDesignLinePick(input: { berm?: BermDepthControl; /** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */ stationId?: string; + /** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */ + center?: number; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; const x0 = x(0); @@ -417,25 +422,38 @@ export function attachDesignLinePick(input: { ) .map((el) => pointsOf(el).map(toM)); const pieces = visiblePieces(mergeStraightPieces(runs), occluders); - if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces)); + const edges = design.road_edges; + const centerZ = + input.center === undefined || !edges || edges.left.offset_m === edges.right.offset_m + ? null + : edges.right.elevation_m - + ((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) / + (edges.left.offset_m - edges.right.offset_m); + const keys = [...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"])]; + if (input.stationId) keysByStation.set(input.stationId, keys); // 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1). const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); - const hits = pieces.map((piece, index) => { + const hits: SVGElement[] = pieces.map((piece) => { const hit = document.createElementNS(SVG_NS, "polyline"); hit.setAttribute( "points", [piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), ); hit.setAttribute("class", HIT_CLASS); + return hit; + }); + // 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`) + const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ)); + if (center) hits.push(center); + hits.forEach((hit, index) => { hit.addEventListener("click", (event) => { event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음 input.onPick(index); }); layer.insertBefore(hit, firstStructureHit ?? null); - return hit; }); const label = document.createElement("span"); @@ -455,7 +473,19 @@ export function attachDesignLinePick(input: { readout.className = "b06-structure-panel__value"; const move = makeRow("이동"); move.controls.classList.add("b06-structure-panel__buttons"); + const centerShift = + center && input.center !== undefined + ? bindCenterShift({ + hit: center, + layer: layer as SVGGraphicsElement, + chainageM: input.center, + kx, + active: () => axis === "center", + preview: (deltaM) => (readout.textContent = centerPreviewText(deltaM)), + }) + : null; const step = (dir: -1 | 1) => () => { + if (axis === "center") return centerShift?.step(dir); if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx)); }; move.controls.append( @@ -521,36 +551,48 @@ export function attachDesignLinePick(input: { label, panel: shell.root, setActive: (index) => { + const onCenter = !!centerShift && index === pieces.length; 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; + hits.forEach((hit, i) => + hit.classList.toggle("is-active", (!!piece || onCenter) && i === index), + ); + label.hidden = !piece && !onCenter; + active = piece || onCenter ? index : null; side = piece ? pieceZone(design, piece).side : null; - axis = piece ? pieceMoveAxis(design, piece) : "x"; + axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x"; const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0; - label.textContent = piece - ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") - : ""; - const open = !!(piece && (axis === "depth" ? berm : shift)); + label.textContent = onCenter + ? "중심점" + : piece + ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") + : ""; + const open = onCenter || !!(piece && (axis === "depth" ? berm : shift)); shell.root.classList.toggle("is-hidden", !open); // 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단) - move.row.classList.toggle("is-hidden", axis !== "x"); - move.controls.classList.toggle("is-hidden", axis !== "x"); + // 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(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"); lift.row.classList.toggle("is-hidden", axis !== "depth"); lift.controls.classList.toggle("is-hidden", axis !== "depth"); const sideName = side === "left" ? "(좌)" : "(우)"; shell.title.textContent = - axis === "y" - ? "노면 높이" - : axis === "depth" - ? `소단 높이 ${sideName}` - : `사면 이동 ${sideName}`; + axis === "center" + ? "중심점" + : axis === "y" + ? "노면 높이" + : axis === "depth" + ? `소단 높이 ${sideName}` + : `사면 이동 ${sideName}`; readout.textContent = - axis === "y" - ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" - : axis === "depth" && side - ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` - : `이동 ${extend.toFixed(1)}m`; + axis === "center" + ? centerPreviewText(0) + : axis === "y" + ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" + : axis === "depth" && side + ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` + : `이동 ${extend.toFixed(1)}m`; berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2) arrows.detach(); document.removeEventListener("keydown", blockOffAxis); diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts index 63febf573..b688e670f 100644 --- a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { thickenDown } from "./B06_Section_UI_Cross_Shape_Pick"; +import { CENTER_HIT_CLASS } from "./B06_Section_UI_Cross_Center"; /** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */ const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; @@ -69,7 +70,8 @@ export function stackPickTargets(layer: SVGElement): void { .sort((a, b) => b.area - a.area) .map(({ el }) => el); const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); - layer.append(...surfaces, ...edges, ...lines); + const centers = kids.filter((el) => el.classList.contains(CENTER_HIT_CLASS)); // 중심점 = 맨 위(356-3) + layer.append(...surfaces, ...edges, ...lines, ...centers); let down: { x: number; y: number } | null = null; layer.addEventListener( "pointerdown", diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 0fc23a43b..b3fff596b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -175,6 +175,24 @@ stroke-width: 7; } +/* 중심점 고름(356-3) — 십자 자리 투명 원 · 고르면 보라 테두리 · 끌면 좌우. 십자는 누름을 원으로 넘김. */ +.b06-chart__center { + pointer-events: none; +} + +.b06-chart__center-hit { + fill: transparent; + stroke: transparent; + stroke-width: 2; + cursor: pointer; +} + +.b06-chart__center-hit.is-active { + fill: color-mix(in srgb, var(--color-royal-amethyst) 18%, transparent); + stroke: var(--color-royal-amethyst); + cursor: ew-resize; +} + /* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ .b06-chart__carriageway-tick { stroke: var(--color-royal-amethyst); diff --git a/resources/tester/helper_356_center.cjs b/resources/tester/helper_356_center.cjs new file mode 100644 index 000000000..4c36e2ef2 --- /dev/null +++ b/resources/tester/helper_356_center.cjs @@ -0,0 +1,227 @@ +/* PLAN 356-3 단위검증 헬퍼 — 횡단 중심점 좌우 = 그 측점 노선 좌우. + * 진짜 TS(`logic/B05_Profile_RouteShift` · `B05_Profile_UI_RouteEdit_Shift` · `B06_Section_UI_Cross_Center` · + * 페이지 사진첩 `B05_Profile_UI_Profile_History`)를 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 계산 묶음은 흉내(노선이 바뀌면 바뀐 구간 첫 계획선을 새로 맞춤 = 그 측점 계획고가 바뀜) · 짝 = `test_356_b06_card_center.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const session = new Map(); +globalThis.sessionStorage = { + get length() { + return session.size; + }, + key: (i) => [...session.keys()][i] ?? null, + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; + +const toasts = []; +let pipe = null; +const graded = []; +const STUBS = { + "../A00_Common/design/design_pipeline_page": { + pagePipeline: () => pipe, + pipelineGrade: (_id, edits) => (graded.push(edits), pipe.grade(edits)), + }, +}; + +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 near = (request) => + (CATALOG.isCatalogRequest(request) ? CATALOG : null) ?? + STUBS[request] ?? + (request.startsWith("@util/") + ? loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)) + : request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request)); + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const B05 = path.join(ROOT, "B05_Profile"); +const shift = loadTs(path.join(B05, "logic", "B05_Profile_RouteShift.ts")); +const align = loadTs(path.join(B05, "logic", "B05_Profile_UI_Profile_Alignment.ts")); +const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts")); +const grade = loadTs(path.join(B05, "logic", "B05_Profile_Pipeline_Grade.ts")); +const history = loadTs(path.join(B05, "B05_Profile_UI_Profile_History.ts")); +const editShift = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Shift.ts")); +const center = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Center.ts")); + +const out = {}; + +// ── 노드 고름 — 직선 끼움 · 0.5 m 안 노드 옮김 · 고정 · 곡선 안 = 교각점 ── +const straight = [ + [0, 0], + [100, 0], +]; +const plan = (planned, line, curves, locked, chainageM, deltaM) => + shift.planRouteShift({ planned, line, curves, locked, chainageM, deltaM }); +out.insert = plan(straight, straight, [], [true, true], 40, 1); +const kinked = [ + [0, 0], + [40.3, 0], + [100, 0], +]; +out.near = plan(kinked, kinked, [], [true, false, true], 40, -0.5); +out.nearLocked = plan(kinked, kinked, [], [true, true, true], 40, -0.5); +const bent = [ + [0, 0], + [60, 0], + [60, 60], +]; +const built = curve.buildEditedPolyline(bent, [true, true, true], [null, 30, null], 30); +const bc = built.curves[0].start; +const tangentIn = Math.hypot(bc[0], bc[1]); // 시점 ~ BC(첫 토막 위) +out.curveHit = plan(bent, built.vertices, built.curves, [true, false, true], tangentIn + 5, 1); +out.curveApex = bent[1]; + +// ── 뒤 측점 다시 셈 — 끼운 뒤 바뀐 범위 = 끼운 노드 앞 노드부터 끝까지 ── +const after = [straight[0], out.insert.at, straight[1]]; +out.range = grade.routeRange(straight, after); + +// ── 높이 그대로 · 되돌리기 한 걸음 · B06 바로 열기 — 편집기 host + 흉내 묶음 ── +const policy = { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 20, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, +}; +const stations = [0, 20, 40, 60, 80, 100]; +const baseOf = (pvi) => ({ + policy, + onlyExplicitCurves: true, + basePvi: pvi.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m })), + chainage: stations, + ground: stations.map(() => 100), + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}); +const savedBase = baseOf([ + [0, 100], + [100, 110], +]); +// 노선이 바뀌면 바뀐 구간 첫 계획선을 새 지반에 다시 맞춤 — 40 m 계획고가 104 → 102 쪽으로 쓸림 +const refitBase = baseOf([ + [0, 100], + [60, 102.5], + [100, 110], +]); +let generation = 0; +const listeners = new Set(); +const state = { base: savedBase, edits: align.emptyEdits() }; +const emit = () => { + const gen = generation; + setTimeout(() => { + for (const fn of [...listeners]) fn({ kind: "stage", stage: "profile", generation: gen }); + }, 5); +}; +pipe = { + get generation() { + return generation; + }, + subscribe: (fn) => (listeners.add(fn), () => listeners.delete(fn)), + output: (name) => + name === "planline" + ? state.base + : name === "profile" + ? align.buildAlignment(state.base, state.edits) + : null, + input: (key) => (key === "alignment" ? state.edits : null), + route(planned) { + state.base = refitBase; + state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림 + sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned)); + sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits)); + generation += 1; + emit(); + }, + grade(edits) { + state.edits = edits; + sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(edits)); + generation += 1; + emit(); + }, +}; + +const album = history.createProfileHistory(() => undefined); +// 편집기 흉내 — 사진(산 배열) · 한 걸음(사진첩에 쌓고 묶음에 노선을 넘김) +const editor = { + planned: straight.map((p) => [...p]), + curveOn: [true, true], + curveRadius: [null, null], + curveLock: [null, null], + curveArc: [null, null], + apexLock: [true, true], + picked: -1, +}; +const applied = []; +const disposers = []; +const host = { onDispose: (fn) => disposers.push(fn) }; +const mountEditor = () => + editShift.bindRouteShift( + host, + "p", + () => editor, + (message) => { + applied.push(message); + album.record(); // 사진첩이 멈춰 있으면 안 쌓임 — 끝에 한 번 + pipe.route(editor.planned); + }, + () => [editor.planned, []], + ); + +(async () => { + const zBefore = align.planElevationAt(pipe.output("profile"), 40); + const sessionBefore = JSON.stringify([...session.entries()]); + // B06 바로 열기 — host 없음 → 페이지가 단 길(종단 보기 숨긴 채 세움)이 편집기를 세움 + let opened = 0; + center.setRouteShiftOpener( + async () => { + opened += 1; + setTimeout(mountEditor, 20); + }, + (text, kind) => toasts.push([text, kind]), + ); + out.moved = await center.shiftRouteAt(40, 1); + out.opened = opened; + out.planned = editor.planned; + out.applied = applied; + out.graded = graded.length; + out.zBefore = zBefore; + out.zAfter = align.planElevationAt(pipe.output("profile"), 40); + out.zRefit = align.planElevationAt(align.buildAlignment(refitBase, align.emptyEdits()), 40); + out.canUndo = album.canUndo(); + album.undo(); + out.undoOne = JSON.stringify([...session.entries()]) === sessionBefore; + out.canUndoAfter = album.canUndo(); + // 고정 노드 — 안 옮기고 까닭 알림(되돌리기 안 쌓임) + editor.planned = kinked.map((p) => [...p]); + editor.apexLock = [true, true, true]; + const lockedMoved = await center.shiftRouteAt(40, -0.5); + out.locked = { moved: lockedMoved, toast: toasts.at(-1), planned: editor.planned }; + disposers.forEach((fn) => fn()); + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_356_b06_card_center.py b/resources/tester/test_356_b06_card_center.py index 4f7fd6684..9455940de 100644 --- a/resources/tester/test_356_b06_card_center.py +++ b/resources/tester/test_356_b06_card_center.py @@ -2,12 +2,23 @@ 못박는 것 - 356-2 카드 초점 없음 — 마우스로 고른 뒤 키를 눌러도 초점 테두리(다른 색) 안 뜸 · ▲▼ 계획고는 창 듣개. + - 356-3 중심점 = 보라 선 고름 끝 조각(판정 원 · 노면 판정보다 위 · 다른 고름과 하나만) · ▲▼ = 종단 계획고 · + ◀▶ · 가로 끌기 = 그 측점 노선 좌우(0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김) · + 놓을 때 한 번 · 뒤 측점은 묶음이 다시 셈(바뀐 범위 = 앞 노드 ~ 끝) · 그 측점 계획고 그대로 · 되돌리기 한 걸음 · + B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움. + +진짜 TS 는 `helper_356_center.cjs` 가 Node 로 돌림. """ from __future__ import annotations +import json +import subprocess from pathlib import Path +import pytest + +HERE = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parents[2] B06 = ROOT / "B06_Section" @@ -17,3 +28,58 @@ def test_카드_초점_없음(): assert "card.tabIndex" not in view assert 'card.addEventListener("keydown"' not in view assert 'card.addEventListener("click", () => onSelect(section.station_id));' in view + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_356_center.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_노드_고름_끼움_옮김_고정_교각점(ts): + assert ts["insert"] == { + "kind": "insert", + "index": 1, + "at": [40, 1], + } # 직선 = 측점 자리 + 좌 1 m 끼움 + assert ts["near"] == {"kind": "move", "index": 1, "to": [40.3, -0.5]} # 0.5 m 안 노드 = 옮김 + assert ts["nearLocked"] == {"kind": "locked", "index": 1} # 고정 노드 = 안 옮김 + hit = ts["curveHit"] + assert hit["kind"] == "move" and hit["index"] == 1 # 곡선 안 = 그 교각점 + ax, ay = ts["curveApex"] + assert ((hit["to"][0] - ax) ** 2 + (hit["to"][1] - ay) ** 2) ** 0.5 == pytest.approx(1.0) + + +def test_뒤_측점은_묶음이_다시_셈(ts): + assert ts["range"] == {"fromM": 0, "toM": None} # 앞 노드 ~ 끝(Infinity) + + +def test_높이_그대로_되돌리기_한_걸음_바로_열기(ts): + assert ts["opened"] == 1 and ts["moved"] is True # 편집기 없음 → 종단 보기 숨긴 채 세움 + assert ts["planned"] == [[0, 0], [40, 1], [100, 0]] + assert len(ts["applied"]) == 1 # 편집기 한 걸음 + assert ts["zRefit"] != pytest.approx(ts["zBefore"], abs=1e-3) # 묶음 다시 맞춤이면 쓸림 + assert ts["graded"] == 1 and ts["zAfter"] == pytest.approx(ts["zBefore"], abs=1e-3) + assert ts["canUndo"] and ts["undoOne"] and not ts["canUndoAfter"] # 노선 + 계획고 = 한 걸음 + locked = ts["locked"] + assert locked["moved"] is False and locked["toast"][1] == "info" + assert locked["planned"] == [[0, 0], [40.3, 0], [100, 0]] + + +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 + 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") + assert "bindRouteShift(host, projectId, snapshotNow, applyEdit," in edit + page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8") + assert 'setRouteShiftOpener(() => shared.ensureView("profile"), showToast);' in page