feat(B05 노선 추천 창): 측점 맞춤 → 종단 반영 단추 · [재계산] 늘 켜짐 + recompute · [적용] 균형 종단 = 단추 값 (421)

- 「종단 반영」 단추(기본 켬 · 다섯 갈래 · 누르면 바로 셈) · 요청에 with_profile 늘 실음 · 답 with_profile 먼저
- [재계산] = 입력 오류 · 셈 중 빼고 늘 켜짐 · recompute: true 보냄(서버 420 짝)
- [적용] · [저장] 의 initial 에 with_profile · 다시 열 때 단추 되살림 · 옛 기록은 갈래로
- 맞추기 전 선(unsnapped) 화면에서 걷음 · 도움말 글(측점 맞춤 늘 · 종단 반영 · 재계산)
- 시험 test_421_recommend_profile · 383_3 · 383_1 · 351_2 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-18 12:32:41 +09:00
co-authored by Claude Opus 5.5
parent 9b560dc241
commit 67de77c48c
11 changed files with 253 additions and 74 deletions
@@ -42,6 +42,8 @@ export interface RouteInitialRequest {
criteria?: RouteInitialCriteria;
/** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */
with_profile?: boolean;
/** [재계산] — 이 입력의 저장본을 지우고 새로 셈(421 · 서버 420). */
recompute?: boolean;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
@@ -28,13 +28,13 @@ import {
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
choiceWithProfile,
DEFAULT_WEIGHTS,
NO_WEIGHT_LOCKS,
normalizeRouteInitial,
readInitialChoice,
sameBase,
serverBase,
withProfileOf,
writeInitialChoice,
type CriteriaKey,
type InitialBase,
@@ -66,9 +66,10 @@ export interface InitialPanel {
mode: RouteInitialLogic;
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
with_profile: boolean;
};
/** 지금 노선이 종단을 반영하는가 — 지금 추천 갈래가 정한다(예상노선 추적만 아님 · PLAN 77-2).
* 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 갈래대로). */
/** 지금 노선이 종단을 반영하는가 — 지금 추천의 「종단 반영」 값(421 · 옛 기록이면 갈래로).
* 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 값대로). */
withProfile: () => boolean;
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */
tag: () => InitialChoice | null;
@@ -123,14 +124,16 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(weights ? { weights } : {}),
...(criteria ? { criteria } : {}),
...(withProfile ? {} : { with_profile: false }),
with_profile: withProfile,
...(recompute ? { recompute: true } : {}),
});
return normalizeRouteInitial(response, logic, weights, criteria);
return normalizeRouteInitial(response, logic, weights, criteria, withProfile);
}
recommendButton.addEventListener("click", () => {
@@ -148,6 +151,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
weightMemory,
logic: (choice ?? base).logic,
// 적용한 추천의 값 · 없으면 켬(421 — 옛 기록의 예상노선 추적도 켬에서 시작)
withProfile: choice?.withProfile ?? base.withProfile ?? true,
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: params.planned().map(([x, y]) => [x, y]),
@@ -169,8 +174,9 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
mode: picked.logic,
weights: picked.weights,
criteria: picked.criteriaOverrides,
with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420)
},
withProfileOf(picked.logic),
choiceWithProfile(picked),
);
},
});
@@ -195,9 +201,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
current: () => {
const now = choice ?? base;
const criteria = choice ? choice.criteriaOverrides : base.criteria;
return { mode: now.logic, weights: now.weights, criteria };
return {
mode: now.logic,
weights: now.weights,
criteria,
with_profile: choiceWithProfile(now),
};
},
withProfile: () => withProfileOf((choice ?? base).logic),
withProfile: () => choiceWithProfile(choice ?? base),
tag: () => choice,
restoreTag(tag) {
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
@@ -27,9 +27,15 @@ export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
weighted: "비중 계산",
};
/** 종단을 늘 반영하는 갈래 — 예상노선 추적만 종단 없음(전처리 계획선 = 지반고 · PLAN 77-2). */
/** 옛 꼴(종단 반영 값 없음)의 갈래별 종단 — 예상노선 추적만 종단 없음(PLAN 77-2). */
export const withProfileOf = (logic: RouteInitialLogic): boolean => logic !== "follow";
/** 이 추천의 종단 반영 — 추천 창 「종단 반영」 단추 값(421) · 옛 캐시 · 옛 서버 답이면 갈래로. */
export const choiceWithProfile = (choice: {
logic: RouteInitialLogic;
withProfile?: boolean | null;
}): boolean => choice.withProfile ?? withProfileOf(choice.logic);
/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */
export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, grade: 25 };
export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const;
@@ -197,8 +203,8 @@ export interface InitialChoice {
line?: Vertex[] | null;
/** MC 가 일반측점 밖인 곡선 수(계획서 351-2) — 서버 `off_station_curves`. 옛 답이면 null. */
offStationCurves?: number | null;
/** 측점 맞춤 끔 = 맞추기 전 선(계획서 351-2) — 모달에만 · 캐시에는 안 적는다. 같으면 null. */
unsnapped?: InitialChoice | null;
/** 추천 창 「종단 반영」 값(421) — [적용] 균형 종단 · 다시 열 때 단추. 옛 캐시면 없음. */
withProfile?: boolean;
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
@@ -292,6 +298,7 @@ export function normalizeRouteInitial(
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteriaOverrides: RouteInitialCriteria | null = null,
withProfile?: boolean,
): InitialChoice {
// 모양은 계획서 20 「API」 줄 — 노드 · 곡선은 `/route/plan` 과 같은 꼴이다.
const body = (response ?? {}) as Record<string, unknown>;
@@ -339,15 +346,8 @@ export function normalizeRouteInitial(
bandWidthM: num(body.band_width_m),
stored: body.stored === true,
offStationCurves: num(body.off_station_curves),
unsnapped:
body.unsnapped && typeof body.unsnapped === "object"
? normalizeRouteInitial(
{ ...body, ...(body.unsnapped as Record<string, unknown>), unsnapped: null },
logic,
weights,
criteriaOverrides,
)
: null,
// 서버가 셈에 쓴 값이 먼저(420) · 옛 서버면 보낸 값
withProfile: typeof body.with_profile === "boolean" ? body.with_profile : withProfile,
balance: parseBalance(m.profile),
line: Array.isArray(body.planned)
? (body.planned as unknown[][]).map((xy): Vertex => [Number(xy[0]), Number(xy[1])])
@@ -372,6 +372,8 @@ export interface InitialBase {
weights: RouteInitialWeights | null;
/** 사용자가 그때 화면 칸에 넣었던 제약 겹침만(비운 칸은 안 담김). */
criteria: RouteInitialCriteria | null;
/** [적용] 때 「종단 반영」(421 · 서버 `with_profile`) — 옛 기록이면 없음. */
withProfile?: boolean;
}
/** 겹침 값 원시 객체를 편다 — 유한수인 칸만 담는다. 빈 결과는 null. */
@@ -399,7 +401,8 @@ export function serverBase(server: unknown): InitialBase {
? (body.logic as RouteInitialLogic)
: "follow";
const criteria = parseCriteriaOverrides(body.criteria);
if (logic !== "weighted") return { logic, weights: null, criteria };
const withProfile = typeof body.with_profile === "boolean" ? body.with_profile : undefined;
if (logic !== "weighted") return { logic, weights: null, criteria, withProfile };
const raw = (body.weights ?? {}) as Record<string, unknown>;
const weights = {
expected: Math.round(Number(raw.expected)),
@@ -407,7 +410,7 @@ export function serverBase(server: unknown): InitialBase {
grade: Math.round(Number(raw.grade)),
};
const valid = WEIGHT_KEYS.every((key) => Number.isFinite(weights[key]));
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria };
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria, withProfile };
}
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중 · 같은 제약 겹침인가. */
@@ -419,6 +422,9 @@ export function sameBase(choice: InitialChoice, base: InitialBase): boolean {
) {
return false;
}
// 서버가 종단 반영을 적어 둔 때만 견줌(옛 기록은 갈래 · 비중 · 제약만)
if (base.withProfile !== undefined && choiceWithProfile(choice) !== base.withProfile)
return false;
return criteriaEqual(choice.criteriaOverrides, base.criteria);
}
@@ -576,10 +582,10 @@ export function readInitialChoice(projectId: string): InitialChoice | null {
}
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
// 균형 종단 · 서버 선 · 맞추기 전 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
// 균형 종단 · 서버 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
writeState(
"routeedit-initial",
choice && { ...choice, balance: undefined, line: undefined, unsnapped: undefined },
choice && { ...choice, balance: undefined, line: undefined },
projectId,
);
}
@@ -3,7 +3,8 @@
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 ·
* 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 ·
* 못 지킨 구간 · 오른쪽 위 [적용] · [닫기].
* 종단은 갈래가 정한다 — 예상노선 추적만 종단 없음, 나머지는 늘 균형 종단(`withProfileOf`).
* 종단은 「종단 반영」 단추가 정한다(421 · 기본 켬 · 다섯 갈래) — 누르면 바로 다시 셈 · [적용] 균형 종단도 이 값.
* [재계산]은 늘 켜짐(입력 오류 · 셈 중 빼고) — 서버 저장본을 지우고 새로 셈(`recompute`).
*
* 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 노선 편집의 노선은
* 건드리지 않는다 — [적용]을 누르면 `onApply` 로 바로 서버 저장(새 초기값 · 346-3), [닫기]는
@@ -40,7 +41,6 @@ import {
violationsLine,
violationText,
WEIGHT_KEYS,
withProfileOf,
type CriteriaKey,
type InitialChoice,
type InitialCriteria,
@@ -67,7 +67,7 @@ type Vertex = [number, number];
const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const;
const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 · 종단 없음(계획고 = 지반고)",
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 + 종단(종단 반영 끄면 계획고 = 지반고)",
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단",
earthwork: "절토 · 성토가 가장 적은 노선 + 종단",
gentle: "종단기울기가 가장 낮은 노선 + 종단",
@@ -121,6 +121,8 @@ export interface RecommendParams {
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
/** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */
logic: RouteInitialLogic;
/** 「종단 반영」 처음 값 — 적용한 추천의 값 · 없으면 켬(421). */
withProfile: boolean;
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
@@ -131,6 +133,7 @@ export interface RecommendParams {
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
onApply: (choice: InitialChoice) => void;
@@ -148,10 +151,8 @@ export function openRecommendModal(params: RecommendParams): void {
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
const invalid = new Set<WeightKey>();
let preview: InitialChoice | null = null;
/** 측점 맞춤(계획서 351-2) — 켬 = 곡선 MC 를 일반측점에 맞춘 선 · 끔 = 맞추기 전 선. */
let snapOn = true;
const shownChoice = (): InitialChoice | null =>
preview && !snapOn ? (preview.unsnapped ?? preview) : preview;
/** 종단 반영(421) — 켬 = 후보마다 균형 종단 · 끔 = 계획고 = 지반고. */
let profileOn = params.withProfile;
let busy = false;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
let dirty = false;
@@ -307,13 +308,14 @@ export function openRecommendModal(params: RecommendParams): void {
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
const snapToggle = toggleButton("측점 맞춤", snapOn, (active) => {
snapOn = active;
render();
const profileToggle = toggleButton("종단 반영", profileOn, (active) => {
profileOn = active;
void run(); // 누르면 바로 — 저장본 있으면 그것
});
snapToggle.dataset.act = "snap";
snapToggle.title = "곡선 가운데(MC)를 일반측점(20 m) 위에 — 끄면 맞추기 전 선";
rerun.before(snapToggle);
profileToggle.dataset.act = "profile";
profileToggle.title = "켬 = 후보마다 측점 횡단으로 균형 종단 · 끔 = 계획고 = 지반고";
rerun.title = "지금 칸 값으로 서버 저장본을 지우고 새로 셈";
rerun.before(profileToggle);
const stateEl = q<HTMLElement>("[data-state]");
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
@@ -345,9 +347,9 @@ export function openRecommendModal(params: RecommendParams): void {
{ kind: "expected", points: params.expected },
{ kind: "current", points: params.current },
];
// 서버가 짠 원호 그대로(351-2 · 측점 맞춤 켬끔) — 옛 답이면 꺾임점
const view = shownChoice();
if (view) layers.push({ kind: "preview", points: view.line ?? view.snapshot.planned });
// 서버가 짠 원호 그대로(351-2) — 옛 답이면 꺾임점
if (preview)
layers.push({ kind: "preview", points: preview.line ?? preview.snapshot.planned });
map?.setLayers(layers);
}
@@ -397,9 +399,12 @@ export function openRecommendModal(params: RecommendParams): void {
input.disabled = busy;
});
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
// 측점 맞춤 오른쪽 · 늘 보임 — 바꾼 것이 있을 때만 켜짐(383-3)
rerun.disabled = busy || !dirty || invalid.size > 0;
const view = shownChoice();
// 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421)
rerun.disabled = busy || invalid.size > 0;
profileToggle.disabled = busy;
profileToggle.setAttribute("aria-pressed", String(profileOn));
profileToggle.classList.toggle("is-active", profileOn);
const view = preview;
applyButton.disabled = busy || dirty || !view || invalid.size > 0;
const shown = metricsLines(view?.metrics ?? null);
line("metrics").textContent = view
@@ -442,7 +447,8 @@ export function openRecommendModal(params: RecommendParams): void {
drawMap();
}
async function run(): Promise<void> {
/** `recompute` = [재계산] — 이 입력의 서버 저장본을 지우고 새로 셈. */
async function run(recompute = false): Promise<void> {
if (logic === "weighted" && invalid.size > 0) {
showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error");
return;
@@ -450,13 +456,13 @@ export function openRecommendModal(params: RecommendParams): void {
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
const withProfile = withProfileOf(logic);
const withProfile = profileOn;
busy = true;
dirty = false;
preview = null;
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
const started = Date.now();
const label = choiceLabel(logic, sentWeights);
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}`;
/** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */
let behind = "";
const tick = (): void => {
@@ -486,6 +492,7 @@ export function openRecommendModal(params: RecommendParams): void {
sentWeights,
Object.keys(criteria).length ? criteria : null,
withProfile,
recompute,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
@@ -497,7 +504,7 @@ export function openRecommendModal(params: RecommendParams): void {
preview = choice;
if (choice.balance) seen.set(label, choice.balance);
else seen.delete(label); // 종단 없는 갈래(예상노선 추적)
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)}${
stateEl.textContent = `미리 보기: ${label}${
choice.stored ? " (저장된 결과)" : ""
} — [적용]하면 바로 저장 · 새 초기값이 됩니다.`;
} catch (error) {
@@ -574,11 +581,10 @@ export function openRecommendModal(params: RecommendParams): void {
render();
});
});
rerun.addEventListener("click", () => void run());
rerun.addEventListener("click", () => void run(true));
applyButton.addEventListener("click", () => {
const view = shownChoice(); // 보이는 쪽(측점 맞춤 켬끔)
if (!view) return;
params.onApply(view);
if (!preview) return;
params.onApply(preview);
closed = true;
close();
});
@@ -30,12 +30,12 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
how: [
"새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음",
"전처리 초기 계획노선과 같은 규칙 — 「측점 맞춤」을 끄면 같은 선",
"전처리 초기 계획노선과 같은 규칙 · 곡선 가운데(MC)는 일반측점(20m) 위로 맞춤",
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
],
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인",
profile: "없음 — 전처리 계획선처럼 계획고 = 지반고",
profile: "켬 = 균형 종단 · 끔 = 전처리 계획선처럼 계획고 = 지반고",
},
gradeline: {
summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선",
@@ -83,7 +83,7 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
{
name: "종단 반영",
text: "예상노선 추적을 뺀 네 갈래는 늘 반영. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨",
text: "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두) — 누르면 바로 다시 셈(저장본 있으면 그것). 끄면 계획고 = 지반고로 흙 양만 셈. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨",
},
{
name: "종단기울기 상한(%)",
@@ -102,8 +102,12 @@ const CRITERIA_HELP: Array<{ name: string; text: string }> = [
text: "기울기를 잴 때 지형 잔굴곡을 고르는 폭. 클수록 매끈한 지반으로 봄. 비우면 5m",
},
{
name: "측점 맞춤",
text: "켬(기본) = 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 밀고 반지름을 맞추며, 같은 쪽으로 도는 이웃 곡선은 큰 곡선 하나로 합침(S자 사이 직선은 남김). 미는 거리 한도는 없고 탐색 복도 · 계류 이격 · 기울기 · 토공이 나빠지지 않는 자리만(예상노선 추적은 6m 안). 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 다시 셈. 끔 = 옮기기 전 선. [적용]은 보이는 쪽",
name: "측점 맞춤(늘)",
text: "모든 갈래에서 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 밀고 반지름을 맞추며, 같은 쪽으로 도는 이웃 곡선은 큰 곡선 하나로 합침(S자 사이 직선은 남김). 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 셈",
},
{
name: "재계산",
text: "지금 칸 값으로 서버 저장본을 지우고 새로 셈(덮어쓰기 아님) — 셈하는 동안 진행 줄이 보임. [적용]한 노선 · [초기화] 자리는 안 바뀜",
},
];
@@ -409,7 +409,7 @@
align-self: flex-start;
}
/* 측점 맞춤 · 재계산 한 줄(383-3) */
/* 종단 반영 · 재계산 한 줄(421) */
.b05-routerec__row {
display: flex;
gap: var(--spacing-8);
@@ -0,0 +1,91 @@
/* 421 추천 창 「종단 반영」 셈 헬퍼 — `_Initial_Model` 을 그 자리에서 트랜스파일해 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 cache = new Map();
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
_map: map,
};
}
const session = memoryStorage();
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
globalThis.fetch = () => Promise.resolve({ ok: false });
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
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 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(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
const answer = {
nodes: [
{ x: 0, y: 0 },
{ x: 10, y: 5 },
],
curves: [],
};
const fromSent = model.normalizeRouteInitial(answer, "earthwork", null, null, false);
const fromServer = model.normalizeRouteInitial(
{ ...answer, with_profile: true, unsnapped: { planned: [[0, 0]] } },
"earthwork",
null,
null,
false,
);
const oldFollow = model.normalizeRouteInitial(answer, "follow", null);
const oldEarth = model.normalizeRouteInitial(answer, "earthwork", null);
const base = model.serverBase({ logic: "gentle", with_profile: false });
const oldBase = model.serverBase({ logic: "gentle" });
model.writeInitialChoice("p421", fromSent);
const readBack = model.readInitialChoice("p421");
process.stdout.write(
JSON.stringify({
sent: fromSent.withProfile,
server: fromServer.withProfile,
serverHasUnsnapped: "unsnapped" in fromServer,
oldFollow: model.choiceWithProfile(oldFollow),
oldEarth: model.choiceWithProfile(oldEarth),
base: base.withProfile,
oldBase: oldBase.withProfile ?? null,
baseProfile: model.choiceWithProfile(base),
oldBaseProfile: model.choiceWithProfile(oldBase),
sameOff: model.sameBase({ ...fromSent, logic: "gentle" }, base),
sameOn: model.sameBase({ ...fromServer, logic: "gentle" }, base),
sameOld: model.sameBase({ ...fromServer, logic: "gentle" }, oldBase),
readBack: readBack && readBack.withProfile,
}),
);
+5 -12
View File
@@ -142,11 +142,6 @@ def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None:
if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음)
assert result["metrics"]["over_limit"]["total"] == 0
assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6
# 측점 맞춤 끔 = 맞추기 전 선(같으면 None) — 비교값 따로
raw = result["unsnapped"]
if raw is not None:
assert raw["planned"] != result["planned"] and raw["metrics"]["length_m"] > 0
assert set(raw) == {"planned", "nodes", "curves", "off_station_curves", "metrics"}
def _project_terrain():
@@ -171,7 +166,7 @@ def _project_terrain():
def test_project_modes_before_after() -> None:
"""검증_sub1 실지형(14 % · R12) — 갈래별 측점 맞춤 전후 넘은 곳 · 최대 기울기 · 측점 밖 곡선."""
"""검증_sub1 실지형(14 % · R12) — 갈래별 맞춘 선의 넘은 곳 · 최대 기울기 · 측점 밖 곡선(421)."""
if not (PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz").is_file():
pytest.skip("검증_sub1 storage 없음")
terrain, streams = _project_terrain()
@@ -189,17 +184,15 @@ def test_project_modes_before_after() -> None:
min_radius_m=12.0,
with_profile=False,
)
raw = result["unsnapped"] or result
after, before = result["metrics"], raw["metrics"]
after = result["metrics"]
curves = len(result["curves"])
on_total += curves - result["off_station_curves"]
curves_total += curves
print(
f"{mode}: 넘은 곳 {before['over_limit']['total']} → {after['over_limit']['total']}"
f" · 최대 기울기 {before['max_grade_pct']:.1f} → {after['max_grade_pct']:.1f} %"
f" · 곡선 {len(raw['curves'])} → {curves}(측점 밖 {result['off_station_curves']})"
f"{mode}: 넘은 곳 {after['over_limit']['total']}"
f" · 최대 기울기 {after['max_grade_pct']:.1f} %"
f" · 곡선 {curves}(측점 밖 {result['off_station_curves']})"
)
assert raw["off_station_curves"] >= result["off_station_curves"]
print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%")
assert on_total / curves_total >= 0.75
@@ -100,7 +100,6 @@ def test_follow_nodes_equal_initial(tmp_path: Path) -> None:
assert all(abs(a[0] - b[0]) <= 1e-3 and abs(a[1] - b[1]) <= 1e-3 for a, b in zip(nodes, saved))
misses = [v for v in result["metrics"]["violations"] if v["kind"] == "station"]
assert len(misses) == result["metrics"]["over_limit"]["station"] == result["off_station_curves"]
assert result["unsnapped"] is not None # 「측점 맞춤」 끔 = 맞추기 전 선
def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
+4 -4
View File
@@ -1,4 +1,4 @@
"""383-3 노선 추천 모달 [재계산] — 측점 맞춤 오른쪽 같은 줄 · 늘 보임 · 바꾼 것 있을 때만 켜짐.
"""383-3 노선 추천 모달 [재계산] — 종단 반영 오른쪽 같은 줄 · 늘 보임 · 늘 켜짐(421).
./venv/Scripts/python.exe -m pytest resources/tester/test_383_3_rerun_row.py -q
"""
@@ -10,11 +10,11 @@ ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
def test_재계산_한_줄_늘_보임_바꿨을_때만_켜짐() -> None:
def test_재계산_한_줄_늘_보임_늘_켜짐() -> None:
ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
assert re.search(r'<div class="b05-routerec__row"><button[^>]*data-act="rerun">재계산', ts)
assert 'data-act="rerun" hidden' not in ts and "rerun.hidden" not in ts
assert "rerun.before(snapToggle)" in ts # 토글이 같은 줄 왼쪽
assert "rerun.disabled = busy || !dirty || invalid.size > 0;" in ts
assert "rerun.before(profileToggle)" in ts # 종단 반영 토글이 같은 줄 왼쪽(421)
assert "rerun.disabled = busy || invalid.size > 0;" in ts
css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8")
assert re.search(r"\.b05-routerec__row \{\s*display: flex;", css)
@@ -0,0 +1,67 @@
"""421 추천 창 — 「측점 맞춤」 → 「종단 반영」 · [재계산] 늘 켜짐 + `recompute`.
./venv/Scripts/python.exe -m pytest resources/tester/test_421_recommend_profile.py -q
"""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_421_profile_toggle.cjs")
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def test_종단_반영_값_정하기() -> None:
out = json.loads(
subprocess.run(
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
).stdout
)
assert out["sent"] is False # 옛 서버 = 보낸 값
assert out["server"] is True # 서버 답 `with_profile` 이 먼저
assert out["serverHasUnsnapped"] is False # 맞추기 전 선은 화면이 안 씀
assert out["oldFollow"] is False and out["oldEarth"] is True # 옛 캐시 = 갈래로
assert out["base"] is False and out["baseProfile"] is False
assert out["oldBase"] is None and out["oldBaseProfile"] is True
assert out["sameOff"] is True and out["sameOn"] is False and out["sameOld"] is True
assert out["readBack"] is False # 캐시에 남아 다시 열 때 되살림
def test_창_단추와_재계산() -> None:
rec = _read("B05_Profile_UI_RouteEdit_Recommend.ts")
assert "측점 맞춤" not in rec and "snapOn" not in rec and "unsnapped" not in rec
assert 'toggleButton("종단 반영", profileOn,' in rec
assert "let profileOn = params.withProfile;" in rec
toggle = rec[rec.index('toggleButton("종단 반영"') :][:200]
assert "profileOn = active;" in toggle and "void run();" in toggle # 누르면 바로
assert "rerun.before(profileToggle);" in rec
assert "rerun.disabled = busy || invalid.size > 0;" in rec # 늘 켜짐
assert 'rerun.addEventListener("click", () => void run(true));' in rec
assert "const withProfile = profileOn;" in rec
assert "params.onApply(preview);" in rec
def test_요청과_적용에_종단_반영() -> None:
init = _read("B05_Profile_UI_RouteEdit_Initial.ts")
assert "with_profile: withProfile," in init
assert "...(recompute ? { recompute: true } : {})," in init
assert "withProfile: choice?.withProfile ?? base.withProfile ?? true," in init
on_apply = init[init.index("onApply: (picked) => {") :][:900]
assert "with_profile: choiceWithProfile(picked)," in on_apply
assert on_apply.count("choiceWithProfile(picked)") == 2 # 저장 칸 · 균형 종단
assert "withProfileOf(" not in init
assert "with_profile: choiceWithProfile(now)" in init # [저장]에도 실음
api = _read("B05_Profile_Api_RouteInitial.ts")
assert "recompute?: boolean;" in api
def test_도움말_글() -> None:
help_ts = _read("B05_Profile_UI_RouteEdit_Recommend_Help.ts")
assert "「측점 맞춤」을 끄면" not in help_ts and "끔 = 옮기기 전 선" not in help_ts
assert 'name: "재계산",' in help_ts and 'name: "측점 맞춤(늘)",' in help_ts
assert "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두)" in help_ts