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

This commit is contained in:
2026-10-06 23:15:02 +09:00
13 changed files with 309 additions and 34 deletions
@@ -40,6 +40,8 @@ export interface RouteInitialRequest {
mode: RouteInitialLogic;
weights?: RouteInitialWeights;
criteria?: RouteInitialCriteria;
/** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */
with_profile?: boolean;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
+19 -8
View File
@@ -64,7 +64,11 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
similar,
weighted,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import attach_profile, cover_shortfall
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
attach_profile,
cover_shortfall,
plain_metrics,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CORRIDOR_M,
ROAD_WIDTH_M,
@@ -219,6 +223,7 @@ def generate_initial_route(
target_grade: float | None = None,
design_speed: int = 20,
pipes: list[tuple[float, float, float]] | None = None,
with_profile: bool = True,
) -> dict[str, Any]:
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
@@ -228,6 +233,7 @@ def generate_initial_route(
후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창
추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
`with_profile` 끄면(종단 반영 끔) 종단 없이 63-4 전 점수(원지반 가늠)로 고른다.
"""
if mode not in MODES:
raise ValueError(f"모르는 갈래: {mode}")
@@ -303,12 +309,16 @@ def generate_initial_route(
if target == "weighted":
outline, metrics = weighted(pool, used, min_radius_m, measure)
else:
for route in [*pool.candidates, pool.follow]:
profiled(route)
outline, metrics = pick(target, pool.candidates)
profiled((outline, metrics))
if pipes and "error" not in metrics["profile"]:
metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes))
if with_profile:
for route in [*pool.candidates, pool.follow]:
profiled(route)
outline, metrics = pick(target, pool.candidates, with_profile)
if with_profile:
profiled((outline, metrics))
if pipes and "error" not in metrics["profile"]:
metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes))
else:
metrics = plain_metrics(metrics)
return {
"mode": mode,
"logic": mode,
@@ -321,7 +331,8 @@ def generate_initial_route(
"curves": [curve.as_dict() for curve in outline.curves],
"metrics": metrics,
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
"similar": None if pool is None else similar(pool, target, outline),
"similar": None if pool is None else similar(pool, target, outline, with_profile),
"with_profile": with_profile,
"band_width_m": None if pool is None else round(pool.band_width_m, 1),
"criteria": {
"max_grade_pct": round(max_grade * 100.0, 2),
@@ -36,7 +36,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_EARTH_M3,
COST_LENGTH_M,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CROSSING_BAND,
OBJECTIVES,
@@ -213,12 +213,15 @@ def build_pool(
return pool
def similar(pool: Pool, mode: str, outline: Any) -> list[dict[str, Any]]:
def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]:
"""이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m).
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다."""
here = LineString(outline.vertices)
others = {"follow": pool.follow, **{name: pick(name, pool.candidates) for name in GOALS}}
others = {
"follow": pool.follow,
**{name: pick(name, pool.candidates, with_profile) for name in GOALS},
}
found = []
for name, (other, _) in others.items():
if name == mode or other is None:
@@ -284,7 +287,9 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
}
def pick(mode: str, candidates: list[Route]) -> Route:
"""갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보."""
def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route:
"""갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보. `with_profile` 끔 = 원지반 가늠 점수."""
goal = GOALS[mode]
return min(candidates, key=lambda item: goal(item[1]))
return min(
candidates, key=lambda item: goal(item[1] if with_profile else plain_metrics(item[1]))
)
@@ -366,6 +366,7 @@ def attach_profile(
metrics["profile"] = {"error": str(error)}
return
estimate = metrics["cut_m3"] + metrics["fill_m3"]
metrics["cost_index_base"] = metrics["cost_index"] # 「종단 반영」 끔이 되돌아갈 값
metrics["profile"] = profile
metrics["earth_estimate_m3"] = round(estimate, 1)
metrics["cost_index"] = round(
@@ -376,3 +377,19 @@ def attach_profile(
def profile_earth(profile: dict[str, Any]) -> float:
"""균형 종단 토공 점수 — 절토(자연) + 성토 + 반입 + 사토 ㎥."""
return profile["cut_m3"] + profile["fill_m3"] + profile["borrow_m3"] + profile["spoil_m3"]
def plain_metrics(metrics: dict[str, Any]) -> dict[str, Any]:
"""균형 종단을 붙이기 전 비교값(복사본) — 「종단 반영」 끔(원지반 가늠 점수).
묶음 캐시의 비교값은 종단을 붙인 채 남으므로, 끌 때는 붙인 것을 걷어 낸다."""
if "profile" not in metrics and "cost_index_base" not in metrics:
return metrics
plain = {
key: value
for key, value in metrics.items()
if key not in ("profile", "earth_estimate_m3", "cost_index_base")
}
if "cost_index_base" in metrics:
plain["cost_index"] = metrics["cost_index_base"]
return plain
+2
View File
@@ -118,6 +118,8 @@ class RouteInitialRequest(BaseModel):
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
with_profile: bool = True
class RouteReplanRequest(BaseModel):
@@ -80,7 +80,9 @@ async def initial_route(
terrain, streams = await asyncio.to_thread(
load_terrain_and_streams, project_root, selection
)
pipes = await asyncio.to_thread(project_pipes, project_root)
pipes = (
await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None
)
result = await asyncio.to_thread(
functools.partial(
generate_initial_route,
@@ -96,6 +98,7 @@ async def initial_route(
target_grade=None if target_pct is None else target_pct / 100.0,
design_speed=design_speed or 20,
pipes=pipes,
with_profile=request.with_profile,
)
)
except (FileNotFoundError, ValueError) as error:
@@ -91,6 +91,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
weights: null as RouteInitialWeights | null,
locks: { ...NO_WEIGHT_LOCKS },
};
/** 추천 「종단 반영」 — 이 편집 창이 사는 동안만 남는다(저장 · 캐시 · [취소] 되돌림 없이 창과 함께 사라짐). */
const profileMemory = { withProfile: true };
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
let planRead: Promise<RoutePlanResponse> | null = null;
@@ -114,11 +116,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(weights ? { weights } : {}),
...(criteria ? { criteria } : {}),
...(withProfile ? {} : { with_profile: false }),
});
return normalizeRouteInitial(response, logic, weights, criteria);
}
@@ -137,6 +141,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
defaults,
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
weightMemory,
profileMemory,
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: opening!.planned.map(([x, y]) => [x, y]),
@@ -45,7 +45,11 @@ import {
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
import { recommendHelpHtml, syncRecommendHelp } from "./B05_Profile_UI_RouteEdit_Recommend_Help";
import {
bindRecommendHelp,
recommendHelpHtml,
syncRecommendHelp,
} from "./B05_Profile_UI_RouteEdit_Recommend_Help";
import {
balanceLine,
candidatesTable,
@@ -128,6 +132,8 @@ export interface RecommendParams {
weights: RouteInitialWeights;
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
/** 「종단 반영」 — 켬이 기본(지금 동작). 편집 창이 사는 동안만 남고 저장하지 않는다. */
profileMemory: { withProfile: boolean };
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
@@ -137,6 +143,7 @@ export interface RecommendParams {
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 편집 창에 놓는다. */
onApply: (choice: InitialChoice) => void;
@@ -211,6 +218,11 @@ export function openRecommendModal(params: RecommendParams): void {
</div>
<span class="b05-routerec__sum" data-weight-sum></span>
</div>
<label class="b05-routerec__profile-opt" data-profile-opt
title="켬 = 후보마다 균형 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(더 빠름)">
<input type="checkbox" data-with-profile /> 종단 반영
<small>끄면 원지반만 · 이 창 안에서만</small>
</label>
<div class="b05-routerec__criteria">
${CRITERIA_KEYS.map(
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
@@ -255,6 +267,9 @@ export function openRecommendModal(params: RecommendParams): void {
const stateEl = q<HTMLElement>("[data-state]");
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
const profileBox = q<HTMLInputElement>("[data-with-profile]");
profileBox.checked = params.profileMemory.withProfile;
bindRecommendHelp(body);
criteriaInputs.forEach((input) => {
const value =
@@ -332,6 +347,7 @@ export function openRecommendModal(params: RecommendParams): void {
input.disabled = busy;
});
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
profileBox.disabled = busy;
rerun.hidden = !dirty;
rerun.disabled = busy || invalid.size > 0;
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
@@ -380,6 +396,7 @@ export function openRecommendModal(params: RecommendParams): void {
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
const withProfile = params.profileMemory.withProfile;
busy = true;
dirty = false;
preview = null;
@@ -388,7 +405,9 @@ export function openRecommendModal(params: RecommendParams): void {
const label = choiceLabel(logic, sentWeights);
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
stateEl.textContent = `${label} 셈하는 중… ${seconds}초 (후보마다 균형 종단을 셈합니다)`;
stateEl.textContent = `${label} 셈하는 중… ${seconds}초${
withProfile ? " (후보마다 균형 종단을 셈합니다)" : " (종단 반영 끔 — 원지반만)"
}`;
};
window.clearInterval(ticker);
tick();
@@ -399,6 +418,7 @@ export function openRecommendModal(params: RecommendParams): void {
logic,
sentWeights,
Object.keys(criteria).length ? criteria : null,
withProfile,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
@@ -409,6 +429,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)} — [적용]을 눌러야 편집 창에 놓입니다.`;
} catch (error) {
if (closed || mine !== seq) return;
@@ -492,6 +513,11 @@ export function openRecommendModal(params: RecommendParams): void {
render();
});
});
profileBox.addEventListener("change", () => {
params.profileMemory.withProfile = profileBox.checked;
dirty = preview !== null;
render();
});
rerun.addEventListener("click", () => void run());
applyButton.addEventListener("click", () => {
if (!preview) return;
@@ -1,9 +1,10 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Help.ts
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법.
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2 · 64-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법.
*
* 글은 서버 엔진(`B05_Profile_Engine_RouteInitial*`)이 실제로 하는 일을 따라 적는다 — 엔진을
* 바꾸면 여기 글도 같이 고친다. 고른 갈래는 강조하고 보이는 자리로 굴린다.
* 바꾸면 여기 글도 같이 고친다. 갈래 설명은 **한 번에 한 갈래**(쪽) — 위 ◀ ▶ 단추와 「n / 전체」로
* 넘기고, 고른 갈래(알약)를 바꾸면 그 갈래 쪽으로 간다.
* ========================================================================== */
import type { RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
@@ -18,6 +19,8 @@ interface HelpEntry {
when: string;
/** 주의할 점. */
caution: string;
/** 「종단 반영」 켬 · 끔이 점수에 미치는 영향(토공 점수를 쓰는 갈래만). */
profile?: string;
}
const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
@@ -51,6 +54,8 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
],
when: "토공량 · 사토 처리를 줄여야 할 때",
caution: "비탈면 흙은 빠진 어림값 · 연장이 길어지는지 비교값으로 함께 볼 것",
profile:
"켬 = 후보마다 균형 종단을 세워 절 + 성 + 반입 + 사토 ㎥ 로 비교 · 끔 = 종단 없이 원지반 절 + 성 어림으로 비교(더 빠름)",
},
gentle: {
summary: "종단기울기가 가장 순한 노선",
@@ -90,6 +95,8 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
],
when: "토공 · 연장 · 구조물 수를 한꺼번에 비교하고 싶을 때",
caution: "실제 원가가 아닌 상대값(임시 비율) — 갈래끼리 비교용으로만 볼 것",
profile:
"켬 = 토공 몫을 균형 종단 토량으로 셈 · 끔 = 원지반 절 + 성 어림으로 셈(종단을 세우지 않음)",
},
weighted: {
summary: "예상 · 토공 · 기울기 세 비중(합 100)을 섞어 찾은 노선",
@@ -104,6 +111,10 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
};
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
{
name: "종단 반영",
text: "켬(기본) = 후보마다 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(종단 그림 · 덮개 줄은 안 나옴). 이 창 안에서만 적용되고 [적용]한 노선 · 저장값에는 남지 않음",
},
{
name: "종단기울기 상한(%)",
text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음",
@@ -152,24 +163,35 @@ const LOGIC_ORDER: RouteInitialLogic[] = [
"weighted",
];
/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */
export function recommendHelpHtml(): string {
const logics = LOGIC_ORDER.map((key) => {
const entry = LOGIC_HELP[key];
return `<article class="b05-routerec__help-item" data-help-logic="${key}">
<h4>${LOGIC_LABEL[key]}</h4>
/** 갈래 설명 쪽 하나 — 고른 갈래(`is-on`)는 테두리로 알린다. */
function logicPage(key: RouteInitialLogic): string {
const entry = LOGIC_HELP[key];
const profile = entry.profile ? `<p><b>종단 반영</b> ${entry.profile}</p>` : "";
return `<article class="b05-routerec__help-item" data-help-logic="${key}" hidden>
<h4>${LOGIC_LABEL[key]} <span class="b05-routerec__help-picked">고른 갈래</span></h4>
<p class="b05-routerec__help-summary">${entry.summary}</p>
<ul>${entry.how.map((line) => `<li>${line}</li>`).join("")}</ul>
<p><b>이럴 때</b> ${entry.when}</p>
${profile}
<p class="b05-routerec__help-caution"><b>주의</b> ${entry.caution}</p>
</article>`;
}).join("");
}
/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */
export function recommendHelpHtml(): string {
const list = (items: Array<{ name: string; text: string }>): string =>
`<dl>${items.map((item) => `<dt>${item.name}</dt><dd>${item.text}</dd>`).join("")}</dl>`;
return `<aside class="b05-routerec__help" aria-label="노선 추천 설명">
<h3>추천 갈래</h3>
<p class="b05-routerec__help-lead">제약을 지키는 후보 노선 여러 벌을 찾은 뒤, 갈래마다 자기 기준이 가장 좋은 것을 고름.</p>
${logics}
<div class="b05-routerec__help-pager" role="group" aria-label="갈래 설명 쪽 넘기기">
<button type="button" class="ui-btn ui-btn--ghost" data-help-nav="-1"
aria-label="앞 갈래 설명">◀</button>
<span data-help-page aria-live="polite"></span>
<button type="button" class="ui-btn ui-btn--ghost" data-help-nav="1"
aria-label="다음 갈래 설명">▶</button>
</div>
${LOGIC_ORDER.map(logicPage).join("")}
<h3>공통 제약</h3>
<p class="b05-routerec__help-lead">예상노선 추적 · 비중 계산을 뺀 갈래는 넘지 않음 · 칸을 비우면 기본값.</p>
${list(CRITERIA_HELP)}
@@ -178,11 +200,39 @@ export function recommendHelpHtml(): string {
</aside>`;
}
/** 고른 갈래 설명을 강조하고 보이는 자리로 굴린다. */
export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic): void {
body.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
const on = item.dataset.helpLogic === logic;
if (on && !item.classList.contains("is-on")) item.scrollIntoView({ block: "nearest" });
item.classList.toggle("is-on", on);
/** 설명 쪽을 `index` 번째 갈래로 — 쪽 수는 갈래 수. */
function showHelpPage(aside: HTMLElement, index: number): void {
const page = Math.min(Math.max(index, 0), LOGIC_ORDER.length - 1);
aside.dataset.page = String(page);
aside.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
item.hidden = item.dataset.helpLogic !== LOGIC_ORDER[page];
});
aside.querySelector<HTMLElement>("[data-help-page]")!.textContent =
`${page + 1} / ${LOGIC_ORDER.length}`;
aside.querySelector<HTMLButtonElement>('[data-help-nav="-1"]')!.disabled = page === 0;
aside.querySelector<HTMLButtonElement>('[data-help-nav="1"]')!.disabled =
page === LOGIC_ORDER.length - 1;
aside.scrollTop = 0;
}
/** ◀ ▶ 단추 — 모달 본문에 한 번만 건다. */
export function bindRecommendHelp(body: HTMLElement): void {
body.addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLElement>("[data-help-nav]");
const aside = body.querySelector<HTMLElement>(".b05-routerec__help");
if (!button || !aside) return;
showHelpPage(aside, Number(aside.dataset.page ?? 0) + Number(button.dataset.helpNav));
});
}
/** 고른 갈래를 표시하고, 고른 갈래가 바뀐 때만 그 갈래 쪽으로 간다(넘겨 보던 쪽은 그대로 둠). */
export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic): void {
const aside = body.querySelector<HTMLElement>(".b05-routerec__help");
if (!aside) return;
aside.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
item.classList.toggle("is-on", item.dataset.helpLogic === logic);
});
if (aside.dataset.pickedLogic === logic) return;
aside.dataset.pickedLogic = logic;
showHelpPage(aside, LOGIC_ORDER.indexOf(logic));
}
@@ -82,7 +82,7 @@
.b05-routerec__help-item h4 {
margin: 0 0 2px;
font-size: var(--text-body-sm);
font-size: var(--text-body);
font-weight: 700;
}
@@ -90,6 +90,49 @@
color: var(--color-primary);
}
/* 「고른 갈래」 표시 — 지금 알약으로 고른 갈래의 쪽에만 보임. */
.b05-routerec__help-picked {
display: none;
margin-left: var(--spacing-4, 4px);
font-size: var(--text-caption, 11px);
font-weight: 600;
}
.b05-routerec__help-item.is-on .b05-routerec__help-picked {
display: inline;
}
/* 쪽 넘김 — ◀ n / 전체 ▶ */
.b05-routerec__help-pager {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-12);
margin-bottom: var(--spacing-8);
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.b05-routerec__help-item[hidden] {
display: none;
}
.b05-routerec__profile-opt {
display: flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-body);
cursor: pointer;
}
.b05-routerec__profile-opt input {
accent-color: var(--color-primary);
}
.b05-routerec__profile-opt small {
color: var(--color-text-secondary);
}
.b05-routerec__help-item p,
.b05-routerec__help dd {
margin: 2px 0;
@@ -0,0 +1,73 @@
"""추천 「종단 반영」 켜기 · 끄기(PLAN 64-1) — 점수 분기 시험.
./venv/Scripts/python.exe -m pytest resources/tester/test_64_1_profile_toggle.py -q
켬(기본) = 후보마다 균형 종단을 세워 토공 점수를 그것으로(63-4) · 끔 = 종단 없이 원지반 가늠
(절 + 성). 끈 요청은 켠 요청이 묶음 캐시에 남긴 종단을 걷어 내고 처음부터 끈 것과 같아야 한다.
"""
import numpy as np
import pytest
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import COST_EARTH_M3
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
from B05_Profile.B05_Profile_Router_Replan import RouteInitialRequest
GRADE = 0.14
RADIUS = 12.0
@pytest.fixture(scope="module")
def hill() -> Terrain:
x = np.arange(0.0, 802.0, 2.0)
y = np.arange(0.0, 402.0, 2.0)
xx, yy = np.meshgrid(x, y)
z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
def _run(terrain: Terrain, mode: str, with_profile: bool) -> dict:
expected = [(float(x), 200.0) for x in np.arange(50.0, 752.0, 4.0)]
return generate_initial_route(
expected,
terrain,
[],
mode=mode,
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
with_profile=with_profile,
)
@pytest.mark.parametrize("mode", ["earthwork", "cost", "follow"])
def test_켬은_종단_끔은_원지반(hill: Terrain, mode: str) -> None:
on = _run(hill, mode, True)
off = _run(hill, mode, False)
assert on["with_profile"] is True and off["with_profile"] is False
assert "error" not in on["metrics"]["profile"]
assert "profile" not in off["metrics"] and "earth_estimate_m3" not in off["metrics"]
# 끔 점수 = 절 + 성 · 켬 점수 = 균형 종단 토공
assert GOALS["earthwork"](off["metrics"]) == (
off["metrics"]["cut_m3"] + off["metrics"]["fill_m3"],
)
assert GOALS["earthwork"](on["metrics"]) == (profile_earth(on["metrics"]["profile"]),)
# 공사비 어림 = 켬은 토공 몫을 종단 점수로 바꾼 값(고른 노선이 달라도 그 노선의 처음 값 기준)
metrics = on["metrics"]
shift = COST_EARTH_M3 * (profile_earth(metrics["profile"]) - metrics["earth_estimate_m3"])
assert metrics["cost_index"] == pytest.approx(metrics["cost_index_base"] + shift, abs=1.0)
def test_끈_요청은_묶음_캐시의_종단을_걷어_낸다(hill: Terrain) -> None:
first = _run(hill, "cost", False)
_run(hill, "cost", True) # 같은 묶음 캐시에 종단을 붙임
again = _run(hill, "cost", False)
assert again["metrics"] == first["metrics"] and again["planned"] == first["planned"]
assert "profile" not in again["metrics"]
def test_요청_기본은_켬이고_끔을_받는다() -> None:
assert RouteInitialRequest(mode="earthwork").with_profile is True
assert RouteInitialRequest(mode="earthwork", with_profile=False).with_profile is False
@@ -0,0 +1,18 @@
/* 64-1 — B05 노선 추천 「종단 반영」 켬 · 끔 점수 비교
* 시작 화면: B05 종단설계 → [계획노선 편집] → [노선 추천] 열림 · 검증 프로젝트(검증_sub1)
* 부르는 법: 실행 뒤 `window.__r` 를 읽음(끝나면 채워짐)
* 기대 수치: 끔 = 균형 종단 줄 · 종단 그림 · 덮개 줄 없음 · 토공 최소 절 + 성 = 원지반 가늠 ·
* 켬 = 균형 종단 줄 있음 · 토공 최소 노선 달라짐(연장 1,156.7 → 1,153.2 m) · 공사비 어림 6,206 → 18,606
*/
(()=>{window.__r=undefined;(async()=>{
const w=ms=>new Promise(r=>setTimeout(r,ms));
const q=s=>document.querySelector(s);
const waitDone=async()=>{const t0=Date.now();await w(400);for(;;){await w(300);const s=q('[data-state]').textContent;if(s.includes('미리 보기:')||s.includes('셈하지 못')) return (Date.now()-t0)/1000;if(Date.now()-t0>280000) return -1}};
const snap=()=>({m:q('[data-line=metrics]').textContent,w:q('[data-line=metrics-work]').textContent,bal:q('[data-line=balance]').textContent,prof:!q('[data-line=profile]').hidden,cands:q('[data-line=candidates]').textContent.length});
const setBox=v=>{if(q('[data-with-profile]').checked!==v) q('[data-with-profile]').click()};
const out={};
for(const l of ['earthwork','cost','gentle']){
setBox(false); q('[data-logic='+l+']').click(); const t1=await waitDone(); out[l+'_off']={t:t1,...snap()};
setBox(true); q('[data-act=rerun]').click(); const t2=await waitDone(); out[l+'_on']={t:t2,...snap()};
}
window.__r=out;})();return 'started'})()
@@ -0,0 +1,20 @@
/* 64-2 — B05 노선 추천 창 오른쪽 설명 = 한 갈래씩 ◀ ▶ 쪽 넘김
* 시작 화면: B05 종단설계 → [계획노선 편집] → [노선 추천] (첫 미리 보기가 뜬 뒤) · 검증 프로젝트(검증_sub1)
* 기대 수치: 쪽 수 = 갈래 수 = 8 · 한 번에 한 쪽만 보임 · 처음 쪽 = 고른 갈래 · 1 / 8 에서 ◀ 꺼짐 ·
* 8 / 8 에서 ▶ 꺼짐 · 다른 칸을 만져 다시 그려도 넘겨 보던 쪽 그대로
*/
(async()=>{
const w=ms=>new Promise(r=>setTimeout(r,ms));
const q=s=>document.querySelector(s);
const help=()=>({page:q('[data-help-page]').textContent,visible:[...document.querySelectorAll('[data-help-logic]')].filter(x=>!x.hidden).map(x=>x.dataset.helpLogic),on:[...document.querySelectorAll('[data-help-logic].is-on')].map(x=>x.dataset.helpLogic),prevDis:q('[data-help-nav="-1"]').disabled,nextDis:q('[data-help-nav="1"]').disabled,total:document.querySelectorAll('[data-help-logic]').length});
const o={};
o.start=help();
for(let i=0;i<10;i++){q('[data-help-nav="-1"]').click()} o.firstPage=help();
for(let i=0;i<10;i++){q('[data-help-nav="1"]').click()} o.lastPage=help();
q('[data-help-nav="-1"]').click(); o.prev=help();
// 렌더가 돌아도 넘겨 보던 쪽 유지 — 체크 토글로 render 유발
q('[data-with-profile]').click(); await w(100); o.afterRender=help();
q('[data-with-profile]').click();
const a=q('.b05-routerec__help'); const r=a.getBoundingClientRect(); o.helpBox={w:Math.round(r.width),h:Math.round(r.height),scrollH:a.scrollHeight};
const art=[...document.querySelectorAll('[data-help-logic]')].find(x=>!x.hidden); o.artText=art.innerText.slice(0,260);
return JSON.stringify(o,null,1)})()