feat(B05): 노선 추천 종단 반영 켜기·끄기 + 갈래 설명 한 쪽씩 넘기기
- 64-1: 추천 창 「종단 반영」 체크(기본 켬) — 요청 with_profile 로 서버가 가름 · 끄면 원지반만(63-4 전 점수) · 묶음 캐시에 남은 종단은 걷어 냄 · 값은 편집 창 안에서만(저장 안 함) - 64-2: 설명 칸을 갈래 한 쪽씩 ◀ ▶ + 「n / 8」 · 고른 갈래 쪽부터 · 토공 최소 · 공사비 어림 최소 쪽에 종단 반영 설명 추가 - 시험: test_64_1_profile_toggle.py · ui_checks 64-1 · 64-2 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -40,6 +40,8 @@ export interface RouteInitialRequest {
|
||||
mode: RouteInitialLogic;
|
||||
weights?: RouteInitialWeights;
|
||||
criteria?: RouteInitialCriteria;
|
||||
/** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */
|
||||
with_profile?: boolean;
|
||||
}
|
||||
|
||||
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)})()
|
||||
Reference in New Issue
Block a user