Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-16 21:05:35 +09:00
14 changed files with 788 additions and 32 deletions
+2
View File
@@ -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 => {
+2
View File
@@ -91,6 +91,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)보다 **넉넉히** 둔다
@@ -628,6 +629,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]]);
@@ -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<boolean> {
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));
}
@@ -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<Vertex>;
/** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */
line: ReadonlyArray<Vertex>;
/** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */
curves: ReadonlyArray<CurveEnds & { node_first: number }>;
/** 노드마다 고정. */
locked: ReadonlyArray<boolean>;
chainageM: number;
/** 좌(+) · 우(−) 이동(m). */
deltaM: number;
}
/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */
function nearestSegment(nodes: ReadonlyArray<Vertex>, 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);
}
+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",
@@ -5,6 +5,7 @@
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
* · 양측절토(낮은 쪽 늘인 노면 `tilt_reach`) = 늘인 노면이 원지반과 만나는 점에서 수직으로 끊음(356-1).
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 ·
* 기울기를 번갈아 서로 교차 · 그림 전용(누름 안 받음 · CSS `.b06-chart__underdrain`).
* 높이가 비면 그리지 않음(수치를 지어내지 않음).
@@ -93,7 +94,12 @@ export function underdrainOutline(
const span = left.offset_m - right.offset_m;
if (!(Math.abs(span) > 1e-9)) return null;
const grade = (left.elevation_m - right.elevation_m) / span;
const line = design.design_line ?? [];
// 양측절토(356-1) — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음 · 너머 점 거름 → 꼬리가 수직으로 닫음
const reach = design.tilt_reach;
const outward = reach?.side === "left" ? 1 : -1;
const line = (design.design_line ?? []).filter(
(p) => !reach || (p.offset_m - reach.offset_m) * outward <= 1e-6,
);
const leftPath = sidePath(left, 1, line, grade, heightM);
const rightPath = sidePath(right, -1, line, grade, heightM);
return [
+1 -4
View File
@@ -183,11 +183,8 @@ export function createCrossSectionCard(
const card: CrossCardElement = document.createElement("article");
card.id = `cross-${section.station_id}`;
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
card.tabIndex = 0;
// 초점 없음(356-2) — 마우스로 고른 뒤 키를 눌러도 초점 테두리 안 뜸 · ▲▼ 계획고는 창 듣개(219-1)
card.addEventListener("click", () => onSelect(section.station_id));
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
});
// 선택 표시·면적 강조는 **DOM만 갈아 끼운다**. 카드를 새로 만들면 휠 줌·팬(viewBox)이
// 초기화돼 사용자가 맞춰 둔 배율이 날아간다(2026-08-02 사용자 지적).
@@ -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);
@@ -65,6 +65,17 @@ const design = {
{ offset_m: -7, elevation_m: 97.2 },
],
};
// 356-1 양측절토 — 우(낮은 쪽) 노면 끝도 절토 → 노면을 늘여 원지반과 만난 점(tilt_reach) · 그 너머 원지반 오름
const bothCut = {
...design,
design_line: [
...design.design_line.slice(0, 3),
{ offset_m: -4, elevation_m: 99.8 },
{ offset_m: -4.5, elevation_m: 99.775 },
{ offset_m: -6, elevation_m: 100.5 },
],
tilt_reach: { side: "right", offset_m: -4.5, elevation_m: 99.775 },
};
const svg = new Node("g");
const group = draw.appendUnderdrainOverlay(
svg,
@@ -96,6 +107,7 @@ process.stdout.write(
attached,
sections: sections.map((s) => s.underdrain ?? null),
outline: draw.underdrainOutline(design, 1),
bothCut: draw.underdrainOutline(bothCut, 1),
noHeight: draw.underdrainOutline(design, null),
drew: !!group,
bodyId: body?.attrs["data-structure-id"] ?? null,
+227
View File
@@ -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);
});
@@ -131,6 +131,22 @@ def test_형상_옵셋선_성토_절토와_만남(ts):
assert ts["drew"] is True and ts["stones"] > 0
def test_양측절토_늘인_노면_원지반_교점에서_끊음(ts):
"""356-1 — 낮은 쪽 노면 끝도 절토(tilt_reach) = reach 에서 수직으로 끊음 · 너머 원지반 따라가지 않음."""
outline = [(p["offset"], p["elevation"]) for p in ts["bothCut"]]
expected = [
(3, 100.15),
(-3, 99.85),
(-4, 99.8),
(-4.5, 99.775), # 늘인 노면 ∩ 원지반(tilt_reach)
(-4.5, 98.775), # reach 아래 옵셋선
(1.846154, 99.092308),
]
assert len(outline) == len(expected)
for got, want in zip(outline, expected):
assert got == pytest.approx(want, abs=1e-5)
def test_횡단도_B07_이_맹암거를_그림():
view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
assert (
@@ -0,0 +1,85 @@
"""356 B06 횡단 카드 — 키 강조 · 중심점 좌우(요구 235-17 · 18).
못박는 것
- 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"
def test_카드_초점_없음():
view = (B06 / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
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