Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user