Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into sub_laptop_2
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { clearDrafts, clearResults } from "./../b_page_state";
|
||||
import { clearResults, clearState, namesInBucket } from "./../b_page_state";
|
||||
import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
@@ -43,7 +43,10 @@ export async function resetDesign(
|
||||
// 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
|
||||
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
|
||||
// 복원은 새 노선 번호를 내므로 모든 노선의 초안 · 결과를 버린다(PLAN 77).
|
||||
clearDrafts(projectId);
|
||||
// 저장 안 한 B08 · B09 초안은 남김 — [초기화]는 B05 · B06 범위만(418).
|
||||
namesInBucket("draft")
|
||||
.filter((name) => !name.startsWith("b08-") && !name.startsWith("b09-"))
|
||||
.forEach((name) => clearState(name, projectId));
|
||||
clearResults(projectId);
|
||||
onCleared?.();
|
||||
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
|
||||
|
||||
@@ -121,8 +121,6 @@ export interface RoutePlanResponse {
|
||||
station_snap_step?: number | null;
|
||||
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
|
||||
current_initial?: { logic: string; weights: Record<string, number> | null };
|
||||
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
|
||||
initial_route?: { nodes: RoutePlanNode[]; curves: RoutePlanCurve[] };
|
||||
}
|
||||
|
||||
export interface RouteReplanResponse {
|
||||
|
||||
@@ -225,24 +225,6 @@ def _read_curves(path: Path) -> list[dict]:
|
||||
return curves if isinstance(curves, list) else []
|
||||
|
||||
|
||||
def _initial_shape(project_root: Path, fallback: list, radius_m: float) -> dict[str, list]:
|
||||
"""전처리가 만든 **초기 노선**의 노드 · 곡선 — 화면 [초기화]가 재계산 없이 놓는 자리.
|
||||
|
||||
수정본이 있어도 초기 폴리라인 파일은 그대로라 항상 같은 노선이 나온다.
|
||||
"""
|
||||
path = planned_route_initial_path(project_root)
|
||||
saved_nodes = _vertices_of(_nodes_path(path))
|
||||
outline = build_planned_polyline(
|
||||
[(x, y) for x, y in (saved_nodes or _vertices_of(path) or fallback)],
|
||||
min_radius_m=radius_m,
|
||||
simplify=not saved_nodes,
|
||||
)
|
||||
return {
|
||||
"nodes": [node.as_dict() for node in outline.nodes],
|
||||
"curves": _read_curves(path) or [curve.as_dict() for curve in outline.curves],
|
||||
}
|
||||
|
||||
|
||||
def _write_planned_polyline(
|
||||
path: Path,
|
||||
points: list[tuple[float, float]],
|
||||
@@ -545,7 +527,6 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
|
||||
"station_snap_step": await asyncio.to_thread(load_station_snap_step, project_root),
|
||||
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
|
||||
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel_Recommend.ts
|
||||
* 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」 ·
|
||||
* [초기화]. 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창 · 초기값 싣기)은 편집기의
|
||||
* `_RouteEdit_Initial` 이 이 요소를 받아 건다 — 여기는 생김새만.
|
||||
* 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」.
|
||||
* 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창)은 편집기의 `_RouteEdit_Initial` 이 이 요소를
|
||||
* 받아 건다 — 여기는 생김새만. 칸 [초기화]는 지움(418) — 되돌림은 좌측 아래 [초기화] 하나.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface RecommendPart {
|
||||
recommendButton: HTMLButtonElement;
|
||||
resetButton: HTMLButtonElement;
|
||||
nowLine: HTMLElement;
|
||||
/** 칸 몸 — 화면 순서대로. */
|
||||
nodes: HTMLElement[];
|
||||
@@ -20,10 +19,7 @@ export function createRecommendPart(): RecommendPart {
|
||||
actions.className = "b05-route__rec-actions";
|
||||
actions.innerHTML = `
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
|
||||
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다 — [적용]은 바로 저장 · 새 초기값">노선 추천</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
|
||||
title="초기값 노선(전처리 · 추천 [적용] 때 정한 것)으로 되돌립니다 — 다시 계산은 [저장]에서">초기화</button>`;
|
||||
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다 — [적용]은 바로 저장 · 새 초기값">노선 추천</button>`;
|
||||
const recommendButton = actions.querySelector<HTMLButtonElement>('[data-act="recommend"]')!;
|
||||
const resetButton = actions.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
|
||||
return { recommendButton, resetButton, nowLine, nodes: [nowLine, actions] };
|
||||
return { recommendButton, nowLine, nodes: [nowLine, actions] };
|
||||
}
|
||||
|
||||
@@ -618,7 +618,7 @@ export async function mountRouteEditor(
|
||||
({ nodes: nodeInfo, curves: curveInfo } = flat);
|
||||
picked = -1;
|
||||
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
|
||||
initial.ready(snapshotNow(), plan.current_initial ?? null);
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
history = draftLink.watch(history, options.draft, snap.draft);
|
||||
draftLink.restore(options.draft, restoreSnapshot);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Initial.ts
|
||||
* 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 →
|
||||
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
|
||||
* 모달(`_Recommend`) · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
|
||||
* 밖(`_Panel_Recommend`)에서 받는다.
|
||||
*
|
||||
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
|
||||
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시
|
||||
* 셈하지 않고 **전처리 때 만든 초기값 노선**(예상노선 추적)을 놓는다 — 그 노드는 `GET /route/plan` 의
|
||||
* `initial_route` 한 곳에서 읽는다. 손으로 고친 노선의 다시 계산은 페이지 [저장]에서(`_Apply` · 346-3) —
|
||||
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. 칸 [초기화]는 없음(418) —
|
||||
* 되돌림은 좌측 아래 [초기화](`design_reset` · 마지막 [적용] 노선). 손으로 고친 노선의 다시 계산은
|
||||
* 페이지 [저장]에서(`_Apply` · 346-3) —
|
||||
* 추천 [적용]만 그 자리에서 서버 노선을 `as_initial` 로 저장한다(초기값 = 추천 노선 · 사용자 확정 · 잠금 막 ·
|
||||
* 끝나면 페이지 다시 세움). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
|
||||
*
|
||||
@@ -17,7 +17,6 @@
|
||||
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
|
||||
import {
|
||||
fetchPrecomputeProgress,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
choiceLabel,
|
||||
DEFAULT_WEIGHTS,
|
||||
NO_WEIGHT_LOCKS,
|
||||
initialRouteSnapshot,
|
||||
normalizeRouteInitial,
|
||||
readInitialChoice,
|
||||
sameBase,
|
||||
@@ -49,7 +47,6 @@ export interface InitialPanelParams {
|
||||
/** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */
|
||||
controls: {
|
||||
recommendButton: HTMLButtonElement;
|
||||
resetButton: HTMLButtonElement;
|
||||
nowLine: HTMLElement;
|
||||
};
|
||||
projectId: string;
|
||||
@@ -62,8 +59,8 @@ export interface InitialPanelParams {
|
||||
}
|
||||
|
||||
export interface InitialPanel {
|
||||
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
|
||||
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
|
||||
/** 노선을 읽었다 — 창을 연 편집값 · 서버가 적어 둔 현재 초기값(`current_initial`). */
|
||||
ready: (opening: RouteEditSnapshot, server?: unknown) => void;
|
||||
/** [저장]에 실어 보낼 지금 추천. */
|
||||
current: () => {
|
||||
mode: RouteInitialLogic;
|
||||
@@ -87,7 +84,7 @@ const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
|
||||
|
||||
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
const { projectId } = params;
|
||||
const { recommendButton, resetButton, nowLine } = params.controls;
|
||||
const { recommendButton, nowLine } = params.controls;
|
||||
|
||||
/** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */
|
||||
let openingChoice = readInitialChoice(projectId);
|
||||
@@ -97,14 +94,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
|
||||
const defaults: Partial<Record<CriteriaKey, number>> = {};
|
||||
let opening: RouteEditSnapshot | null = null;
|
||||
let edited = false;
|
||||
let busy = false;
|
||||
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
|
||||
const weightMemory = {
|
||||
weights: null as RouteInitialWeights | null,
|
||||
locks: { ...NO_WEIGHT_LOCKS },
|
||||
};
|
||||
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
|
||||
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕). 창이 사는 동안 안 바뀐다. */
|
||||
let planRead: Promise<RoutePlanResponse> | null = null;
|
||||
|
||||
function loadPlan(): Promise<RoutePlanResponse> {
|
||||
@@ -119,7 +115,6 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
const now = choice ?? base;
|
||||
nowLine.textContent = `현재 추천: ${choiceLabel(now.logic, now.weights)}`;
|
||||
recommendButton.disabled = busy || !opening;
|
||||
resetButton.disabled = busy || !opening;
|
||||
}
|
||||
|
||||
/** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */
|
||||
@@ -182,46 +177,11 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
})();
|
||||
});
|
||||
|
||||
// [초기화] — 재계산 없이 전처리 때 만든 초기값 노선(예상노선 추적)을 다시 놓는다.
|
||||
resetButton.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
if (busy || !opening) return;
|
||||
busy = true;
|
||||
render();
|
||||
try {
|
||||
const plan = await loadPlan().catch(() => null);
|
||||
// 서버가 초기값 노선을 못 주면, 고친 적 없는 노선일 때만 창을 연 노선이 곧 초기값이다.
|
||||
const snapshot = initialRouteSnapshot(plan?.initial_route) ?? (edited ? null : opening);
|
||||
if (!snapshot) {
|
||||
showToast("전처리 초기값 노선을 읽지 못했습니다.", "error");
|
||||
return;
|
||||
}
|
||||
if (!resetButton.isConnected) return;
|
||||
choice = {
|
||||
logic: "follow",
|
||||
weights: null,
|
||||
snapshot: copy(snapshot),
|
||||
metrics: null,
|
||||
criteria: null,
|
||||
criteriaOverrides: null,
|
||||
};
|
||||
writeInitialChoice(projectId, choice);
|
||||
params.apply(
|
||||
copy(snapshot),
|
||||
"초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [저장]에서.",
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
if (resetButton.isConnected) render();
|
||||
}
|
||||
})();
|
||||
});
|
||||
render();
|
||||
|
||||
return {
|
||||
ready(snapshot, wasEdited, server) {
|
||||
ready(snapshot, server) {
|
||||
opening = copy(snapshot);
|
||||
edited = wasEdited;
|
||||
// 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
|
||||
// 캐시를 버린다.
|
||||
if (server) {
|
||||
|
||||
@@ -233,13 +233,6 @@ function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
/** 서버 `/route/plan` 의 `initial_route`(전처리 초기값 노선)를 편집값으로 편다. 없거나 모자라면 null. */
|
||||
export function initialRouteSnapshot(initialRoute: unknown): RouteEditSnapshot | null {
|
||||
const body = (initialRoute ?? {}) as Record<string, unknown>;
|
||||
if (!Array.isArray(body.nodes) || body.nodes.length < 2) return null;
|
||||
return snapshotOf(body.nodes, body.curves);
|
||||
}
|
||||
|
||||
/** 미리 보기 지도 한 겹 — 점 목록과 종류. */
|
||||
export interface MapLayer {
|
||||
kind: "expected" | "current" | "preview";
|
||||
|
||||
@@ -236,6 +236,32 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
|
||||
1.6,
|
||||
);
|
||||
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
|
||||
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
||||
// 예상노선 뒤 · 노선 앞(414-2) — 노선이 네모 위를 지나감 · 고른 곡선은 `drawPickedCurve` 가 위에.
|
||||
context.save();
|
||||
context.lineWidth = 2;
|
||||
scene.curveInfo.forEach((curve) => {
|
||||
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
|
||||
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
|
||||
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
|
||||
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
|
||||
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
|
||||
const isPicked = curve.node_first === scene.picked;
|
||||
[curve.start, curve.end].forEach((point) => {
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
context.beginPath();
|
||||
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
|
||||
context.rect(x - size, y - size, size * 2, size * 2);
|
||||
context.fillStyle = isPicked
|
||||
? style.getPropertyValue("--map-route") || "#f97316"
|
||||
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
context.fill();
|
||||
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.stroke();
|
||||
});
|
||||
});
|
||||
context.restore();
|
||||
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
|
||||
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
|
||||
strokePolyline(
|
||||
@@ -294,29 +320,6 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
}
|
||||
});
|
||||
|
||||
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
|
||||
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
||||
context.lineWidth = 2;
|
||||
scene.curveInfo.forEach((curve) => {
|
||||
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
|
||||
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
|
||||
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
|
||||
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
|
||||
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
|
||||
const isPicked = curve.node_first === scene.picked;
|
||||
[curve.start, curve.end].forEach((point) => {
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
context.beginPath();
|
||||
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
|
||||
context.rect(x - size, y - size, size * 2, size * 2);
|
||||
context.fillStyle = isPicked
|
||||
? style.getPropertyValue("--map-route") || "#f97316"
|
||||
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
context.fill();
|
||||
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.stroke();
|
||||
});
|
||||
});
|
||||
context.restore();
|
||||
drawPickedCurve(context, scene, style);
|
||||
drawPickHover(context, scene, line, style, NODE_R);
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
PipelineStation,
|
||||
} from "../../A00_Common/design/logic/design_pipeline_types";
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||||
import { stationLabel } from "../../common_util/common_util_station";
|
||||
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
|
||||
|
||||
@@ -212,7 +213,8 @@ export function stationSection(
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */
|
||||
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름)는 그대로 · 측점 · 지반 · 횡단 · 계획선 표본은 새 노선
|
||||
* (십자선 대체 높이도 초안 종단 · 413). */
|
||||
export function draftSectionDetail(
|
||||
saved: SectionDetailResponse,
|
||||
ground: GroundOutput,
|
||||
@@ -242,6 +244,7 @@ export function draftSectionDetail(
|
||||
})),
|
||||
stations: sections.map(({ samples: _s, design: _d, preview: _p, ...head }) => head),
|
||||
profile_alignment: profile,
|
||||
design_profiles: [toDesignProfile(profile, saved.longitudinal.design_profiles?.[0])],
|
||||
},
|
||||
cross_sections: sections,
|
||||
balloon_offsets: saved.balloon_offsets,
|
||||
|
||||
@@ -441,7 +441,7 @@ export function attachDesignLinePick(input: {
|
||||
stationId?: string;
|
||||
/** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */
|
||||
center?: number;
|
||||
/** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */
|
||||
/** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · `bare` 쪽만 · 370-2 · 414-1). */
|
||||
toes?: ReturnType<typeof toePoints>;
|
||||
/** 있으면 쪽마다 노견 끝 = 원지반(절·성토 없음 · 379-1) — 손잡이 값 앞 알림. */
|
||||
bare?: ReturnType<typeof edgeOnGround>;
|
||||
@@ -476,7 +476,10 @@ export function attachDesignLinePick(input: {
|
||||
: 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 toeSides = (["left", "right"] as const).filter((at) => input.toes?.[at]);
|
||||
// 손잡이 = 그 쪽이 절 ↔ 성 바뀜 자리(노견 끝 = 원지반 · `bare`)일 때만(414-1)
|
||||
const toeSides = (["left", "right"] as const).filter(
|
||||
(at) => input.toes?.[at] && input.bare?.[at],
|
||||
);
|
||||
const rockLines = input.rock ? kids.filter((el) => el.classList.contains(ROCK_LINE_CLASS)) : [];
|
||||
const keys = [
|
||||
...pieceKeys(design, pieces),
|
||||
@@ -502,7 +505,7 @@ export function attachDesignLinePick(input: {
|
||||
// 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`)
|
||||
const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ));
|
||||
if (center) hits.push(center);
|
||||
// 교차점 손잡이(370-2) — 쪽마다 원 · 늘 보임 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례)
|
||||
// 교차점 손잡이(370-2) — 바뀜 자리 쪽 원 · 평소 투명 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례)
|
||||
for (const at of toeSides) {
|
||||
const toe = input.toes?.[at] as { offset_m: number; elevation_m: number };
|
||||
const hit = centerHit(x(toe.offset_m), toDisplayY(toe.elevation_m));
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type ZoomPanState,
|
||||
} from "../A00_Common/b_svg_zoom_pan";
|
||||
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import { unscaleAt } from "./B06_Section_UI_Cross_View_Frame";
|
||||
import { detailHomeView, layerPoints } from "./B06_Section_UI_Cross_Detail_Home";
|
||||
import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table";
|
||||
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
@@ -209,16 +210,20 @@ export function attachDetailZoom(
|
||||
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
|
||||
const axisLayer = clone.querySelector<SVGGElement>("g.b06-chart__axes");
|
||||
const renderAxes = axes && axisLayer ? createCrossAxes(axisLayer, axes) : null;
|
||||
// 십자 중심 = 세로 선 x1 · y1 + 18 · 계획 십자가 첫 보기 중심 · 원지반 십자도 크기 되돌림(413)
|
||||
const centerOf = (marker: Element | null): [number, number] => {
|
||||
const line = marker?.querySelector("line");
|
||||
return [Number(line?.getAttribute("x1")), Number(line?.getAttribute("y1")) + 18];
|
||||
};
|
||||
const marker = layer.querySelector<SVGGElement>("g.b06-chart__center");
|
||||
const markerLine = marker?.querySelector("line");
|
||||
const cx = Number(markerLine?.getAttribute("x1"));
|
||||
const cy = Number(markerLine?.getAttribute("y1")) + 18;
|
||||
const [cx, cy] = centerOf(marker);
|
||||
const ground = layer.querySelector<SVGGElement>("g.b06-chart__ground-center");
|
||||
const [gx, gy] = centerOf(ground);
|
||||
const follow = (state: ZoomPanState): void => {
|
||||
renderAxes?.(state);
|
||||
marker?.setAttribute(
|
||||
"transform",
|
||||
`translate(${cx} ${cy}) scale(${1 / Math.max(state.scale, 1e-6)}) translate(${-cx} ${-cy})`,
|
||||
);
|
||||
marker?.setAttribute("transform", unscaleAt(cx, cy, state.scale));
|
||||
ground?.setAttribute("transform", unscaleAt(gx, gy, state.scale));
|
||||
};
|
||||
follow(initial ?? IDENTITY_VIEW);
|
||||
const plot = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* B06_Section_UI_Cross_Detail_Pick.ts
|
||||
* [상세] 수량표 칸 누름 → 그림 복제 안 그 항목 강조(150-4).
|
||||
*
|
||||
* 칸 키 → 그림 요소: 면적 띠(깍기 · 쌓기) · 사토장 선 · 중심 십자(높이 넷) · 보라 판정 선 구역
|
||||
* 칸 키 → 그림 요소: 면적 띠(깍기 · 쌓기) · 사토장 선 · 계획 · 원지반 십자(높이 넷) · 보라 판정 선 구역
|
||||
* (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려
|
||||
* `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리.
|
||||
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖.
|
||||
@@ -16,10 +16,10 @@ import { crossZoneAt, type CrossZone } from "./B06_Section_UI_Cross_Design_Pick"
|
||||
type LineZone = CrossZone | "ditch" | "cut_slope";
|
||||
/** 칸 키 → 강조 대상(선택자 = 그 요소 · zone = 그 구역 판정 선). 없는 키 = 누름 없음. */
|
||||
export const DETAIL_TARGETS: Readonly<Record<string, { selector: string } | { zone: LineZone }>> = {
|
||||
ground: { selector: "g.b06-chart__center" },
|
||||
ground: { selector: "g.b06-chart__ground-center" }, // 원지반 십자(413)
|
||||
planned: { selector: "g.b06-chart__center" },
|
||||
fill: { selector: "g.b06-chart__center" },
|
||||
cut: { selector: "g.b06-chart__center" },
|
||||
fill: { selector: "g.b06-chart__center, g.b06-chart__ground-center" },
|
||||
cut: { selector: "g.b06-chart__center, g.b06-chart__ground-center" },
|
||||
cut_soil: { selector: "g.b06-chart__area--cut_soil" },
|
||||
cut_rock: { selector: "g.b06-chart__area--cut_rock" },
|
||||
embankment: { selector: "g.b06-chart__area--fill" },
|
||||
|
||||
@@ -344,7 +344,9 @@ export function createCrossSectionCard(
|
||||
} else {
|
||||
const { sourceSamples, maxOffset, elevationMid, displayMax, pixelsPerMeter, heightPx } =
|
||||
metrics;
|
||||
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
|
||||
// 십자선 · 계획 상자 = 형상과 한 높이(그 측점 설계 높이 · 413) · 설계 없을 때만 넘겨받은 종단 표본
|
||||
const designZ = section.design?.design_elevation_m ?? designElevation;
|
||||
const hasDesign = designZ !== undefined && Number.isFinite(designZ);
|
||||
const valid = sourceSamples.filter(validElevation);
|
||||
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
// 좌표 규약(+offset=좌, -offset=우)을 표준 횡단면도 관례에 맞춘다: 진행방향을 바라보는
|
||||
@@ -378,7 +380,7 @@ export function createCrossSectionCard(
|
||||
elevationMid,
|
||||
exaggeration,
|
||||
centerZ: section.center_z ?? null,
|
||||
designZ: hasDesign ? (designElevation as number) : null,
|
||||
designZ: hasDesign ? (designZ as number) : null,
|
||||
});
|
||||
renderAxes(cardZoomStates.get(section.station_id) ?? IDENTITY_VIEW);
|
||||
|
||||
@@ -610,7 +612,8 @@ export function createCrossSectionCard(
|
||||
const scaleCenter = appendCardFrame(svg, plotLayer, {
|
||||
valid,
|
||||
centerX: x(0),
|
||||
designElevation: hasDesign ? (designElevation as number) : null,
|
||||
designElevation: hasDesign ? (designZ as number) : null,
|
||||
groundElevation: section.center_z ?? null,
|
||||
toY: toDisplayY,
|
||||
widthPx,
|
||||
heightPx,
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface CardFrameInput {
|
||||
centerX: number;
|
||||
/** 계획고(없으면 null — 옛 자료는 지반고 폴백). */
|
||||
designElevation: number | null;
|
||||
/** 중심 원지반고(`section.center_z` · 없으면 null = 중심에 가장 가까운 지반고). */
|
||||
groundElevation?: number | null;
|
||||
/** 표고 → 화면 y(높이 배율 반영). */
|
||||
toY: (elevation: number) => number;
|
||||
widthPx: number;
|
||||
@@ -34,27 +36,30 @@ export function appendCardFrame(
|
||||
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) return sample;
|
||||
return nearest;
|
||||
}, null);
|
||||
const groundElevation = input.groundElevation ?? centerSample?.elevation_m ?? null;
|
||||
// 십자선 = 계획 노선 자리(구 데이터는 지반고 폴백).
|
||||
const centerElevation = hasDesign ? designElevation : (centerSample?.elevation_m ?? null);
|
||||
const centerElevation = hasDesign ? designElevation : groundElevation;
|
||||
const centerY = centerElevation !== null ? toY(centerElevation) : heightPx / 2;
|
||||
const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`;
|
||||
const marker = svgElement("g", { class: "b06-chart__center" });
|
||||
marker.append(
|
||||
svgElement("line", {
|
||||
x1: centerX,
|
||||
y1: centerY - 18,
|
||||
x2: centerX,
|
||||
y2: centerY + 18,
|
||||
class: centerMarkerClass,
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: centerX - 18,
|
||||
y1: centerY,
|
||||
x2: centerX + 18,
|
||||
y2: centerY,
|
||||
class: centerMarkerClass,
|
||||
}),
|
||||
const cross = (groupClass: string, y: number, lineClass: string): SVGElement => {
|
||||
const group = svgElement("g", { class: groupClass });
|
||||
group.append(
|
||||
svgElement("line", { x1: centerX, y1: y - 18, x2: centerX, y2: y + 18, class: lineClass }),
|
||||
svgElement("line", { x1: centerX - 18, y1: y, x2: centerX + 18, y2: y, class: lineClass }),
|
||||
);
|
||||
return group;
|
||||
};
|
||||
// 원지반 십자선(413) — 계획 십자선보다 먼저(겹치면 보라가 위) · 다른 무리(고름 · 강조 선택자 안 닿음)
|
||||
const groundY = groundElevation !== null ? toY(groundElevation) : null;
|
||||
const ground =
|
||||
hasDesign && groundY !== null
|
||||
? cross("b06-chart__ground-center", groundY, "b06-chart__ground-center-marker")
|
||||
: null;
|
||||
const marker = cross(
|
||||
"b06-chart__center",
|
||||
centerY,
|
||||
`b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`,
|
||||
);
|
||||
if (ground) plotLayer.append(ground);
|
||||
plotLayer.append(marker);
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
@@ -79,12 +84,17 @@ export function appendCardFrame(
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
);
|
||||
// 도형 레이어 배율을 십자선 중심에서 되돌림 — 자리는 따라가고 길이 18px 은 그대로.
|
||||
return (scale) =>
|
||||
marker.setAttribute(
|
||||
"transform",
|
||||
`translate(${centerX} ${centerY}) scale(${1 / Math.max(scale, 1e-6)}) translate(${-centerX} ${-centerY})`,
|
||||
);
|
||||
// 도형 레이어 배율을 십자선 중심에서 되돌림 — 자리는 따라가고 길이 18px 은 그대로(두 십자 모두).
|
||||
return (scale) => {
|
||||
marker.setAttribute("transform", unscaleAt(centerX, centerY, scale));
|
||||
if (ground && groundY !== null)
|
||||
ground.setAttribute("transform", unscaleAt(centerX, groundY, scale));
|
||||
};
|
||||
}
|
||||
|
||||
/** (cx, cy) 를 축으로 배율을 되돌리는 transform — 카드 · [상세] 창 십자선이 같이 씀. */
|
||||
export function unscaleAt(cx: number, cy: number, scale: number): string {
|
||||
return `translate(${cx} ${cy}) scale(${1 / Math.max(scale, 1e-6)}) translate(${-cx} ${-cy})`;
|
||||
}
|
||||
|
||||
/** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */
|
||||
|
||||
@@ -335,13 +335,13 @@ export function createSectionView(
|
||||
applySelection(previous);
|
||||
};
|
||||
|
||||
/** 십자선이 설 계획고 — 세월교 측점은 구체 위 노면이 월류 높이만큼 낮게 앉으므로
|
||||
* 그만큼 내려 찍는다(2026-08-30 사용자 확정). 하강량은 백엔드가 계획고를 내려
|
||||
* 잡으며 실어 준 값(`design.surface_drop_m`)이고, 원래 노면은 점선이 남긴다. */
|
||||
/** 십자선이 설 계획고 = 그 측점 설계 높이(형상과 한 값 · 413). 설계가 없을 때만 종단 표본 —
|
||||
* 세월교 측점은 월류 높이(`design.surface_drop_m`)만큼 내려 찍는다(2026-08-30 사용자 확정). */
|
||||
const cardDesignElevation = (
|
||||
detail: SectionDetailResponse,
|
||||
section: CrossSection,
|
||||
): number | undefined => {
|
||||
if (section.design?.design_elevation_m !== undefined) return section.design.design_elevation_m;
|
||||
const planZ = designElevationAt(detail.longitudinal.design_profiles, section.chainage_m);
|
||||
if (planZ === undefined) return undefined;
|
||||
return planZ - (section.design?.surface_drop_m ?? 0);
|
||||
|
||||
@@ -85,10 +85,17 @@
|
||||
}
|
||||
|
||||
/* 중심점 고름(356-3) — 십자 자리 투명 원 · 고르면 보라 테두리 · 끌면 좌우. 십자는 누름을 원으로 넘김. */
|
||||
.b06-chart__center {
|
||||
.b06-chart__center,
|
||||
.b06-chart__ground-center {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 원지반 십자선(413) = 원지반선 색 · 계획 십자선 아래(먼저 그림) */
|
||||
.b06-chart__ground-center-marker {
|
||||
stroke: var(--color-chart-1);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.b06-chart__center-hit {
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
@@ -102,16 +109,15 @@
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
/* 교차점 손잡이(370-2) — 사면 ∩ 원지반 작은 원 · 늘 보임 · 고르면 채움 · 올리면 같은 모양(379-1). */
|
||||
/* 교차점 손잡이(370-2 · 414-1) — 절 ↔ 성 바뀜 자리 작은 원 · 평소 투명 · 올리거나 고르면 채움. */
|
||||
.b06-chart__toe-hit {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b06-chart__toe-hit:hover,
|
||||
.b06-chart__toe-hit.is-active {
|
||||
fill: var(--color-royal-amethyst);
|
||||
stroke: var(--color-royal-amethyst);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
stroke-width: 9;
|
||||
}
|
||||
|
||||
.b06-detail__cad .b06-chart__ground-center.is-active line,
|
||||
.b06-detail__cad .b06-chart__center.is-active line {
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 3;
|
||||
|
||||
@@ -58,7 +58,9 @@ const matches = (el, selector) => {
|
||||
};
|
||||
function fakeSvg(ppm, x0) {
|
||||
const center = node("g", ["b06-chart__center"]);
|
||||
const ground = node("g", ["b06-chart__ground-center"]); // 원지반 십자(413)
|
||||
const els = [
|
||||
ground,
|
||||
center,
|
||||
node("line", [], { x1: String(x0) }, center),
|
||||
node("g", ["b06-chart__area", "b06-chart__area--cut_soil"]),
|
||||
@@ -104,7 +106,11 @@ const activeOf = (svg) => {
|
||||
if (el.set.has("is-active") && !el.set.has("b06-chart__design-hit"))
|
||||
names.push(
|
||||
[...el.set].find(
|
||||
(c) => c.includes("--") || c === "b06-chart__center" || c === "b06-chart__spoil-fill",
|
||||
(c) =>
|
||||
c.includes("--") ||
|
||||
c === "b06-chart__center" ||
|
||||
c === "b06-chart__ground-center" ||
|
||||
c === "b06-chart__spoil-fill",
|
||||
),
|
||||
);
|
||||
return names;
|
||||
@@ -124,6 +130,8 @@ for (const key of [
|
||||
"embankment",
|
||||
"spoil_fill",
|
||||
"ground",
|
||||
"planned",
|
||||
"cut",
|
||||
"ditch_soil",
|
||||
"grading_cut",
|
||||
"grading_fill",
|
||||
|
||||
@@ -129,14 +129,17 @@ function chart() {
|
||||
const plot = svgElement("g", { class: "b06-chart__plot-layer" });
|
||||
const center = svgElement("g", { class: "b06-chart__center" });
|
||||
center.append(svgElement("line", { x1: 319, y1: 124, x2: 319, y2: 160 }));
|
||||
plot.append(center);
|
||||
// 원지반 십자(413) — 계획 십자보다 먼저 · 중심 319,200
|
||||
const ground = svgElement("g", { class: "b06-chart__ground-center" });
|
||||
ground.append(svgElement("line", { x1: 319, y1: 182, x2: 319, y2: 218 }));
|
||||
plot.append(ground, center);
|
||||
clipGroup.append(plot);
|
||||
svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" }));
|
||||
return { svg, axes, plot, center };
|
||||
return { svg, axes, plot, center, ground };
|
||||
}
|
||||
const ticks = (axes) =>
|
||||
axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent);
|
||||
const { svg, axes, plot: layer, center } = chart();
|
||||
const { svg, axes, plot: layer, center, ground } = chart();
|
||||
let saved;
|
||||
const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next));
|
||||
const ticksBefore = ticks(axes);
|
||||
@@ -194,6 +197,7 @@ out.reset = layer.getAttribute("transform");
|
||||
// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로)
|
||||
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
|
||||
out.centerTransform = center.getAttribute("transform");
|
||||
out.groundTransform = ground.getAttribute("transform");
|
||||
out.max = (() => {
|
||||
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
|
||||
return saved.scale;
|
||||
|
||||
@@ -318,6 +318,88 @@ const out = {};
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1b. 십자선 둘(413) — 원지반 먼저 · 계획 나중 · 배율 되돌림 둘 다 ─────────────
|
||||
{
|
||||
const { appendCardFrame } = makeLoader(
|
||||
[
|
||||
B06("B06_Section_UI_Cross_View_Frame"),
|
||||
B06("B06_Section_UI_Section_Common"),
|
||||
path.join(ROOT, "common_util", "common_util_svg.ts"),
|
||||
],
|
||||
{
|
||||
"@config/config_frontend": { PIPE_TYPES: [] },
|
||||
"../B05_Profile/B05_Profile_UI_IrregularStations": { PIPE_DISPLAY_NAME: {} },
|
||||
"@ui/ui_template_locale": {
|
||||
currentLanguageIndex: 0,
|
||||
ui_locales: new Proxy({}, { get: (_t, key) => [String(key)] }),
|
||||
},
|
||||
},
|
||||
)(B06("B06_Section_UI_Cross_View_Frame"));
|
||||
const toY = (z) => (900 - z) * 10;
|
||||
const frame = (designElevation, groundElevation) => {
|
||||
const plot = new FakeNode("g");
|
||||
const scale = appendCardFrame(new FakeNode("svg"), plot, {
|
||||
valid: [{ offset_m: 0.4, elevation_m: 870, valid: true }],
|
||||
centerX: 50,
|
||||
designElevation,
|
||||
groundElevation,
|
||||
toY,
|
||||
widthPx: 480,
|
||||
heightPx: 300,
|
||||
});
|
||||
return { plot, scale };
|
||||
};
|
||||
const midY = (group) => Number(group.querySelector("line").attrs.y1) + 18;
|
||||
const { plot, scale } = frame(878.5, 876.57);
|
||||
scale(2);
|
||||
const [groundG, designG] = plot.children;
|
||||
const bare = frame(null, null).plot;
|
||||
out.crosshair = {
|
||||
order: plot.children.map((node) => node.className),
|
||||
design_y: midY(designG),
|
||||
want_design_y: toY(878.5),
|
||||
ground_y: midY(groundG),
|
||||
want_ground_y: toY(876.57),
|
||||
design_only_ground: bare.querySelector(".b06-chart__ground-center"),
|
||||
scaled: [groundG.attrs.transform, designG.attrs.transform],
|
||||
want_scaled: [toY(876.57), toY(878.5)].map(
|
||||
(y) => `translate(50 ${y}) scale(0.5) translate(-50 ${-y})`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1c. 평면 초안 상세 = 계획선 표본도 초안 종단(413 · 십자선 대체 높이) ─────────────
|
||||
{
|
||||
const B05 = (name) => path.join(ROOT, "B05_Profile", "logic", `${name}.ts`);
|
||||
const { draftSectionDetail } = makeLoader([
|
||||
B05("B05_Profile_Pipeline_Detail"),
|
||||
B05("B05_Profile_UI_Profile_Data"),
|
||||
])(B05("B05_Profile_Pipeline_Detail"));
|
||||
const draftSamples = [
|
||||
{ chainage_m: 0, elevation_m: 110 },
|
||||
{ chainage_m: 20, elevation_m: 112 },
|
||||
];
|
||||
const saved = {
|
||||
longitudinal: {
|
||||
design_profiles: [
|
||||
{ id: "old", name: "계획선", samples: [{ chainage_m: 0, elevation_m: 100 }] },
|
||||
],
|
||||
},
|
||||
cross_sections: [],
|
||||
balloon_offsets: {},
|
||||
};
|
||||
const detail = draftSectionDetail(
|
||||
saved,
|
||||
{ stations: [], longitudinal: { chainageM: [0, 20], z: [109, 111] } },
|
||||
{ samples: draftSamples, balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 } },
|
||||
null,
|
||||
new Map(),
|
||||
20,
|
||||
);
|
||||
const profile = detail.longitudinal.design_profiles[0];
|
||||
out.draftProfile = { samples: profile.samples, id: profile.id };
|
||||
}
|
||||
|
||||
// ── 2. 아래 띠(318-2 · 318-3) — 중심고 글 없음 · 암 경계 맨 왼쪽 · 토사도 띠 남음 ─────
|
||||
{
|
||||
const control = (cls) => () => {
|
||||
|
||||
@@ -241,7 +241,7 @@ out.stop = {
|
||||
};
|
||||
}
|
||||
|
||||
// ② 교차점 손잡이 — 쪽마다 원 · 늘 보임 · 키 toe:left|right · 고르면 ◀▶ 가 멈춤 자리로 보냄
|
||||
// ② 교차점 손잡이 — 바뀜 자리(노견 끝 = 원지반) 쪽만 원 · 키 toe:left|right · 고르면 ◀▶ 로 절 ↔ 성(414-1)
|
||||
const x = (o) => 300 - o * 20;
|
||||
const y = (e) => 400 - (e - 100) * 20;
|
||||
const attach = (sec, changes) => {
|
||||
@@ -260,21 +260,28 @@ const attach = (sec, changes) => {
|
||||
});
|
||||
return { layer, handle, picks };
|
||||
};
|
||||
const toesOf = (layer) => layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
|
||||
const near = section(crossing[0]); // 보통 단면(우 = 절토 · 바뀜 자리 아님)
|
||||
const nearToes = toesOf(attach(near, []).layer);
|
||||
const changes = [];
|
||||
const near = section(crossing[0]);
|
||||
const { layer, handle, picks } = attach(near, changes);
|
||||
const toes = layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
|
||||
const keys = pick.designLineMoves(near, () => undefined).toes;
|
||||
const bareSec = section(out.stop.value); // 바뀜 자리(우 노견 끝 = 원지반)
|
||||
const { layer, handle, picks } = attach(bareSec, changes);
|
||||
const toes = toesOf(layer);
|
||||
const moves = pick.designLineMoves(bareSec, () => undefined);
|
||||
const keys = moves.toes;
|
||||
const rightToeHit = toes.find((t) => Number(t.getAttribute("cx")) > x(0));
|
||||
out.toe = {
|
||||
nearCount: nearToes.length,
|
||||
nearSides: ["left", "right"].filter((s) => pick.designLineMoves(near, () => undefined).toes[s]),
|
||||
count: toes.length,
|
||||
bareSides: ["left", "right"].filter((s) => keys[s] && moves.bare[s]),
|
||||
centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")),
|
||||
sides: ["left", "right"].filter((s) => keys[s]),
|
||||
rightOnGround:
|
||||
keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9,
|
||||
rightCx: toes.length === 2 ? Number(toes[1].getAttribute("cx")) : null,
|
||||
rightCx: rightToeHit ? Number(rightToeHit.getAttribute("cx")) : null,
|
||||
rightX: keys.right ? x(keys.right.offset_m) : null,
|
||||
};
|
||||
toes[1].click();
|
||||
rightToeHit.click();
|
||||
handle.setActive(picks[0]);
|
||||
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(끝 = 값 글)
|
||||
const cellOf = (h) => ({
|
||||
@@ -287,9 +294,12 @@ const cellOf = (h) => ({
|
||||
});
|
||||
const cell = cellOf(handle);
|
||||
cell.button(x(-1) > x(0) ? "right" : "left").click(); // 화면 → 바깥(우 = −offset)
|
||||
cell.button(x(-1) > x(0) ? "left" : "right").click(); // 화면 → 안쪽
|
||||
out.toePick = {
|
||||
picked: picks,
|
||||
active: toes[1].classList.contains("is-active"),
|
||||
active: rightToeHit.classList.contains("is-active"),
|
||||
modeBefore: bareSec.design.section_mode,
|
||||
modes: changes.map((c) => section(c.shoulder_extend.valueM).design.section_mode),
|
||||
value: cell.value,
|
||||
title: cell.title,
|
||||
panelOpen: !handle.cell.classList.contains("is-empty"),
|
||||
@@ -321,8 +331,10 @@ out.inherit.label = cellOf(after.handle).title;
|
||||
// ⑤ 370-5 — 손잡이를 고른 뒤 넘어간 사면이 원지반과 못 만나면(손잡이 없음) 같은 쪽 첫 사면 선이 이어받음
|
||||
pick.setPickedLine("S1", null);
|
||||
{
|
||||
const again = attach(near, []);
|
||||
again.layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"))[1].click();
|
||||
const again = attach(bareSec, []);
|
||||
toesOf(again.layer)
|
||||
.find((t) => Number(t.getAttribute("cx")) > x(0))
|
||||
.click();
|
||||
pick.setPickedLine("S1", again.picks[0]);
|
||||
let extend = crossing[1];
|
||||
let open = null;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* 418 좌측 아래 [초기화] 헬퍼 — 진짜 `design_reset` · `b_page_state` 를 Node 로 돌려 남은 초안을
|
||||
* 표준출력 JSON 한 벌로. 서버 · 확인창 · 다른 캐시 지우기는 가짜로 막음.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const calls = [];
|
||||
const STUBS = {
|
||||
"@ui/ui_template_elements": {
|
||||
showConfirmDialog: async () => true,
|
||||
showLoadingOverlay: () => {},
|
||||
hideLoadingOverlay: () => {},
|
||||
showToast: (message, kind) => calls.push(["toast", kind]),
|
||||
},
|
||||
"@ui/ui_template_locale": {
|
||||
currentLanguageIndex: 0,
|
||||
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
|
||||
},
|
||||
B05_Profile_Api_Fetch: {
|
||||
resetRouteDesign: async () => calls.push(["resetRouteDesign"]),
|
||||
clearRouteLatestCache: () => {},
|
||||
},
|
||||
B05_Profile_UI_Profile_Edit: { clearAlignmentDrafts: () => {} },
|
||||
B06_Section_Section_Store: { invalidateSectionDetail: () => {} },
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
const load = (file) => {
|
||||
if (cache.has(file)) return cache.get(file);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(file, box.exports);
|
||||
const near = (m) => {
|
||||
const stub = STUBS[m] ?? STUBS[path.basename(m)];
|
||||
if (stub) return stub;
|
||||
if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`));
|
||||
throw new Error(`안 막은 import: ${m}`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, near);
|
||||
return box.exports;
|
||||
};
|
||||
|
||||
class Store {
|
||||
constructor() {
|
||||
this.map = new Map();
|
||||
}
|
||||
get length() {
|
||||
return this.map.size;
|
||||
}
|
||||
key(i) {
|
||||
return [...this.map.keys()][i] ?? null;
|
||||
}
|
||||
getItem(k) {
|
||||
return this.map.has(k) ? this.map.get(k) : null;
|
||||
}
|
||||
setItem(k, v) {
|
||||
this.map.set(k, String(v));
|
||||
}
|
||||
removeItem(k) {
|
||||
this.map.delete(k);
|
||||
}
|
||||
}
|
||||
const session = new Store();
|
||||
globalThis.window = { sessionStorage: session, localStorage: new Store() };
|
||||
globalThis.location = { reload: () => calls.push(["reload"]) };
|
||||
|
||||
const state = load(path.join(ROOT, "A00_Common", "b_page_state.ts"));
|
||||
const { resetDesign } = load(path.join(ROOT, "A00_Common", "design", "design_reset.ts"));
|
||||
|
||||
const P = "p418";
|
||||
const OTHER = "q418";
|
||||
const SEED = {
|
||||
"b08-materials": { units: { a: 1 } },
|
||||
"b08-hydro": { "0+020": { area: 1 } },
|
||||
"b08-options": { soft_rock_pct: 10 },
|
||||
"b08-milestone": { site: "x" },
|
||||
"b09-estimation": { estimate: { b: 2 } },
|
||||
"route-draft": { rev: 3 },
|
||||
"std-cross": { width: 4 },
|
||||
};
|
||||
for (const [name, value] of Object.entries(SEED)) {
|
||||
state.writeState(name, value, P);
|
||||
state.writeState(name, value, OTHER);
|
||||
}
|
||||
state.writeState("crossdesign", { "0+020": "cut" }, P, 7); // 노선 범위 B06 초안
|
||||
|
||||
(async () => {
|
||||
await resetDesign(P, 7);
|
||||
const left = {};
|
||||
for (const name of [...Object.keys(SEED), "crossdesign"]) {
|
||||
left[name] = state.readState(name, P, name === "crossdesign" ? 7 : undefined);
|
||||
}
|
||||
const other = Object.fromEntries(Object.keys(SEED).map((n) => [n, state.readState(n, OTHER)]));
|
||||
process.stdout.write(JSON.stringify({ left, other, seed: SEED, calls }));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -41,7 +41,8 @@ function loadTs(file) {
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
|
||||
if (request.startsWith("@ui/"))
|
||||
return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
@@ -215,18 +216,7 @@ const sameAsServer = {
|
||||
weightedOther: model.sameBase(planShape, bases.weighted),
|
||||
};
|
||||
|
||||
// ⑥ 전처리 초기값 노선(`/route/plan` 의 `initial_route`)과 미리 보기 지도 선.
|
||||
const initialRoute = {
|
||||
ok: model.initialRouteSnapshot({
|
||||
nodes: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 5, radius_m: 20 },
|
||||
],
|
||||
curves: [],
|
||||
}),
|
||||
none: model.initialRouteSnapshot(undefined),
|
||||
tooShort: model.initialRouteSnapshot({ nodes: [{ x: 0, y: 0 }], curves: [] }),
|
||||
};
|
||||
// ⑥ 미리 보기 지도 선.
|
||||
const mapLines = {
|
||||
fit: model.mapPaths(
|
||||
[
|
||||
@@ -548,7 +538,6 @@ process.stdout.write(
|
||||
cleared,
|
||||
bases,
|
||||
sameAsServer,
|
||||
initialRoute,
|
||||
mapLines,
|
||||
historyRecs,
|
||||
sweep,
|
||||
|
||||
@@ -60,7 +60,10 @@ def test_칸마다_그림_강조(out):
|
||||
assert active["cut_rock"] == ["b06-chart__area--cut_rock"]
|
||||
assert active["embankment"] == ["b06-chart__area--fill"]
|
||||
assert "spoil_fill" not in out["targets"] # 사토장 횡단 그림 없음(380-1)
|
||||
assert active["ground"] == ["b06-chart__center"]
|
||||
# 413 — 원지반고 = 원지반 십자 · 계획고 = 계획 십자 · 절성토고 = 둘 다
|
||||
assert active["ground"] == ["b06-chart__ground-center"]
|
||||
assert active["planned"] == ["b06-chart__center"]
|
||||
assert active["cut"] == ["b06-chart__ground-center", "b06-chart__center"]
|
||||
assert active["ditch_soil"] == ["ditch"]
|
||||
assert active["grading_cut"] == ["cutSlope"]
|
||||
assert active["grading_fill"] == ["fillSlope"]
|
||||
|
||||
@@ -56,6 +56,8 @@ def test_축_고정_도형_층만_움직임_눈금_다시(out):
|
||||
assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로
|
||||
# 확대 — 십자는 중심(319,142 · 칸 가운데)에서 배율 되돌림
|
||||
assert out["centerTransform"].startswith("translate(319 142) scale(0.869")
|
||||
# 413 — 원지반 십자도 자기 중심(319,200)에서 배율 되돌림
|
||||
assert out["groundTransform"].startswith("translate(319 200) scale(0.869")
|
||||
assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만
|
||||
assert out["noLayer"] is None # 도형 층 없음 = 줌 없음
|
||||
|
||||
@@ -105,7 +107,7 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸():
|
||||
for selector in (
|
||||
".b06-detail__cad .b06-chart__area.is-active > polygon {\n fill: color-mix(in srgb, var(--color-danger)",
|
||||
".b06-detail__cad .b06-chart__design-hit.is-active {\n stroke: color-mix(in srgb, var(--color-danger)",
|
||||
".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);",
|
||||
".b06-detail__cad .b06-chart__ground-center.is-active line,\n.b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);",
|
||||
".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%",
|
||||
".b06-detail__qtable [data-key][data-empty] {\n cursor: default;",
|
||||
):
|
||||
|
||||
@@ -135,15 +135,43 @@ def test_center_wired_into_card_and_modal() -> None:
|
||||
assert "centerZ: section.center_z ?? null" in _src(
|
||||
"B06_Section", "B06_Section_UI_Section_View.ts"
|
||||
)
|
||||
# 379-3 계획 상자 = 십자선 계획고(카드 designElevation · 모달 cardDesignElevation)
|
||||
assert "designZ: hasDesign ? (designElevation as number) : null" in _src(
|
||||
"B06_Section", "B06_Section_UI_Cross_View.ts"
|
||||
)
|
||||
assert "designZ: cardDesignElevation(detail, section) ?? null" in _src(
|
||||
"B06_Section", "B06_Section_UI_Section_View.ts"
|
||||
# 379-3 계획 상자 = 십자선 계획고(카드 designZ · 모달 cardDesignElevation)
|
||||
# 413 — 십자선 · 계획 상자 = 그 측점 설계 높이(형상과 한 값) · 설계 없을 때만 종단 표본
|
||||
card = _src("B06_Section", "B06_Section_UI_Cross_View.ts")
|
||||
assert "const designZ = section.design?.design_elevation_m ?? designElevation;" in card
|
||||
assert "designZ: hasDesign ? (designZ as number) : null" in card
|
||||
assert "designElevation: hasDesign ? (designZ as number) : null" in card
|
||||
assert "groundElevation: section.center_z ?? null" in card
|
||||
view = _src("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
assert "designZ: cardDesignElevation(detail, section) ?? null" in view
|
||||
assert (
|
||||
"if (section.design?.design_elevation_m !== undefined) return section.design.design_elevation_m;"
|
||||
in view
|
||||
)
|
||||
|
||||
|
||||
def test_two_crosshairs(result: dict) -> None:
|
||||
"""413 — 원지반 십자(원지반 색 · 다른 무리) 먼저 · 계획 십자 나중(위) · 높이 = 설계 · 원지반."""
|
||||
cross = result["crosshair"]
|
||||
assert cross["order"] == ["b06-chart__ground-center", "b06-chart__center"]
|
||||
assert abs(cross["design_y"] - cross["want_design_y"]) < 1e-6 # 설계 높이(종단 표본 아님)
|
||||
assert abs(cross["ground_y"] - cross["want_ground_y"]) < 1e-6 # center_z
|
||||
assert cross["design_only_ground"] is None # 설계 없음 = 십자 하나(지반 폴백)
|
||||
assert cross["scaled"] == cross["want_scaled"] # 둘 다 자기 중심에서 배율 되돌림
|
||||
areas = _src("B06_Section", "B06_Section_UI_Style_Cross_Areas.css")
|
||||
assert "var(--color-chart-1)" in _rule(areas, ".b06-chart__ground-center-marker")
|
||||
|
||||
|
||||
def test_draft_detail_profile_is_draft(result: dict) -> None:
|
||||
"""413 — 평면 초안 상세의 계획선 표본 = 초안 종단(저장 종단 아님) · 이름은 저장분 그대로."""
|
||||
draft = result["draftProfile"]
|
||||
assert draft["samples"] == [
|
||||
{"chainage_m": 0, "elevation_m": 110},
|
||||
{"chainage_m": 20, "elevation_m": 112},
|
||||
]
|
||||
assert draft["id"] == "old"
|
||||
|
||||
|
||||
# ── 318-2 · 318-3 양식 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ def test_칸_자리와_재료():
|
||||
def test_편집기가_칸_요소를_밖에서_받음():
|
||||
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
|
||||
assert "overlay" not in initial
|
||||
assert "const { recommendButton, resetButton, nowLine } = params.controls;" in initial
|
||||
assert (
|
||||
"const { recommendButton, nowLine } = params.controls;" in initial
|
||||
) # 칸 [초기화] 지움(418)
|
||||
assert "current: params.planned().map(([x, y]) => [x, y])," in initial # 지금 노선
|
||||
main = _read("B05_Profile/B05_Profile_UI_RouteEdit.ts")
|
||||
assert "controls: options.recommend," in main
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_넘김_알림은_결과_한_줄에_합침():
|
||||
|
||||
def test_노선_반영_글자_없음():
|
||||
recommend = _read("B05_Profile/B05_Profile_UI_Panel_Recommend.ts")
|
||||
assert "노선 반영" not in recommend and "다시 계산은 [저장]에서" in recommend
|
||||
assert "노선 반영" not in recommend # 칸 [초기화](「다시 계산은 [저장]에서」 글) 지움(418)
|
||||
for rel in (
|
||||
"B05_Profile/B05_Profile_UI_RouteEdit.ts",
|
||||
"B05_Profile/B05_Profile_UI_RouteEdit_History.ts",
|
||||
|
||||
@@ -61,7 +61,7 @@ def test_반올림이_교차_앞이어도_다음_걸음은_넘어감(out) -> Non
|
||||
|
||||
|
||||
def test_손잡이_없는_넘어감도_고름_유지(out) -> None:
|
||||
"""370-5 — 넘어간 사면이 원지반과 못 만나 손잡이가 사라져도 같은 쪽 사면 선 · 암 칸으로 안 넘어감."""
|
||||
"""370-5 — 바뀜 자리 손잡이를 고른 뒤 사면이 원지반과 못 만나도 같은 쪽 사면 선 · 암 칸으로 안 넘어감."""
|
||||
got = out["noToe"]
|
||||
assert got["toeGone"] is True
|
||||
assert got["index"] is not None
|
||||
@@ -70,23 +70,28 @@ def test_손잡이_없는_넘어감도_고름_유지(out) -> None:
|
||||
assert got["buttons"] == ["left", "reset", "right"] # ◀↺▶ 남음
|
||||
|
||||
|
||||
def test_교차점_손잡이_쪽마다_늘_보임(out) -> None:
|
||||
def test_교차점_손잡이는_바뀜_자리만(out) -> None:
|
||||
"""414-1 — 원 = 그 쪽 노견 끝 = 원지반(±5 mm · `bare`)일 때만 · 보통 단면은 원 0."""
|
||||
toe = out["toe"]
|
||||
assert toe["count"] == len(toe["sides"]) == 2
|
||||
assert toe["nearSides"] == ["left", "right"] # 사면 ∩ 원지반 자리는 있어도
|
||||
assert toe["nearCount"] == 0 # 보통 단면(절 · 성 정해짐) = 원 0
|
||||
assert toe["count"] == len(toe["bareSides"]) == 1 and toe["bareSides"] == ["right"]
|
||||
assert toe["centerClass"] is True # 중심점 원과 같은 고르기 차례
|
||||
assert toe["rightOnGround"] is True
|
||||
assert abs(toe["rightCx"] - toe["rightX"]) < 1e-9
|
||||
|
||||
|
||||
def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None:
|
||||
def test_손잡이를_고르면_좌우로_절성토_넘김(out) -> None:
|
||||
"""414-1 — 바뀜 자리 원을 고르면 ▶(바깥) = 성토 · ◀(안쪽) = 절토."""
|
||||
got = out["toePick"]
|
||||
assert got["active"] is True and got["panelOpen"] is True
|
||||
assert got["title"] == "사면 이동 (우)" # 아래 띠 공용 칸(370-3)
|
||||
assert got["value"].startswith("노선에서 ")
|
||||
assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right"
|
||||
assert got["value"].startswith("절·성토 없음 · 노선에서 ")
|
||||
assert [c["side"] for c in got["sent"]] == ["right", "right"]
|
||||
low, high = out["stop"]["crossing"]
|
||||
assert abs(got["sent"][0]["valueM"] - out["stop"]["value"]) < 1e-9 # ◀▶ 도 멈춤 자리
|
||||
assert low < got["sent"][0]["valueM"] < high
|
||||
outward, inward = (c["valueM"] for c in got["sent"])
|
||||
assert outward >= high and inward <= low # 멈춤 자리에서 다시 안 섬
|
||||
assert got["modes"] == ["left_cut", "both_cut"] # 우 = 성토 → 절토
|
||||
|
||||
|
||||
def test_절에서_성으로_넘어가도_고름_유지(out) -> None:
|
||||
|
||||
@@ -23,12 +23,23 @@ def test_노드는_라벨_뒤_맨_위_회전_안():
|
||||
scene = render[render.index("export function drawRouteEditScene(") :]
|
||||
scene = scene[: scene.index("context.restore(); // 회전 끝")]
|
||||
labels = scene.index("drawContourLabels(context, scene, band, style);")
|
||||
for later in ("scene.planned.forEach(", "scene.curveInfo.forEach(", "drawPickHover("):
|
||||
for later in ("scene.planned.forEach(", "drawPickedCurve(", "drawPickHover("):
|
||||
assert labels < scene.index(later), later
|
||||
for earlier in ("drawStationMarks(", "scene.overStations?.(", "drawMeasureMarks("):
|
||||
assert scene.index(earlier) < labels, earlier
|
||||
|
||||
|
||||
def test_곡선_네모는_예상노선_뒤_노선_앞():
|
||||
"""414-2 — 모든 곡선 BC · EC 네모 = 예상노선 뒤 · 노선 앞(노선이 네모 위를 지나감)."""
|
||||
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
|
||||
scene = render[render.index("export function drawRouteEditScene(") :]
|
||||
expected = scene.index("scene.expected,")
|
||||
squares = scene.index("scene.curveInfo.forEach(")
|
||||
route = scene.index("strokePolyline(", squares)
|
||||
assert expected < squares < route
|
||||
assert scene[route:].split(",")[2].strip() == "line"
|
||||
|
||||
|
||||
def test_집기_맴돔_한_함수():
|
||||
pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
|
||||
assert "pickAtScreen(" in pointer
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""418 초기화 정리(요구 254-3 · 253-6).
|
||||
|
||||
- 「노선 추천」 칸 [초기화] 없음 · 그 단추만 쓰던 길(편집기 handler · `initial_route`) 없음
|
||||
- 좌측 아래 [초기화] — 저장 안 한 B08 · B09 초안 남김 · B05 · B06 초안만 지움(진짜 TS)
|
||||
- 추천 [적용] → 고쳐 저장 → 아래 [초기화] = [적용]한 노선(진짜 `refresh_initial_snapshot`)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
restore_snapshot_files,
|
||||
save_initial_snapshot,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).with_name("helper_418_reset_drafts.cjs")
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_노선_추천_칸에_초기화_단추_없음():
|
||||
part = _read("B05_Profile/B05_Profile_UI_Panel_Recommend.ts")
|
||||
assert 'data-act="reset"' not in part and "resetButton" not in part
|
||||
assert part.count('data-act="recommend" disabled') == 1
|
||||
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
|
||||
assert "resetButton" not in initial and "initialRouteSnapshot" not in initial
|
||||
assert "initial_route" not in _read("B05_Profile/B05_Profile_Api_Replan.ts")
|
||||
replan = _read("B05_Profile/B05_Profile_Router_Replan.py")
|
||||
assert '"initial_route"' not in replan and "_initial_shape" not in replan
|
||||
|
||||
|
||||
def test_아래_초기화는_B08_B09_초안_남김():
|
||||
out = json.loads(
|
||||
subprocess.run(
|
||||
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
|
||||
).stdout
|
||||
)
|
||||
seed, left = out["seed"], out["left"]
|
||||
for name in ("b08-materials", "b08-hydro", "b08-options", "b08-milestone", "b09-estimation"):
|
||||
assert left[name] == seed[name], name
|
||||
# B05 · B06 초안(프로젝트 범위 · 노선 범위)은 지움
|
||||
assert left["route-draft"] is None and left["std-cross"] is None
|
||||
assert left["crossdesign"] is None
|
||||
# 다른 프로젝트 초안은 안 건드림
|
||||
assert out["other"] == seed
|
||||
assert [c[0] for c in out["calls"]] == ["resetRouteDesign", "toast", "reload"]
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self):
|
||||
self.lastrowid = 0
|
||||
self._rows = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, params=None):
|
||||
self._rows = [{"id": 7, "project_id": "p1"}] if "FROM routes" in sql else []
|
||||
|
||||
async def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
async def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _Connection:
|
||||
def cursor(self, *_args, **_kwargs):
|
||||
return _Cursor()
|
||||
|
||||
|
||||
class _Pool:
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield _Connection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path: Path) -> Path:
|
||||
(tmp_path / "B05_Profile/route").mkdir(parents=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_route(root: Path, tag: str) -> None:
|
||||
(root / "B05_Profile/route/planned_route.csv").write_text(f"x,y\n{tag}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _route(root: Path) -> str:
|
||||
return (root / "B05_Profile/route/planned_route.csv").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_추천_적용_뒤_고쳐도_아래_초기화는_적용_노선(project, monkeypatch):
|
||||
from B05_Profile import B05_Profile_Router_RouteInitial as initial_router
|
||||
|
||||
monkeypatch.setattr(initial_router, "get_db_pool", lambda: _Pool())
|
||||
# ① 자동설계 체인 직후 초기값
|
||||
_write_route(project, "auto")
|
||||
asyncio.run(save_initial_snapshot(_Connection(), project, 7))
|
||||
# ② 추천 [적용](as_initial) — 그 노선으로 초기값 다시 뜸
|
||||
_write_route(project, "applied")
|
||||
asyncio.run(initial_router.refresh_initial_snapshot(uuid4(), project, 7))
|
||||
# ③ 손으로 고쳐 [저장] — 초기값 그대로(as_initial 아님 · 스냅샷 안 부름)
|
||||
_write_route(project, "edited")
|
||||
# ④ 아래 [초기화] = 초기값 파일 복원
|
||||
restore_snapshot_files(project)
|
||||
assert _route(project) == "x,y\napplied\n"
|
||||
# ⑤ 다른 추천으로 바꿔 [적용] → 그것이 새 초기화 자리
|
||||
_write_route(project, "applied2")
|
||||
asyncio.run(initial_router.refresh_initial_snapshot(uuid4(), project, 7))
|
||||
_write_route(project, "edited2")
|
||||
restore_snapshot_files(project)
|
||||
assert _route(project) == "x,y\napplied2\n"
|
||||
@@ -146,13 +146,12 @@ def test_캐시는_결과_통_프로젝트_칸(results):
|
||||
|
||||
|
||||
def test_전처리_초기값_노선을_편집값으로(results):
|
||||
snap = results["initialRoute"]["ok"]
|
||||
assert snap["planned"] == [[0, 0], [10, 5]]
|
||||
assert snap["curveRadius"] == [None, 20]
|
||||
assert snap["picked"] == -1
|
||||
# 서버가 안 주거나 노드가 모자라면 null — 화면이 [초기화]를 안 한다.
|
||||
assert results["initialRoute"]["none"] is None
|
||||
assert results["initialRoute"]["tooShort"] is None
|
||||
"""칸 [초기화]만 쓰던 전처리 초기 노선 펴기는 걷음(418) — 되돌림은 좌측 아래 [초기화] 하나."""
|
||||
assert "initialRoute" not in results
|
||||
model = os.path.join(
|
||||
HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"
|
||||
)
|
||||
assert "initialRouteSnapshot" not in open(model, encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_미리_보기_지도_선(results):
|
||||
@@ -177,8 +176,7 @@ def test_도구_막대는_노선_추천_하나만():
|
||||
assert "연 상태로" not in chrome and "history-reset" not in chrome
|
||||
part = open(os.path.join(page, "B05_Profile_UI_Panel_Recommend.ts"), encoding="utf-8").read()
|
||||
assert part.count('data-act="recommend" disabled') == 1 # 단추 하나
|
||||
assert part.count('data-act="reset" disabled') == 1
|
||||
assert part.index('data-act="recommend" disabled') < part.index('data-act="reset" disabled')
|
||||
assert 'data-act="reset"' not in part # 칸 [초기화] 지움(418)
|
||||
initial = open(
|
||||
os.path.join(page, "B05_Profile_UI_RouteEdit_Initial.ts"), encoding="utf-8"
|
||||
).read()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
① `POST /route/initial` → 비교값이 다 있음
|
||||
② 화면 코드로 [확인] 몸통을 만들어 `POST /route/replan` → 서버가 initial 을 적음
|
||||
③ `GET /route/plan` → 현재 초기값 = 보낸 것 · 다시 연 노드 = 보낸 노드
|
||||
④ [초기화] — 서버 `initial_route`(전처리 초기값 노선)를 놓음 · 재계산 없음
|
||||
④ 칸 [초기화]용 `initial_route` 는 없음(418) — 되돌림은 좌측 아래 [초기화](초기값 스냅샷)
|
||||
화면 쪽 셈은 `helper_b05_routeedit_initial_apply.cjs` 가 화면 코드 그대로 돌린다.
|
||||
|
||||
화면 제약 칸(종단기울기 · 최소 반지름 · 계류 이격 · 지반 고름, 계획서 20 확정)에 겹친 값도
|
||||
@@ -267,19 +267,11 @@ def test_다시_연_노선은_확인한_노선(rounds):
|
||||
|
||||
|
||||
def test_초기화는_전처리_초기값_노선으로(rounds):
|
||||
"""[초기화] 는 재계산 없이 `GET /route/plan` 의 `initial_route` 를 놓는다 — 무엇을 확인했든
|
||||
같은 노선이고, 확인 전(수정본 없음) 노드와 같다."""
|
||||
before = rounds["before"]
|
||||
assert before["edited"] is False
|
||||
first = [[n["x"], n["y"]] for n in before["nodes"]]
|
||||
"""칸 [초기화] 지움(418) — `GET /route/plan` 이 전처리 초기 노선(`initial_route`)을 안 준다."""
|
||||
assert rounds["before"]["edited"] is False
|
||||
for item in rounds["rounds"]:
|
||||
assert item["plan"]["edited"] is True
|
||||
initial = item["plan"]["initial_route"]
|
||||
assert len(initial["nodes"]) >= 2 and isinstance(initial["curves"], list)
|
||||
assert _close([[n["x"], n["y"]] for n in initial["nodes"]], first), item["logic"]
|
||||
# 확인한 마지막 노선은 초기값과 다르다 — 되돌림이 실제로 노선을 바꾼다.
|
||||
last = rounds["rounds"][-1]
|
||||
assert not _close(last["reopened"]["planned"], first)
|
||||
assert "initial_route" not in item["plan"], item["logic"]
|
||||
|
||||
|
||||
def test_제약_겹침도_확인_뒤_그대로(api, rounds):
|
||||
|
||||
@@ -100,7 +100,9 @@ def test_진행_단계_두_칸이_보기를_따라감():
|
||||
def test_초기화_계획노선_편집은_다시_세움():
|
||||
"""[초기화] · [노선 반영](옛 계획노선 편집 [확인]) = 새 초기값 — 옛 캐시를 다 버리고 페이지를 통째로 다시 읽는다(PLAN 77)."""
|
||||
reset = _read("A00_Common/design/design_reset.ts")
|
||||
assert "clearDrafts(projectId);" in reset and "clearResults(projectId);" in reset
|
||||
assert (
|
||||
'namesInBucket("draft")' in reset and "clearResults(projectId);" in reset
|
||||
) # B08 · B09 초안 남김(418)
|
||||
assert "location.reload()" in reset
|
||||
view = _read("A00_Common/design/design_view_profile.ts")
|
||||
assert "location.reload()" not in view # [노선 반영]은 새로고침 대신 다시 세움(345-3)
|
||||
|
||||
Reference in New Issue
Block a user