feat(B05): 노선 추천 정리 — 알약 다섯 한 줄 · 종단은 갈래가 정함 · 오른쪽 위 [적용]/[닫기] · 지도 안 옵션 겹창 · 추천 토량에 곡선부 확폭(77)

- 알약 = 예상노선 추적(종단 없음) · 영선 · 토공 최소 · 기울기 최소 · 비중 계산(넷은 종단 늘 반영) · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 화면에서 뺌
- 「종단 반영」 켬/끔 단추 없앰(추천 창 · 종단 띠) — 띠 · [확인]은 지금 추천 갈래로 종단 여부
- ✕ 없애고 [적용] · [닫기]를 제목줄 오른쪽 위로 · 옵션 칸은 지도 왼쪽 위 작은 겹창 · 창 폭 넓힘
- 적용한 추천 갈래가 있으면 창을 다시 열 때 그 갈래가 골라진 채
- 도움말: 종단 반영 = 측점마다 횡단을 보고 정한 종단(적분값 아님)
- 균형 종단 단면적에 곡선부 확폭(B05 측점 생성과 같은 셈) — 빼서 확정 뒤 성토가 1.7배 벌어졌음(노선 267 같은 계획고: 성 10,102 → 17,819 · 본 설계 16,140)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMfGERKUg5nMMNQsyVZ2j
This commit is contained in:
2026-10-07 21:29:25 +09:00
co-authored by Claude Opus 5.5
parent fd1fc8b401
commit 6aa77e1bb7
12 changed files with 282 additions and 237 deletions
@@ -10,7 +10,8 @@
1. 측점(20 m)마다 지반 횡단(±20 m · 0.5 m)을 DEM 에서 뜬다 — B05 측점 생성과 같은 꼴
(좌 = +offset · 상단측 = 좌우 평균 표고가 높은 쪽 · 그쪽 절토).
2. 측점마다 계획고 ±Δ 의 절 · 성 단면적 곡선 — B06 `compute_cross_design` 그대로(지반유형
리핑암 = B06 기본값) · `AREA_STEP_M` 간격으로 재고 사이는 직선 보간.
리핑암 = B06 기본값 · 곡선부 확폭 = B05 측점 생성과 같은 `_plan_radii` · `_curve_widenings`
— 빼면 확정 뒤 성토가 1.7배로 벌어짐 · PLAN 77-7) · `AREA_STEP_M` 간격으로 재고 사이는 보간.
3. 동적계획(앞 두 측점 상태)으로 측점 계획고를 고른다 — 목표 = 절토(다짐 환산) + 성토 ㎥ +
λ × (성토 − 절토). λ 를 이분해 |절 − 성| / 큰 쪽이 허용(10 %) 안인 가장 작은 λ.
· 종단기울기 상한 — 넘지 못함.
@@ -57,8 +58,8 @@ GRADE_EPS = 1e-6
def route_sections(
vertices: list[tuple[float, float]], terrain: Terrain
) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str]]:
"""측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토)."""
) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str], list[dict[str, Any]]]:
"""측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토) · 곡선부 확폭."""
xy = np.asarray(vertices, dtype=np.float64)
keep = np.r_[True, np.hypot(*np.diff(xy, axis=0).T) > 1e-9]
xy = xy[keep]
@@ -100,11 +101,27 @@ def route_sections(
lefts, rights = row[(offsets > 0) & flags], row[(offsets < 0) & flags]
up = "right" if lefts.size and rights.size and rights.mean() > lefts.mean() else "left"
modes.append(f"{up}_cut")
return at, ground, sections, modes
return at, ground, sections, modes, _widenings(xy, chain, at)
def _widenings(xy: np.ndarray, chain: np.ndarray, at: np.ndarray) -> list[dict[str, Any]]:
"""측점마다 `compute_cross_design` 확폭 인자 — B05 측점 생성(곡선표 없을 때)과 같은 셈."""
from B05_Profile.B05_Profile_Engine_Sections_Core import _curve_widenings, _plan_radii
radii, outer = _plan_radii(xy, chain, at, float(chain[-1]))
widenings, sides = _curve_widenings(at, radii, outer)
return [
{"plan_radius_m": r, "curve_outer_side": s, "curve_widening_m": w}
for r, s, w in zip(radii, sides, widenings)
]
def _area_curves(
sections: list[list[dict[str, Any]]], modes: list[str], ground: np.ndarray, levels: np.ndarray
sections: list[list[dict[str, Any]]],
modes: list[str],
ground: np.ndarray,
levels: np.ndarray,
curves: list[dict[str, Any]] | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""측점 · 계획고 후보마다 (절토 자연 ㎡, 절토 다짐 환산 ㎡, 성토 ㎡)."""
from B06_Section.B06_Section_Engine_Design import compute_cross_design
@@ -113,6 +130,7 @@ def _area_curves(
shape = levels.shape
cut, cut_c, fill = np.zeros(shape), np.zeros(shape), np.zeros(shape)
for i, (samples, mode) in enumerate(zip(sections, modes)):
widen = curves[i] if curves else {}
delta = levels[i] - ground[i]
lo = np.floor(delta.min() / AREA_STEP_M) * AREA_STEP_M
at = np.arange(lo, delta.max() + AREA_STEP_M, AREA_STEP_M)
@@ -120,7 +138,11 @@ def _area_curves(
for d in at:
try:
r = compute_cross_design(
samples, float(ground[i] + d), ground_type=GROUND_TYPE, section_mode=mode
samples,
float(ground[i] + d),
ground_type=GROUND_TYPE,
section_mode=mode,
**widen,
)
except ValueError: # 지반 샘플이 모자란 측점 — 토량 모름(0)
rows.append((0.0, 0.0, 0.0))
@@ -196,6 +218,7 @@ def balanced_profile(
*,
max_grade: float,
design_speed: int = 20,
curves: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""상한(종단기울기 · 종단곡선)을 지키며 절 · 성토를 횡단 ㎥ 로 맞춘 계획선과 토량.
@@ -208,7 +231,9 @@ def balanced_profile(
weight[1:] += spans / 2.0
offsets = np.arange(-LEVEL_RANGE_M, LEVEL_RANGE_M + 1e-9, LEVEL_STEP_M)
levels = _reference(ground, spans, max_grade)[:, None] + offsets[None, :]
cut, cut_c, fill = (weight[:, None] * a for a in _area_curves(sections, modes, ground, levels))
cut, cut_c, fill = (
weight[:, None] * a for a in _area_curves(sections, modes, ground, levels, curves)
)
criteria = (
FOREST_ROAD_PROFILE_CRITERIA["design_speed"].get(design_speed)
or (FOREST_ROAD_PROFILE_CRITERIA["design_speed"][20])
@@ -293,11 +318,17 @@ def route_profile(
vertices: list[tuple[float, float]], terrain: Terrain, *, max_grade: float, design_speed: int
) -> dict[str, Any]:
"""후보 노선 하나의 균형 종단 — 측점 횡단을 뜨고 `balanced_profile`."""
chainage, ground, sections, modes = route_sections(vertices, terrain)
chainage, ground, sections, modes, curves = route_sections(vertices, terrain)
if len(chainage) < 2:
raise ValueError("노선이 짧아 종단을 세울 수 없습니다.")
return balanced_profile(
chainage, ground, sections, modes, max_grade=max_grade, design_speed=design_speed
chainage,
ground,
sections,
modes,
max_grade=max_grade,
design_speed=design_speed,
curves=curves,
)
+1 -2
View File
@@ -226,13 +226,12 @@ export async function openRouteEditModal(
projectId,
planMinRadiusM: () => minRadiusM,
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
onProfileChange: () => strip.refresh(),
});
/** 지도 아래 종단 띠 — 노드를 놓을 때마다 다시 묻는다(PLAN 75-1). 셈은 `_ProfileStrip` 몫. */
const strip = createProfileStrip({
root: overlay.querySelector<HTMLElement>("[data-strip]")!,
projectId,
profileMemory: initial.profileMemory,
withProfile: initial.withProfile,
now: () => ({
vertices: planned.map(([x, y], index) => ({
x,
@@ -43,7 +43,7 @@ export function createRouteEditChrome(): RouteEditChrome {
<span class="b05-routeedit__rec-now" data-rec-now></span>
<span class="b05-routeedit__actions">
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 토공 최소 · 기울기 순한 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
aria-pressed="false" disabled
@@ -92,8 +92,6 @@ export function createRouteEditChrome(): RouteEditChrome {
</div>
<div class="b05-routeedit__strip" data-strip>
<div class="b05-routeedit__strip-head">
<button type="button" class="ui-btn ui-btn--ghost" data-strip-toggle aria-pressed="true"
title="켬 = 계획선은 지금 노선의 균형 종단(확인도 이것으로) · 끔 = 지반선만(계획고 = 지반고)">종단 반영 켬</button>
<span data-strip-note>종단을 읽는 중…</span>
</div>
<div class="b05-routeedit__strip-graph" data-strip-graph></div>
@@ -33,6 +33,7 @@ import {
readInitialChoice,
sameBase,
serverBase,
withProfileOf,
writeInitialChoice,
type CriteriaKey,
type InitialBase,
@@ -47,8 +48,6 @@ export interface InitialPanelParams {
planMinRadiusM: () => number;
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
apply: (snapshot: RouteEditSnapshot, message: string) => void;
/** 추천 창에서 「종단 반영」을 바꿨다 — 종단 띠가 다시 묻는다. */
onProfileChange: () => void;
}
export interface InitialPanel {
@@ -60,9 +59,9 @@ export interface InitialPanel {
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
};
/** 「종단 반영」 — 추천 창 · 종단 띠가 함께 쥔다. 켬이면 [확인]이 지금 노선의 균형 종단을
* 1차 종단으로 쓴다(추천을 손으로 고쳤어도 · PLAN 75-1). 편집 창이 사는 동안만 남는다. */
profileMemory: { withProfile: boolean };
/** 지금 노선이 종단을 반영하는가 — 지금 추천 갈래가 정한다(예상노선 추적만 아님 · PLAN 77-2).
* 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 갈래대로). */
withProfile: () => boolean;
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */
tag: () => InitialChoice | null;
restoreTag: (tag: unknown) => void;
@@ -96,8 +95,6 @@ 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;
@@ -146,12 +143,11 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
defaults,
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
weightMemory,
profileMemory,
logic: (choice ?? base).logic,
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: opening!.planned.map(([x, y]) => [x, y]),
request,
onProfileChange: params.onProfileChange,
onApply: (picked) => {
choice = picked;
writeInitialChoice(projectId, choice);
@@ -220,7 +216,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const criteria = choice ? choice.criteriaOverrides : base.criteria;
return { mode: now.logic, weights: now.weights, criteria };
},
profileMemory,
withProfile: () => withProfileOf((choice ?? base).logic),
tag: () => choice,
restoreTag(tag) {
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
@@ -20,13 +20,16 @@ export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
follow: "예상노선 추적",
gradeline: "영선 노선",
earthwork: "토공 최소",
gentle: "기울기 순한",
gentle: "기울기 최소",
safe: "안전 우선",
crossings: "계류 횡단 최소",
cost: "공사비 어림 최소",
weighted: "비중 계산",
};
/** 종단을 늘 반영하는 갈래 — 예상노선 추적만 종단 없음(전처리 계획선 = 지반고 · PLAN 77-2). */
export const withProfileOf = (logic: RouteInitialLogic): boolean => logic !== "follow";
/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */
export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, grade: 25 };
export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const;
@@ -432,8 +435,11 @@ const withParticle = (name: string): string => {
/** 같은 노선 알림 한 줄 — 이 지형은 제약 안에서 갈 길이 한 띠뿐일 때. 없으면 빈 글(줄을 숨긴다).
* 「이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 토공 최소 · 짧은 노선과 거의 같은 노선(가장 먼 거리 6.1m)」 */
/** 화면에서 뺀 갈래(PLAN 77-1) — 서버는 아직 셈하므로 같은 노선 알림에서도 뺀다. */
const HIDDEN_LOGICS = new Set<RouteInitialLogic>(["safe", "crossings", "cost"]);
export function similarLine(choice: InitialChoice | null): string {
const similar = choice?.similar;
const similar = choice?.similar?.filter((item) => !HIDDEN_LOGICS.has(item.logic));
if (!choice || !similar || similar.length === 0) return "";
const names = similar.map((item) => LOGIC_LABEL[item.logic]).join(" · ");
const gap = Math.max(...similar.map((item) => item.gapM));
@@ -3,9 +3,10 @@
* 계획노선 편집 창 지도 아래 **종단 띠**(PLAN 75-1) — 지금 노선 전체 연장의 원지반선 · 계획선 ·
* 측점 눈금 한 줄.
*
* 「종단 반영」(추천 창과 같은 `profileMemory`) 켬 = 계획선은 지금 노드로 서버가 다시 셈한 균형
* 종단(`/route/profile-preview` — [확인] 체인과 같은 셈). 끔 = 지반선만(계획고 = 지반고 ·
* `/route/elevations`). [확인]도 같은 규칙을 탄다(`_Apply` 의 `balanced_profile`).
* 보여 주기만 한다(PLAN 77-5) — 종단 여부는 지금 추천 갈래가 정한다(`withProfile`). 종단 갈래 =
* 계획선은 지금 노드로 서버가 다시 낸 균형 종단(`/route/profile-preview` — [확인] 체인과 같은 셈).
* 예상노선 추적 = 지반선만(계획고 = 지반고 · `/route/elevations`). [확인]도 같은 규칙을 탄다
* (`_Apply` 의 `balanced_profile`).
*
* 갱신은 노드를 놓을 때마다(끌기 중 아님) · 최신 요청만 채택(seq) · 기다리는 동안 옛 그림은 흐리게.
* 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다.
@@ -27,27 +28,26 @@ const STATION_M = 20;
export interface ProfileStripParams {
root: HTMLElement;
projectId: string;
/** 추천 창 「종단 반영」과 한 벌 — 이 띠의 켬/끔 단추도 같은 값을 바꾼다. */
profileMemory: { withProfile: boolean };
/** 지금 추천 갈래가 종단을 반영하는가(예상노선 추적만 아님). */
withProfile: () => boolean;
/** 지금 편집값 — 노드(서버로) · 그려진 노선(지반선 측점) · 지금 추천 제약. */
now: () => { vertices: RouteReplanVertex[]; line: ReadonlyArray<Vertex>; criteria: unknown };
}
export interface ProfileStrip {
/** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용 · 켬/끔) — 다시 묻는다. */
/** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용) — 다시 묻는다. */
refresh: () => void;
/** [확인] 직전 — 지금 노선으로 띠를 맞추고 응답을 기다려, 띠에 보인 계획선이 균형 종단인지
* 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 켬/끔 값. */
* 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 갈래 값. */
settle: () => Promise<boolean>;
}
export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
const { root, projectId, profileMemory } = params;
const toggle = root.querySelector<HTMLButtonElement>("[data-strip-toggle]")!;
const { root, projectId } = params;
const note = root.querySelector<HTMLElement>("[data-strip-note]")!;
const graph = root.querySelector<HTMLElement>("[data-strip-graph]")!;
let seq = 0;
/** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 켬/끔이면 다시 묻지 않는다. */
/** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 갈래 종단이면 다시 묻지 않는다. */
let lastKey = "";
/** 마지막 요청(끝날 때까지) · 띠에 그려진 계획선이 균형 종단인가(못 그렸으면 null). */
let pending: Promise<void> = Promise.resolve();
@@ -66,9 +66,7 @@ export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
}
function refresh(): void {
const on = profileMemory.withProfile;
toggle.setAttribute("aria-pressed", String(on));
toggle.textContent = `종단 반영 ${on ? "켬" : "끔"}`;
const on = params.withProfile();
const { vertices, line, criteria } = params.now();
const key = JSON.stringify([on, vertices, criteria]);
if (vertices.length < 2 || line.length < 2 || key === lastKey) return;
@@ -84,21 +82,17 @@ export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
): Promise<void> {
const ticket = ++seq;
root.classList.add("is-waiting");
note.textContent = on ? "균형 종단을 셈하는 중…" : "지반선을 읽는 중…";
const started = performance.now();
note.textContent = "종단을 읽는 중…";
try {
const profile = on
? parseBalance(await fetchProfilePreview(projectId, vertices, criteria))
: await groundOnly(line);
if (ticket !== seq || !root.isConnected) return; // 그 사이 노선이 또 바뀌었다.
if (!profile) throw new Error("균형 종단을 세우지 못했습니다.");
const seconds = ((performance.now() - started) / 1000).toFixed(1);
const total = profile.stationsM[profile.stationsM.length - 1];
graph.innerHTML = profileSvg(profile, true);
shownOn = on;
note.textContent =
`${on ? "계획선 = 균형 종단" : "지반선만(계획고 = 지반고)"} · ` +
`연장 ${total.toFixed(1)}m · 측점 ${profile.stationsM.length}개 · ${seconds}초`;
note.textContent = `${on ? "원지반선 · 계획 종단선" : "원지반선(계획고 = 지반고)"} · 연장 ${total.toFixed(1)}m`;
} catch (error) {
if (ticket !== seq) return;
lastKey = ""; // 실패 — 다음 번에 다시 묻는다.
@@ -110,17 +104,12 @@ export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
}
}
toggle.addEventListener("click", () => {
profileMemory.withProfile = !profileMemory.withProfile;
refresh();
});
return {
refresh,
settle: async () => {
refresh();
await pending;
return shownOn ?? profileMemory.withProfile;
return shownOn ?? params.withProfile();
},
};
}
@@ -1,8 +1,9 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend.ts
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2) — 알약 넷(예상노선 추적 · 토공 최소 ·
* 기울기 순한 · 비중 계산) · 비중 슬라이더 셋 · 공통 제약 네 칸 · 미리 보기 지도 · 비교값 ·
* 못 지킨 구간 · [적용] · [닫기].
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 ·
* 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 ·
* 못 지킨 구간 · 오른쪽 위 [적용] · [닫기].
* 종단은 갈래가 정한다 — 예상노선 추적만 종단 없음, 나머지는 늘 균형 종단(`withProfileOf`).
*
* 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 편집 창의 노선은
* 건드리지 않는다 — [적용]을 눌러야 `onApply` 로 편집값에 실리고(되돌리기 한 걸음), [닫기]는
@@ -37,6 +38,7 @@ import {
violationsLine,
violationText,
WEIGHT_KEYS,
withProfileOf,
type CriteriaKey,
type InitialChoice,
type InitialCriteria,
@@ -60,26 +62,14 @@ import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
const PILLS: RouteInitialLogic[] = [
"follow",
"gradeline",
"earthwork",
"gentle",
"safe",
"crossings",
"cost",
"weighted",
];
const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const;
const PILL_TITLE: Record<RouteInitialLogic, string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다",
earthwork: "절토 · 성토가 가장 적은 노선",
gentle: "종단기울기가 가장 순한 노선",
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) — 목표 기울기를 넣을 수 있음",
safe: "급한 옆경사 · 계류 가까운 곳을 피하는 노선",
crossings: "계류를 건너는 횟수가 가장 적은 노선",
cost: "토공 + 연장 + 계류 횡단을 섞은 공사비 어림(상대값)이 가장 낮은 노선",
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선",
const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 · 종단 없음(계획고 = 지반고)",
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단",
earthwork: "절토 · 성토가 가장 적은 노선 + 종단",
gentle: "종단기울기가 가장 낮은 노선 + 종단",
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선 + 종단",
};
const WEIGHT_NAME: Record<WeightKey, string> = {
@@ -132,8 +122,8 @@ export interface RecommendParams {
weights: RouteInitialWeights;
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
/** 「종단 반영」 — 켬이 기본(지금 동작). 편집 창이 사는 동안만 남고 저장하지 않는다. */
profileMemory: { withProfile: boolean };
/** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */
logic: RouteInitialLogic;
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
@@ -147,12 +137,13 @@ export interface RecommendParams {
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 편집 창에 놓는다. */
onApply: (choice: InitialChoice) => void;
/** 「종단 반영」을 바꿨다 — 편집 창 종단 띠가 다시 묻는다(PLAN 75-1). */
onProfileChange?: () => void;
}
export function openRecommendModal(params: RecommendParams): void {
let logic: RouteInitialLogic = "follow";
// 알약에 없는 옛 갈래(안전 우선 등)로 적용했던 노선이면 예상노선 추적에서 시작한다.
let logic: RouteInitialLogic = (PILLS as readonly string[]).includes(params.logic)
? params.logic
: "follow";
const { locks } = params.weightMemory;
let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) };
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
@@ -171,11 +162,11 @@ export function openRecommendModal(params: RecommendParams): void {
let map: ReturnType<typeof createRecommendMap> | null = null;
openModal({
title: "노선 추천",
closeLabel: "✕",
closeLabel: "닫기", // 제목줄 단추는 mount 에서 [적용] · [닫기] 묶음으로 갈아 끼운다(PLAN 77-3)
dialogClass: "b05-routerec",
// 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다.
backdropClass: "b05-routerec-backdrop",
// ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다.
// [닫기] · Esc · 바깥 누르기 모두 미리 보기를 버린다.
beforeClose: () => {
closed = true;
window.clearInterval(ticker);
@@ -186,30 +177,21 @@ export function openRecommendModal(params: RecommendParams): void {
bodyEl = body;
body.classList.add("b05-routerec__body");
body.innerHTML = `
<div class="b05-routerec__pillrow">
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래">
${PILLS.map(
(key) => `<button type="button" class="b05-routerec__pill" role="radio"
data-logic="${key}" title="${PILL_TITLE[key]}">${LOGIC_LABEL[key]}</button>`,
).join("")}
</div>
<button type="button" class="b05-routerec__pill b05-routerec__profile-toggle"
data-with-profile aria-pressed="true"
title="켬 = 후보마다 균형 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(더 빠름) · 이 창 안에서만">종단 반영</button>
<div class="b05-routerec__actions">
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" disabled>적용</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="close">닫기</button>
</div>
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래">
${PILLS.map(
(key) => `<button type="button" class="b05-routerec__pill" role="radio"
data-logic="${key}" title="${PILL_TITLE[key]}">${LOGIC_LABEL[key]}</button>`,
).join("")}
</div>
<div class="b05-routerec__main">
<div class="b05-routerec__mapbox">
<div class="b05-routerec__mapwrap" role="img" aria-label="추천 노선 미리 보기">
${RECOMMEND_MAP_HTML}
</div>
<div class="b05-routerec__legend">
<span><i class="is-expected"></i>예상노선(원본)</span>
<span><i class="is-current"></i>창을 연 노선</span>
<span><i class="is-preview"></i>미리 보기</span>
</div>
<div class="b05-routerec__maphint">휠 = 확대 · 축소 &nbsp; 끌기 = 이동 &nbsp; ↺ ↻ = 회전</div>
</div>
<div class="b05-routerec__side">
<div class="b05-routerec__side" role="group" aria-label="추천 옵션">
<div class="b05-routerec__weights" hidden>
<div class="b05-routerec__bars">
${WEIGHT_KEYS.map(
@@ -237,6 +219,14 @@ export function openRecommendModal(params: RecommendParams): void {
<input type="number" inputmode="decimal" step="0.1" min="0" max="100"
data-criteria="${TARGET_GRADE_KEY}" placeholder="상한" />%</label>
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>다시 셈</button>
</div>
</div>
<div class="b05-routerec__legend">
<span><i class="is-expected"></i>예상노선(원본)</span>
<span><i class="is-current"></i>창을 연 노선</span>
<span><i class="is-preview"></i>미리 보기</span>
</div>
<div class="b05-routerec__maphint">휠 = 확대 · 축소 &nbsp; 끌기 = 이동 &nbsp; ↺ ↻ = 회전</div>
</div>
</div>
<div class="b05-routerec__lines">
@@ -255,21 +245,22 @@ export function openRecommendModal(params: RecommendParams): void {
</div>
<div class="b05-routerec__foot">
<span class="b05-routerec__state" data-state></span>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" disabled>적용</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="close">닫기</button>
</div>
${recommendHelpHtml()}`;
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
// [적용] · [닫기]를 제목줄 오른쪽 위로 — 공용 겉틀의 닫기 단추와 갈아 끼운다(PLAN 77-3).
const actions = q<HTMLElement>(".b05-routerec__actions");
const applyButton = q<HTMLButtonElement>('[data-act="apply"]');
const closeButton = q<HTMLButtonElement>('[data-act="close"]');
body.parentElement?.querySelector(".ui-modal__top > button")?.replaceWith(actions);
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
const applyButton = q<HTMLButtonElement>('[data-act="apply"]');
const stateEl = q<HTMLElement>("[data-state]");
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
const profileToggle = q<HTMLButtonElement>("[data-with-profile]");
bindRecommendHelp(body);
criteriaInputs.forEach((input) => {
@@ -348,8 +339,6 @@ export function openRecommendModal(params: RecommendParams): void {
input.disabled = busy;
});
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
profileToggle.disabled = busy;
profileToggle.setAttribute("aria-pressed", String(params.profileMemory.withProfile));
rerun.hidden = !dirty;
rerun.disabled = busy || invalid.size > 0;
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
@@ -398,7 +387,7 @@ export function openRecommendModal(params: RecommendParams): void {
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
const withProfile = params.profileMemory.withProfile;
const withProfile = withProfileOf(logic);
busy = true;
dirty = false;
preview = null;
@@ -408,7 +397,7 @@ export function openRecommendModal(params: RecommendParams): void {
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
stateEl.textContent = `${label} 셈하는 중… ${seconds}초${
withProfile ? " (후보마다 균형 종단을 셈합니다)" : " (종단 반영 끔 — 원지반만)"
withProfile ? " (후보마다 측점 횡단으로 종단을 셈합니다)" : " (종단 없음 — 원지반만)"
}`;
};
window.clearInterval(ticker);
@@ -431,7 +420,7 @@ export function openRecommendModal(params: RecommendParams): void {
}
preview = choice;
if (choice.balance) seen.set(label, choice.balance);
else seen.delete(label); // 종단 반영 끔 — 켜고 셈했던 같은 갈래 줄은 지움
else seen.delete(label); // 종단 없는 갈래(예상노선 추적)
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`;
} catch (error) {
if (closed || mine !== seq) return;
@@ -515,12 +504,6 @@ export function openRecommendModal(params: RecommendParams): void {
render();
});
});
profileToggle.addEventListener("click", () => {
params.profileMemory.withProfile = !params.profileMemory.withProfile;
dirty = preview !== null;
render();
params.onProfileChange?.();
});
rerun.addEventListener("click", () => void run());
applyButton.addEventListener("click", () => {
if (!preview) return;
@@ -528,13 +511,13 @@ export function openRecommendModal(params: RecommendParams): void {
closed = true;
close();
});
q<HTMLButtonElement>('[data-act="close"]').addEventListener("click", () => {
closeButton.addEventListener("click", () => {
closed = true;
close();
});
render();
// 처음 고른 상태 = 예상노선 추적 — 열자마자 그 미리 보기를 받는다.
// 처음 고른 갈래(적용한 추천 · 없으면 예상노선 추적) — 열자마자 그 미리 보기를 받는다.
void run();
},
});
@@ -1,6 +1,6 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Help.ts
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2 · 64-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법.
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2 · 64-2 · 77) — 갈래 다섯 · 종단 반영 · 공통 제약 넷 · 결과 줄 읽는 법.
*
* 글은 서버 엔진(`B05_Profile_Engine_RouteInitial*`)이 실제로 하는 일을 따라 적는다 — 엔진을
* 바꾸면 여기 글도 같이 고친다. 갈래 설명은 **한 번에 한 갈래**(쪽) — 위 ◀ ▶ 단추와 「n / 전체」로
@@ -19,11 +19,13 @@ interface HelpEntry {
when: string;
/** 주의할 점. */
caution: string;
/** 「종단 반영」 켬 · 끔이 점수에 미치는 영향(토공 점수를 쓰는 갈래만). */
/** 종단 반영이 이 갈래에 하는 일. */
profile?: string;
}
const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
type ShownLogic = "follow" | "gradeline" | "earthwork" | "gentle" | "weighted";
const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
follow: {
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
how: [
@@ -33,6 +35,7 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
],
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인",
profile: "없음 — 전처리 계획선처럼 계획고 = 지반고",
},
gradeline: {
summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선",
@@ -54,8 +57,7 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
],
when: "토공량 · 사토 처리를 줄여야 할 때",
caution: "비탈면 흙은 빠진 어림값 · 연장이 길어지는지 비교값으로 함께 볼 것",
profile:
"켬 = 후보마다 균형 종단을 세워 절 + 성 + 반입 + 사토 ㎥ 로 비교 · 끔 = 종단 없이 원지반 절 + 성 어림으로 비교(더 빠름)",
profile: "후보마다 균형 종단을 세워 절 + 성 + 반입 + 사토 ㎥ 로 비교",
},
gentle: {
summary: "종단기울기가 가장 순한 노선",
@@ -66,38 +68,6 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
when: "차량 주행 · 노면 침식이 걱정되는 노선",
caution: "거리 비용이 작아 돌아가는 긴 노선이 나올 수 있음 — 연장을 확인",
},
safe: {
summary: "급한 옆경사 · 계류 가까운 곳을 피하는 노선",
how: [
"옆경사 35°(약 70%) 이상 · 계류 이격 거리 안을 위험 구간으로 보고 피함",
"후보 중 위험 구간을 지나는 길이가 가장 짧은 것을 고름(같으면 평균 옆경사)",
"35° 기준 = 「35° 이상 전폭 절토」 기술 기준",
],
when: "붕괴 · 유실 위험이 큰 급경사지 · 계곡 가까운 지형",
caution:
"계곡을 따라가는 지형에서는 위험 구간이 어디서나 길어 다른 갈래와 차이가 작음 — 계류 이격 값을 줄여 다시 셈해 볼 것",
},
crossings: {
summary: "계류를 건너는 횟수가 가장 적은 노선",
how: [
"계류 위를 지나는 칸에 벌점을 매겨 길을 찾음",
"후보 중 계류 횡단 수가 가장 적은 것을 고름(같으면 계류에서 더 먼 쪽)",
"10m 안에서 여러 번 건너는 것은 한 번으로 셈",
],
when: "배수관 · 세월교 같은 횡단 구조물을 줄이고 싶을 때",
caution: "소하천 · 지방하천 · 국가하천만 셈 — 작은 세류는 빠짐",
},
cost: {
summary: "토공 · 연장 · 계류 횡단을 한 숫자로 묶은 공사비 어림이 가장 낮은 노선",
how: [
"공사비 어림 = 흙 1㎥ × 1 + 연장 1m × 2 + 계류 횡단 1곳 × 400(흙 ㎥ 상당)",
"같은 비율을 1m 당 값으로 바꿔 길을 찾고, 후보 중 어림값이 가장 낮은 것을 고름",
],
when: "토공 · 연장 · 구조물 수를 한꺼번에 비교하고 싶을 때",
caution: "실제 원가가 아닌 상대값(임시 비율) — 갈래끼리 비교용으로만 볼 것",
profile:
"켬 = 토공 몫을 균형 종단 토량으로 셈 · 끔 = 원지반 절 + 성 어림으로 셈(종단을 세우지 않음)",
},
weighted: {
summary: "예상 · 토공 · 기울기 세 비중(합 100)을 섞어 찾은 노선",
how: [
@@ -113,7 +83,7 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
{
name: "종단 반영",
text: "켬(기본) = 후보마다 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(종단 그림 · 덮개 줄은 안 나옴). 켬으로 [적용]한 노선을 고치지 않고 [확인]하면 그 균형 종단이 첫 계획선이 됨 · 끔 · 손으로 고친 노선은 계획고 = 지반고",
text: "예상노선 추적을 뺀 네 갈래는 늘 반영. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [확인] 뒤 본 설계 값과 차이가 남. [확인]하면 편집 창 아래 띠에 보인 종단이 그대로 첫 계획선이 됨",
},
{
name: "종단기울기 상한(%)",
@@ -152,19 +122,10 @@ const LINES_HELP: Array<{ name: string; text: string }> = [
},
];
const LOGIC_ORDER: RouteInitialLogic[] = [
"follow",
"gradeline",
"earthwork",
"gentle",
"safe",
"crossings",
"cost",
"weighted",
];
const LOGIC_ORDER: ShownLogic[] = ["follow", "gradeline", "earthwork", "gentle", "weighted"];
/** 갈래 설명 쪽 하나 — 고른 갈래(`is-on`)는 테두리로 알린다. */
function logicPage(key: RouteInitialLogic): string {
function logicPage(key: ShownLogic): 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>
@@ -234,5 +195,5 @@ export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic):
});
if (aside.dataset.pickedLogic === logic) return;
aside.dataset.pickedLogic = logic;
showHelpPage(aside, LOGIC_ORDER.indexOf(logic));
showHelpPage(aside, Math.max(0, LOGIC_ORDER.indexOf(logic as ShownLogic)));
}
@@ -14,9 +14,9 @@
z-index: calc(var(--z-modal, 1000) + 2);
}
/* 오른쪽 설명 칸(계획서 48-2)만큼 넓힘 — 왼쪽 기존 칸은 예전 폭 그대로. */
/* 오른쪽 설명 칸(계획서 48-2) · 옵션 칸을 지도 안으로 옮긴 만큼 지도를 넓힘(PLAN 77-4). */
.ui-modal.b05-routerec {
width: min(1320px, 96vw);
width: min(1560px, 97vw);
--modal-max: 92vh;
font-size: var(--text-body-sm);
}
@@ -166,11 +166,10 @@
}
}
/* 알약 — 하나만 고른 상태(라디오 묶음). */
/* 여덟 알약 — 네 칸 두 줄로 고르게(좁은 폭은 두 칸 네 줄). */
/* 알약 — 하나만 고른 상태(라디오 묶음). 다섯 알약 한 줄(좁은 폭은 두 칸 · PLAN 77-1). */
.b05-routerec__pills {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--spacing-8);
}
@@ -212,32 +211,10 @@
font-weight: 600;
}
/* 갈래 알약 오른쪽 「종단 반영」 켬/끔 — 알약과 같은 모양 · 켬은 테두리 · 글자만 강조(고른 갈래와 갈림). */
.b05-routerec__pillrow {
/* 제목줄 오른쪽 위 [적용] · [닫기](공용 겉틀 닫기 단추 자리 · PLAN 77-3). */
.b05-routerec__actions {
display: flex;
gap: var(--spacing-8);
align-items: center;
}
.b05-routerec__pillrow > .b05-routerec__pills {
flex: 1;
min-width: 0;
}
.b05-routerec__profile-toggle {
flex: none;
padding-inline: var(--spacing-12);
}
.b05-routerec__profile-toggle[aria-pressed="true"] {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
color: var(--color-primary);
font-weight: 600;
}
.b05-routerec__profile-toggle[aria-pressed="true"]::before {
content: "✓ ";
}
.b05-routerec__pill:disabled {
@@ -247,7 +224,7 @@
.b05-routerec__main {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-12);
align-items: start;
}
@@ -261,7 +238,7 @@
/* 미리 보기 지도 — 캔버스 칸(높이 고정). 회전 단추는 편집 창 것(`.b05-routeedit__spin`)과 같은 모양. */
.b05-routerec__mapwrap {
position: relative;
height: 380px;
height: 460px;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
@@ -309,12 +286,25 @@
border-top: 3px solid var(--color-primary);
}
/* 옵션 겹창 — 지도 왼쪽 위 작은 칸(회전 단추는 오른쪽 위 · PLAN 77-4). 지도 끌기를 막지 않게 제 칸만. */
.b05-routerec__side {
position: absolute;
top: var(--spacing-8);
left: var(--spacing-8);
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-12);
gap: var(--spacing-8);
width: 232px;
max-height: calc(100% - 2 * var(--spacing-8));
overflow: auto;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
box-shadow: var(--shadow-sm, 0 2px 6px rgb(0 0 0 / 0.15));
color: var(--color-text-body);
font-size: var(--text-body-sm);
font-size: var(--text-caption);
}
/* 비중 — 세로 막대 셋이 나란히(값 위 · 이름 아래). 슬라이더가 세로로 선다. */
@@ -343,7 +333,7 @@
.b05-routerec__bar input[type="range"] {
width: 28px;
height: 130px;
height: 90px;
margin: 0;
writing-mode: vertical-lr;
direction: rtl; /* 위로 올릴수록 큰 값 */
@@ -505,7 +495,7 @@
}
.b05-routerec__mapwrap {
height: 300px;
height: 360px;
}
}
@@ -228,8 +228,20 @@ const initialRoute = {
const mapLines = {
fit: model.mapPaths(
[
{ kind: "expected", points: [[0, 0], [100, 0]] },
{ kind: "preview", points: [[0, 0], [100, 50]] },
{
kind: "expected",
points: [
[0, 0],
[100, 0],
],
},
{
kind: "preview",
points: [
[0, 0],
[100, 50],
],
},
],
460,
340,
@@ -241,7 +253,10 @@ const mapLines = {
// ⑦ 되돌리기 · 다시하기가 걸음마다 실은 「현재 추천」을 되살린다(노선과 짝).
const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_History.ts"));
const step = (x, rec) => ({
planned: [[x, 0], [x + 1, 1]],
planned: [
[x, 0],
[x + 1, 1],
],
curveOn: [true, true],
curveRadius: [null, null],
curveLock: [null, null],
@@ -257,7 +272,12 @@ const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map
// ⑧ 비중 고정(계획서 26-2c) — 고정한 값은 안 움직이고 나머지가 합 100 을 맞춘다.
const W = { expected: 70, earthwork: 15, grade: 15 };
const L = (...keys) => ({ expected: false, earthwork: false, grade: false, ...Object.fromEntries(keys.map((k) => [k, true])) });
const L = (...keys) => ({
expected: false,
earthwork: false,
grade: false,
...Object.fromEntries(keys.map((k) => [k, true])),
});
const sweep = [];
for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected", "grade")]) {
for (const key of ["expected", "earthwork", "grade"]) {
@@ -266,7 +286,9 @@ for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected
sweep.push({
sum: out.expected + out.earthwork + out.grade,
lockedSame: Object.keys(locks).every((k) => !locks[k] || out[k] === W[k]),
range: [out.expected, out.earthwork, out.grade].every((n) => Number.isInteger(n) && n >= 0 && n <= 100),
range: [out.expected, out.earthwork, out.grade].every(
(n) => Number.isInteger(n) && n >= 0 && n <= 100,
),
});
}
}
@@ -275,9 +297,18 @@ const weightLocks = {
freeThree: model.moveWeight(W, L(), "expected", 40),
lockExpected: model.moveWeight(W, L("expected"), "earthwork", 20),
lockExpectedClamp: model.moveWeight(W, L("expected"), "earthwork", 90),
twoLocked: model.moveWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 50),
twoLocked: model.moveWeight(
{ expected: 70, earthwork: 20, grade: 10 },
L("expected", "earthwork"),
"grade",
50,
),
movingLocked: model.moveWeight(W, L("expected"), "expected", 10),
derived: [model.derivedWeightKey(L()), model.derivedWeightKey(L("expected")), model.derivedWeightKey(L("expected", "earthwork"))],
derived: [
model.derivedWeightKey(L()),
model.derivedWeightKey(L("expected")),
model.derivedWeightKey(L("expected", "earthwork")),
],
canLock: {
none: model.canLockWeight(L(), "grade"),
one: model.canLockWeight(L("expected"), "grade"),
@@ -293,8 +324,18 @@ const weightLocks = {
over: model.inputWeight(W, L(), "earthwork", 101),
nan: model.inputWeight(W, L(), "earthwork", Number.NaN),
onLocked: model.inputWeight(W, L("earthwork"), "earthwork", 15),
derivedSame: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 10),
derivedOther: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 30),
derivedSame: model.inputWeight(
{ expected: 70, earthwork: 20, grade: 10 },
L("expected", "earthwork"),
"grade",
10,
),
derivedOther: model.inputWeight(
{ expected: 70, earthwork: 20, grade: 10 },
L("expected", "earthwork"),
"grade",
30,
),
},
};
@@ -321,10 +362,9 @@ const gradeline = withExtras(
);
const sixth = {
labels: Object.fromEntries(
["follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"].map((k) => [
k,
model.LOGIC_LABEL[k],
]),
["follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"].map(
(k) => [k, model.LOGIC_LABEL[k]],
),
),
removedLogics: ["short", "balance"].map((k) => k in model.LOGIC_LABEL),
// 옛 저장값(short · balance)은 예상노선 추적으로 읽는다 — 서버 기억 · 캐시 둘 다.
@@ -337,8 +377,9 @@ const sixth = {
criteriaTarget: model.criteriaLine(gradeline.criteria, 0, true),
criteriaNoTarget: model.criteriaLine(gradeline.criteria, 0, false),
targetParsed: gradeline.criteria.targetGradePct,
targetOverride: model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 })
.criteriaOverrides,
targetOverride: model.normalizeRouteInitial(plainResponse, "gradeline", null, {
target_grade_pct: 10,
}).criteriaOverrides,
targetBase: model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }),
sameTarget: [
model.sameBase(
@@ -360,9 +401,21 @@ const sixth = {
}),
),
similarOne: model.similarLine(
withExtras({ band_width_m: 13.54, similar: [{ logic: "safe", max_gap_m: 6.1 }] }),
withExtras({ band_width_m: 13.54, similar: [{ logic: "gradeline", max_gap_m: 6.1 }] }),
),
similarNoBand: model.similarLine(
withExtras({ similar: [{ logic: "weighted", max_gap_m: 9.1 }] }),
),
// 화면에서 뺀 갈래(안전 우선 · 계류 횡단 최소 · 공사비 어림 최소)만 있으면 줄 숨김(PLAN 77-1)
similarHidden: model.similarLine(
withExtras({
band_width_m: 13.5,
similar: [
{ logic: "safe", max_gap_m: 1 },
{ logic: "cost", max_gap_m: 2 },
],
}),
),
similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "cost", max_gap_m: 9.1 }] })),
similarEmpty: model.similarLine(withExtras({ band_width_m: 13.5, similar: [] })),
similarMissing: model.similarLine(withExtras({})),
similarNull: model.similarLine(null),
@@ -464,7 +517,16 @@ const balance63 = {
session._map.clear();
session.setItem(
"aislo:result:routeedit-initial:p-9",
JSON.stringify({ logic: "short", weights: null, snapshot: { planned: [[0, 0], [1, 1]] } }),
JSON.stringify({
logic: "short",
weights: null,
snapshot: {
planned: [
[0, 0],
[1, 1],
],
},
}),
);
sixth.legacyCache = model.readInitialChoice("p-9");
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""노선 추천 균형 종단에 곡선부 확폭 — PLAN 77-7.
빼면 확정 뒤 본 설계(확폭 있음)와 성토가 1.7배로 벌어졌다(랩탑 메인 노선 267 실측).
추천 측점 확폭 = B05 측점 생성과 같은 `_plan_radii` · `_curve_widenings`.
"""
import numpy as np
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import _widenings
from config.config_system_design import curve_widening_m
def _route() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
straight = [(x, 0.0) for x in np.arange(0.0, 60.0, 2.0)]
radius = 15.0
arc = [
(60.0 + radius * np.sin(t), radius - radius * np.cos(t))
for t in np.linspace(0.0, np.pi / 2, 30)
]
xy = np.asarray(straight + arc + [(75.0, 15.0 + d) for d in np.arange(2.0, 60.0, 2.0)])
chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
at = np.append(np.arange(0.0, chain[-1], 5.0), chain[-1])
return xy, chain, at
def test_곡선_측점은_확폭_직선은_없음():
xy, chain, at = _route()
rows = _widenings(xy, chain, at)
assert len(rows) == len(at)
on_curve = [row for a, row in zip(at, rows) if 64.0 < a < 80.0]
assert on_curve and all(row["curve_widening_m"] > 0 for row in on_curve)
assert max(row["curve_widening_m"] for row in on_curve) == curve_widening_m(15.0)
assert all(row["curve_outer_side"] in ("left", "right") for row in on_curve)
straight = [row for a, row in zip(at, rows) if a < 40.0]
assert all(row["curve_widening_m"] == 0 for row in straight)
+12 -18
View File
@@ -249,13 +249,13 @@ def test_비중_값_칸_직접_입력(results):
}
def test_추천_갈래_여덟_이름_알약_순서(results):
def test_추천_갈래_이름_알약_다섯_순서(results):
six = results["sixth"]
assert six["labels"] == {
"follow": "예상노선 추적",
"gradeline": "영선 노선",
"earthwork": "토공 최소",
"gentle": "기울기 순한",
"gentle": "기울기 최소",
"safe": "안전 우선",
"crossings": "계류 횡단 최소",
"cost": "공사비 어림 최소",
@@ -267,24 +267,17 @@ def test_추천_갈래_여덟_이름_알약_순서(results):
with open(page, encoding="utf-8") as handle:
text = handle.read()
start = text.index("const PILLS")
pills = text[start : text.index("];", start)]
pills = text[start : text.index("] as const", start)]
order = [
pills.index(f'"{name}"')
for name in (
"follow",
"gradeline",
"earthwork",
"gentle",
"safe",
"crossings",
"cost",
"weighted",
)
for name in ("follow", "gradeline", "earthwork", "gentle", "weighted")
]
assert order == sorted(order) # 알약 순서
assert order == sorted(order) # 알약 순서 · 다섯(PLAN 77-1)
for gone in ("safe", "crossings", "cost"):
assert f'"{gone}"' not in pills
assert '"short"' not in pills and '"balance"' not in pills
assert (
"grid-template-columns: repeat(4"
"grid-template-columns: repeat(5"
in open(
os.path.join(
HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Style_RouteRecommend.css"
@@ -337,13 +330,14 @@ def test_같은_노선_알림_줄(results):
six = results["sixth"]
assert six["similarTwo"] == (
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — "
"토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)"
"토공 최소 · 기울기 최소와 거의 같은 노선(가장 먼 거리 6.1m)"
)
assert six["similarOne"] == (
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 안전 우선과 거의 같은 노선(가장 먼 거리 6.1m)"
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 영선 노선과 거의 같은 노선(가장 먼 거리 6.1m)"
)
assert six["similarHidden"] == "" # 화면에서 뺀 갈래만이면 줄 숨김(PLAN 77-1)
# 띠 폭이 없어도 줄은 선다 · 받침 있는 이름은 「과」
assert "폭" not in six["similarNoBand"] and "공사비 어림 최소와 거의" in six["similarNoBand"]
assert "폭" not in six["similarNoBand"] and "비중 계산과 거의" in six["similarNoBand"]
# 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음
assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == ""
assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목(옛 short 포함)은 버림