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:
@@ -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 — 놓으면 반영`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user