feat(B06): 356-3 횡단 중심점 고름 — ▲▼ 계획고 · ◀▶ · 가로 끌기 = 그 측점 노선 좌우

- 중심점 = 보라 선 고름 끝 조각(판정 원 · 보라 선 위 · 다른 고름과 하나만) · 십자는 누름 통과
- 좌우 = 노선 편집기가 등록한 host(B05_Profile_UI_RouteEdit_Shift) — 0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김
- 끄는 동안 · 키를 잇는 동안 십자만 옮겨 보임 · 놓을 때 한 번 커밋
- 뒤 측점은 계산 묶음이 조용히 · 그 측점 계획고 = 옮기기 전 값(계획고 편집) · 노선 + 계획고 = 되돌리기 한 걸음
- B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움(design_page 등록)
- 시험 test_356 · helper_356(노드 고름 · 뒤 측점 범위 · 높이 그대로 · 되돌리기 한 걸음 · 바로 열기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-16 20:53:10 +09:00
co-authored by Claude Opus 5.5
parent 55c5aab90e
commit c67a39c266
10 changed files with 733 additions and 27 deletions
+151
View File
@@ -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<string | null>;
let host: RouteShiftHost | null = null;
const waiters = new Set<(ready: RouteShiftHost | null) => void>();
let opener: (() => Promise<void>) | 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<void>) | null, toast?: Notice): void {
opener = next;
if (toast) notice = toast;
}
async function readyHost(): Promise<RouteShiftHost | null> {
if (host) return host;
const waiting = new Promise<RouteShiftHost | null>((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<boolean> {
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<SVGElement>("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 — 놓으면 반영`;
}
+68 -26
View File
@@ -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<CrossSection, "station_id" | "chainage_m" | "design">,
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<CrossDesignChange>) =>
@@ -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);
@@ -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",
@@ -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);