feat(B05): 노선 추천 알약 여덟(영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소) · 영선 목표 기울기 칸 · 비교값 두 묶음 (PLAN 23-5b)
- 알약: 예상노선 추적 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중 계산 — 짧은 노선 · 흙 균형 뺌 · 네 칸 두 줄(좁으면 두 칸 네 줄) - 영선 노선일 때만 「목표 기울기」 칸(criteria.target_grade_pct · 비우면 상한) · 응답에서 쓴 값을 기준값 줄에 보임 - 비교값 두 묶음: 노선 · 기울기(목표 기울기 편차 · 옆경사) / 흙 · 안전 · 비용(위험 구간 통과 · 계류 횡단 · 이격 · 공사비 어림 상대값) - 옛 저장값 short · balance 는 예상노선 추적으로 읽음 · 시험: 새 이름 · 비교값 · 목표 기울기 · 옛 값 · 왕복에 새 넷 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */
|
||||
export type RouteInitialLogic =
|
||||
"follow" | "earthwork" | "gentle" | "short" | "balance" | "weighted";
|
||||
"follow" | "gradeline" | "earthwork" | "gentle" | "safe" | "crossings" | "cost" | "weighted";
|
||||
|
||||
/** 비중 계산의 세 비중(%) — 합 100. */
|
||||
export interface RouteInitialWeights {
|
||||
@@ -32,6 +32,8 @@ export interface RouteInitialCriteria {
|
||||
min_radius_m?: number;
|
||||
stream_offset_m?: number;
|
||||
grade_smooth_m?: number;
|
||||
/** 영선 노선(`gradeline`)의 목표 종단기울기(%) — 비우면 종단기울기 상한. */
|
||||
target_grade_pct?: number;
|
||||
}
|
||||
|
||||
export interface RouteInitialRequest {
|
||||
|
||||
@@ -17,10 +17,12 @@ type Vertex = [number, number];
|
||||
|
||||
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
|
||||
follow: "예상노선 추적",
|
||||
gradeline: "영선 노선",
|
||||
earthwork: "토공 최소",
|
||||
gentle: "기울기 순한",
|
||||
short: "짧은 노선",
|
||||
balance: "흙 균형",
|
||||
safe: "안전 우선",
|
||||
crossings: "계류 횡단 최소",
|
||||
cost: "공사비 어림 최소",
|
||||
weighted: "비중 계산",
|
||||
};
|
||||
|
||||
@@ -38,6 +40,12 @@ export const CRITERIA_KEYS = [
|
||||
] as const;
|
||||
export type CriteriaKey = (typeof CRITERIA_KEYS)[number];
|
||||
|
||||
/** 영선 노선 목표 기울기 칸 — 공통 제약 네 칸과 따로(영선을 고를 때만 보이고 보낸다). */
|
||||
export const TARGET_GRADE_KEY = "target_grade_pct" as const;
|
||||
|
||||
/** 겹침 값에 담길 수 있는 모든 키 — 캐시 · 서버 기억 견주기에 쓴다. */
|
||||
const OVERRIDE_KEYS = [...CRITERIA_KEYS, TARGET_GRADE_KEY] as const;
|
||||
|
||||
/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */
|
||||
export function rebalanceWeights(
|
||||
weights: RouteInitialWeights,
|
||||
@@ -122,6 +130,18 @@ export interface InitialMetrics {
|
||||
meanGradePct: number | null;
|
||||
cutM3: number | null;
|
||||
fillM3: number | null;
|
||||
/** 옆경사(지형이 노선 옆으로 기운 정도, %) 평균 · 최대. */
|
||||
sideSlopeAvgPct: number | null;
|
||||
sideSlopeMaxPct: number | null;
|
||||
/** 위험 구간(옆경사 35° 이상 · 계류 버퍼 안)을 지나는 길이(m). */
|
||||
hazardM: number | null;
|
||||
/** 계류를 건너는 횟수 · 계류와의 평균 이격(m). */
|
||||
streamCrossings: number | null;
|
||||
streamOffsetAvgM: number | null;
|
||||
/** 측점 기울기와 영선 목표 기울기 차의 평균(%p). */
|
||||
gradeDevPct: number | null;
|
||||
/** 공사비 어림 — 절성토 · 연장 · 계류 횡단을 섞은 **상대값**(단위 없음 · 실제 공사비 아님). */
|
||||
costIndex: number | null;
|
||||
/** 남는 흙(㎥) = 절토 − 성토 — + 남음 / − 모자람. 서버 `surplus_m3`. */
|
||||
surplusM3: number | null;
|
||||
overLimitCount: number | null;
|
||||
@@ -146,6 +166,8 @@ export interface InitialCriteria {
|
||||
streamOffsetM: number | null;
|
||||
/** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */
|
||||
gradeSmoothM: number | null;
|
||||
/** 영선 노선이 실제로 쓴 목표 기울기(%) — 서버 `target_grade_pct`. */
|
||||
targetGradePct: number | null;
|
||||
}
|
||||
|
||||
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
|
||||
@@ -284,6 +306,13 @@ export function normalizeRouteInitial(
|
||||
meanGradePct: pick(m, "avg_grade_pct"),
|
||||
cutM3: pick(m, "cut_m3"),
|
||||
fillM3: pick(m, "fill_m3"),
|
||||
sideSlopeAvgPct: pick(m, "side_slope_avg_pct"),
|
||||
sideSlopeMaxPct: pick(m, "side_slope_max_pct"),
|
||||
hazardM: pick(m, "hazard_m"),
|
||||
streamCrossings: pick(m, "stream_crossings"),
|
||||
streamOffsetAvgM: pick(m, "stream_offset_avg_m"),
|
||||
gradeDevPct: pick(m, "grade_dev_pct"),
|
||||
costIndex: pick(m, "cost_index"),
|
||||
surplusM3: pick(m, "surplus_m3"),
|
||||
overLimitCount: pick(over, "total"),
|
||||
offsetM: pick(m, "offset_avg_m"),
|
||||
@@ -307,6 +336,7 @@ export function normalizeRouteInitial(
|
||||
minRadiusM: pick(c, "min_radius_m"),
|
||||
streamOffsetM: pick(c, "stream_offset_m"),
|
||||
gradeSmoothM: pick(c, "grade_smooth_m"),
|
||||
targetGradePct: pick(c, "target_grade_pct"),
|
||||
}
|
||||
: null,
|
||||
criteriaOverrides,
|
||||
@@ -327,7 +357,7 @@ function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
const out: RouteInitialCriteria = {};
|
||||
for (const key of CRITERIA_KEYS) {
|
||||
for (const key of OVERRIDE_KEYS) {
|
||||
const value = num(source[key]);
|
||||
if (value !== null) out[key] = value;
|
||||
}
|
||||
@@ -336,7 +366,7 @@ function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null {
|
||||
|
||||
/** 두 겹침 값이 같은가 — 없는 칸은 서로 같다고 본다. */
|
||||
function criteriaEqual(a: RouteInitialCriteria | null, b: RouteInitialCriteria | null): boolean {
|
||||
return CRITERIA_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null));
|
||||
return OVERRIDE_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null));
|
||||
}
|
||||
|
||||
/** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추적. */
|
||||
@@ -414,27 +444,61 @@ export function similarLine(choice: InitialChoice | null): string {
|
||||
export const SIMILAR_HINT =
|
||||
"기울기 · 최소R · 계류 제약 값을 바꾸면 갈래가 서로 벌어질 수 있습니다(다시 셈).";
|
||||
|
||||
/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */
|
||||
export function metricsLine(metrics: InitialMetrics | null): string {
|
||||
if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다.";
|
||||
return [
|
||||
/** 결과 비교값 — 두 묶음. 노선 · 기울기 / 흙 · 안전 · 비용. 서버가 안 준 칸(옛 답)은 빠진다. */
|
||||
export interface MetricsLines {
|
||||
route: string;
|
||||
work: string;
|
||||
}
|
||||
|
||||
const NO_METRICS = "비교값 — 알약을 누르면 서버가 셈해 보입니다.";
|
||||
|
||||
export function metricsLines(metrics: InitialMetrics | null): MetricsLines {
|
||||
if (!metrics) return { route: NO_METRICS, work: "" };
|
||||
const has = (value: number | null): value is number => value !== null;
|
||||
const route = [
|
||||
`연장 ${fixed(metrics.lengthM, 1, "m")}`,
|
||||
`기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`,
|
||||
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
|
||||
...(metrics.surplusM3 === null ? [] : [`남는 흙 ${surplusText(metrics.surplusM3)}`]),
|
||||
...(has(metrics.gradeDevPct)
|
||||
? [`목표 기울기 편차 ${fixed(metrics.gradeDevPct, 1, "%p")}`]
|
||||
: []),
|
||||
...(has(metrics.sideSlopeAvgPct)
|
||||
? [
|
||||
`옆경사 평균 ${fixed(metrics.sideSlopeAvgPct, 1, "%")} · 최대 ${fixed(metrics.sideSlopeMaxPct, 1, "%")}`,
|
||||
]
|
||||
: []),
|
||||
`기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`,
|
||||
`이격 ${fixed(metrics.offsetM, 1, "m")}`,
|
||||
`예상노선과 이격 ${fixed(metrics.offsetM, 1, "m")}`,
|
||||
].join(" · ");
|
||||
const work = [
|
||||
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
|
||||
...(has(metrics.surplusM3) ? [`남는 흙 ${surplusText(metrics.surplusM3)}`] : []),
|
||||
...(has(metrics.hazardM) ? [`위험 구간 통과 ${fixed(metrics.hazardM, 0, "m")}`] : []),
|
||||
...(has(metrics.streamCrossings) ? [`계류 횡단 ${metrics.streamCrossings}회`] : []),
|
||||
...(has(metrics.streamOffsetAvgM)
|
||||
? [`계류 이격 평균 ${fixed(metrics.streamOffsetAvgM, 1, "m")}`]
|
||||
: []),
|
||||
...(has(metrics.costIndex)
|
||||
? [`공사비 어림 ${fixed(metrics.costIndex, 0, "")}(어림 · 상대값)`]
|
||||
: []),
|
||||
].join(" · ");
|
||||
return { route, work };
|
||||
}
|
||||
|
||||
/** 공통 제약 기준값 한 줄 — 서버가 실제로 쓴 값(계획서 20 확정 · 화면 칸에서 바꿀 수 있음). */
|
||||
export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string {
|
||||
export function criteriaLine(
|
||||
criteria: InitialCriteria | null,
|
||||
planMinRadiusM: number,
|
||||
/** 영선 노선일 때만 — 서버가 쓴 목표 기울기를 함께 보인다. */
|
||||
showTarget = false,
|
||||
): string {
|
||||
const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null);
|
||||
const parts = [
|
||||
`종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`,
|
||||
`최소 R ${fixed(radius, 0, "m")}`,
|
||||
`계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`,
|
||||
];
|
||||
if (showTarget && criteria?.targetGradePct != null)
|
||||
parts.push(`영선 목표 기울기 ${fixed(criteria.targetGradePct, 1, "%")}`);
|
||||
// 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다.
|
||||
if (criteria?.gradeSmoothM)
|
||||
parts.push(`기울기 잴 지반 ${fixed(criteria.gradeSmoothM, 0, "m")} 고름`);
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
criteriaLine,
|
||||
CRITERIA_KEYS,
|
||||
LOGIC_LABEL,
|
||||
metricsLine,
|
||||
metricsLines,
|
||||
TARGET_GRADE_KEY,
|
||||
canLockWeight,
|
||||
derivedWeightKey,
|
||||
inputWeight,
|
||||
@@ -48,10 +49,12 @@ type Vertex = [number, number];
|
||||
|
||||
const PILLS: RouteInitialLogic[] = [
|
||||
"follow",
|
||||
"gradeline",
|
||||
"earthwork",
|
||||
"gentle",
|
||||
"short",
|
||||
"balance",
|
||||
"safe",
|
||||
"crossings",
|
||||
"cost",
|
||||
"weighted",
|
||||
];
|
||||
|
||||
@@ -59,8 +62,10 @@ const PILL_TITLE: Record<RouteInitialLogic, string> = {
|
||||
follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다",
|
||||
earthwork: "절토 · 성토가 가장 적은 노선",
|
||||
gentle: "종단기울기가 가장 순한 노선",
|
||||
short: "연장이 가장 짧은 노선(제약 지킴)",
|
||||
balance: "절토와 성토가 비슷해 남는 흙이 가장 적은 노선",
|
||||
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) — 목표 기울기를 넣을 수 있음",
|
||||
safe: "급한 옆경사 · 계류 가까운 곳을 피하는 노선",
|
||||
crossings: "계류를 건너는 횟수가 가장 적은 노선",
|
||||
cost: "토공 + 연장 + 계류 횡단을 섞은 공사비 어림(상대값)이 가장 낮은 노선",
|
||||
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선",
|
||||
};
|
||||
|
||||
@@ -200,11 +205,16 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}</label>`,
|
||||
).join("")}
|
||||
</div>
|
||||
<label class="b05-routerec__target" data-target-row hidden
|
||||
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
|
||||
<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__lines">
|
||||
<div data-line="metrics"></div>
|
||||
<div data-line="metrics-work"></div>
|
||||
<div data-line="violations" hidden></div>
|
||||
<div class="is-warn" data-line="relaxed" hidden></div>
|
||||
<div data-line="similar" hidden></div>
|
||||
@@ -228,7 +238,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
|
||||
|
||||
criteriaInputs.forEach((input) => {
|
||||
const value = params.criteria?.[input.dataset.criteria as CriteriaKey];
|
||||
const value =
|
||||
params.criteria?.[input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY];
|
||||
input.value = value === undefined ? "" : String(value);
|
||||
});
|
||||
|
||||
@@ -236,10 +247,13 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
function currentCriteria(): RouteInitialCriteria {
|
||||
const out: RouteInitialCriteria = {};
|
||||
criteriaInputs.forEach((input) => {
|
||||
const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY;
|
||||
// 목표 기울기는 영선 노선을 물을 때만 보낸다.
|
||||
if (key === TARGET_GRADE_KEY && logic !== "gradeline") return;
|
||||
const raw = input.value.trim();
|
||||
if (raw === "") return;
|
||||
const value = Number(raw);
|
||||
if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value;
|
||||
if (Number.isFinite(value)) out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -290,19 +304,26 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
q<HTMLElement>("[data-weight-sum]").textContent =
|
||||
`합 ${weights.expected + weights.earthwork + weights.grade}`;
|
||||
criteriaInputs.forEach((input) => {
|
||||
const key = input.dataset.criteria as CriteriaKey;
|
||||
const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY;
|
||||
// 목표 기울기를 비우면 종단기울기 상한을 쓴다 — 그 값이 자리표시.
|
||||
const known =
|
||||
key === TARGET_GRADE_KEY ? params.defaults.max_grade_pct : params.defaults[key];
|
||||
input.placeholder =
|
||||
params.defaults[key] !== undefined ? String(params.defaults[key]) : "기본값";
|
||||
known !== undefined ? String(known) : key === TARGET_GRADE_KEY ? "상한" : "기본값";
|
||||
input.disabled = busy;
|
||||
});
|
||||
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
|
||||
rerun.hidden = !dirty;
|
||||
rerun.disabled = busy || invalid.size > 0;
|
||||
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
|
||||
const shown = metricsLines(preview?.metrics ?? null);
|
||||
line("metrics").textContent = preview
|
||||
? preview.metrics
|
||||
? metricsLine(preview.metrics)
|
||||
? shown.route
|
||||
: "비교값 — 서버 답에 없습니다."
|
||||
: "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다.";
|
||||
line("metrics-work").textContent = preview ? shown.work : "";
|
||||
line("metrics-work").hidden = !line("metrics-work").textContent;
|
||||
const violations = violationsLine(preview?.metrics ?? null);
|
||||
line("violations").textContent = violations;
|
||||
line("violations").title = (preview?.metrics?.violations ?? [])
|
||||
@@ -318,7 +339,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
line("similar-hint").textContent = similar ? SIMILAR_HINT : "";
|
||||
line("similar-hint").hidden = !similar;
|
||||
line("criteria").textContent = preview
|
||||
? criteriaLine(preview.criteria, params.planMinRadiusM)
|
||||
? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline")
|
||||
: "";
|
||||
line("criteria").hidden = !preview;
|
||||
drawMap();
|
||||
|
||||
@@ -26,14 +26,32 @@
|
||||
}
|
||||
|
||||
/* 알약 — 하나만 고른 상태(라디오 묶음). */
|
||||
/* 여덟 알약 — 네 칸 두 줄로 고르게(좁은 폭은 두 칸 네 줄). */
|
||||
.b05-routerec__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 좁은 폭 — 두 줄을 지키려 글자를 줄이고, 글자가 잘릴 만큼 좁으면(560px 이하) 두 칸 네 줄. */
|
||||
@media (max-width: 720px) {
|
||||
.b05-routerec__pill {
|
||||
padding-inline: 4px;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.b05-routerec__pills {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.b05-routerec__pill {
|
||||
padding: 8px var(--spacing-16);
|
||||
padding: 8px var(--spacing-8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
@@ -245,7 +263,8 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routerec__criteria label {
|
||||
.b05-routerec__criteria label,
|
||||
.b05-routerec__target {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 5.5em 1.5em;
|
||||
align-items: center;
|
||||
@@ -253,7 +272,8 @@
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-routerec__criteria input {
|
||||
.b05-routerec__criteria input,
|
||||
.b05-routerec__target input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -264,7 +284,8 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.b05-routerec__criteria input::placeholder {
|
||||
.b05-routerec__criteria input::placeholder,
|
||||
.b05-routerec__target input::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -318,3 +339,7 @@
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.b05-routerec__target[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -162,8 +162,8 @@ try {
|
||||
const labels = {
|
||||
follow: model.choiceLabel("follow", null),
|
||||
weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, grade: 20 }),
|
||||
metrics: model.metricsLine(plain.metrics),
|
||||
metricsNone: model.metricsLine(null),
|
||||
metrics: model.metricsLines(plain.metrics),
|
||||
metricsNone: model.metricsLines(null).route,
|
||||
criteria: model.criteriaLine(plain.criteria, 0),
|
||||
criteriaFallback: model.criteriaLine(null, 15),
|
||||
violations: model.violationsLine(plain.metrics),
|
||||
@@ -298,21 +298,58 @@ const weightLocks = {
|
||||
},
|
||||
};
|
||||
|
||||
// ⑨ 추천 갈래 여섯 · 남는 흙 · 같은 노선 알림(계획서 23-4c 화면).
|
||||
const withExtras = (extra, metricExtra = {}) =>
|
||||
// ⑨ 추천 갈래 여덟 · 새 비교값 · 영선 목표 기울기 · 같은 노선 알림(계획서 23-5b 화면).
|
||||
const newMetrics = {
|
||||
side_slope_avg_pct: 65.8,
|
||||
side_slope_max_pct: 104.1,
|
||||
hazard_m: 458.4,
|
||||
stream_crossings: 0,
|
||||
stream_offset_avg_m: 312.44,
|
||||
grade_dev_pct: 8.53,
|
||||
cost_index: 9691.4,
|
||||
surplus_m3: -578.2,
|
||||
};
|
||||
const withExtras = (extra, metricExtra = {}, logic = "gradeline") =>
|
||||
model.normalizeRouteInitial(
|
||||
{ ...plainResponse, ...extra, metrics: { ...plainResponse.metrics, ...metricExtra } },
|
||||
"short",
|
||||
logic,
|
||||
null,
|
||||
);
|
||||
const gradeline = withExtras(
|
||||
{ criteria: { ...plainResponse.criteria, target_grade_pct: 12 } },
|
||||
newMetrics,
|
||||
);
|
||||
const sixth = {
|
||||
labels: Object.fromEntries(
|
||||
["follow", "earthwork", "gentle", "short", "balance", "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)은 예상노선 추적으로 읽는다 — 서버 기억 · 캐시 둘 다.
|
||||
legacyBase: ["short", "balance"].map((k) => model.serverBase({ logic: k })),
|
||||
legacyCache: undefined, // 아래 ④ 캐시 왕복 뒤에 채운다(저장소를 같이 쓰므로)
|
||||
surplus: [122.4, -578.2, 0, 0.4].map((v) => model.surplusText(v)),
|
||||
metricsPlus: model.metricsLine(withExtras({}, { surplus_m3: 122.4 }).metrics),
|
||||
metricsMinus: model.metricsLine(withExtras({}, { surplus_m3: -578 }).metrics),
|
||||
metricsNone: model.metricsLine(withExtras({}).metrics),
|
||||
groups: model.metricsLines(gradeline.metrics),
|
||||
groupsMinus: model.metricsLines(withExtras({}, { surplus_m3: -578 }).metrics),
|
||||
groupsOld: model.metricsLines(withExtras({}).metrics),
|
||||
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,
|
||||
targetBase: model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }),
|
||||
sameTarget: [
|
||||
model.sameBase(
|
||||
model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 }),
|
||||
model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }),
|
||||
),
|
||||
model.sameBase(
|
||||
model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 12 }),
|
||||
model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }),
|
||||
),
|
||||
],
|
||||
similarTwo: model.similarLine(
|
||||
withExtras({
|
||||
band_width_m: 13.5,
|
||||
@@ -323,13 +360,19 @@ const sixth = {
|
||||
}),
|
||||
),
|
||||
similarOne: model.similarLine(
|
||||
withExtras({ band_width_m: 13.54, similar: [{ logic: "balance", max_gap_m: 6.1 }] }),
|
||||
withExtras({ band_width_m: 13.54, similar: [{ logic: "safe", max_gap_m: 6.1 }] }),
|
||||
),
|
||||
similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "weighted", max_gap_m: 9.1 }] })),
|
||||
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),
|
||||
similarUnknown: withExtras({ similar: [{ logic: "nope", max_gap_m: 1 }, { logic: "short", max_gap_m: "x" }] }).similar,
|
||||
similarUnknown: withExtras({
|
||||
similar: [
|
||||
{ logic: "nope", max_gap_m: 1 },
|
||||
{ logic: "short", max_gap_m: 2 },
|
||||
{ logic: "cost", max_gap_m: "x" },
|
||||
],
|
||||
}).similar,
|
||||
bandParsed: withExtras({ band_width_m: 13.5 }).bandWidthM,
|
||||
hint: model.SIMILAR_HINT,
|
||||
weightedViolation: model.violationsLine({
|
||||
@@ -351,8 +394,6 @@ const sixth = {
|
||||
limit: 14,
|
||||
reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음",
|
||||
}),
|
||||
shortLabel: model.choiceLabel("short", { expected: 0, earthwork: 0, grade: 0 }),
|
||||
balanceLabel: model.choiceLabel("balance", null),
|
||||
};
|
||||
|
||||
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
|
||||
@@ -378,6 +419,14 @@ const badLogic = model.readInitialChoice("p-1");
|
||||
model.writeInitialChoice("p-1", null);
|
||||
const cleared = model.readInitialChoice("p-1");
|
||||
|
||||
// 옛 저장값(short)이 캐시에 있어도 읽지 않는다 — 서버 기억(추적)을 쓴다.
|
||||
session._map.clear();
|
||||
session.setItem(
|
||||
"aislo:result:routeedit-initial:p-9",
|
||||
JSON.stringify({ logic: "short", weights: null, snapshot: { planned: [[0, 0], [1, 1]] } }),
|
||||
);
|
||||
sixth.legacyCache = model.readInitialChoice("p-9");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
moves,
|
||||
|
||||
@@ -81,7 +81,7 @@ const out = jobs.map((job) => {
|
||||
},
|
||||
},
|
||||
label: model.choiceLabel(choice.logic, choice.weights),
|
||||
metrics: model.metricsLine(choice.metrics),
|
||||
metrics: model.metricsLines(choice.metrics),
|
||||
violations: model.violationsLine(choice.metrics),
|
||||
relaxed: model.relaxedLine(choice),
|
||||
criteria: model.criteriaLine(choice.criteria, 0),
|
||||
|
||||
@@ -77,11 +77,12 @@ def test_비교값과_기준값_글(results):
|
||||
labels = results["labels"]
|
||||
assert labels["follow"] == "예상노선 추적"
|
||||
assert labels["weighted"] == "비중 계산 60/20/20"
|
||||
assert labels["metrics"] == (
|
||||
"연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥"
|
||||
" · 기준 초과 2구간 · 이격 3.3m"
|
||||
)
|
||||
assert "로직을 누르면" in labels["metricsNone"]
|
||||
# 두 묶음 — 노선 · 기울기 / 흙 · 안전 · 비용(서버가 안 준 새 칸은 빠짐)
|
||||
assert labels["metrics"] == {
|
||||
"route": "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 기준 초과 2구간 · 예상노선과 이격 3.3m",
|
||||
"work": "절토 1,500㎥ · 성토 900㎥",
|
||||
}
|
||||
assert "알약을 누르면" in labels["metricsNone"]
|
||||
# 기준값 확정(2026-09-29) — 「후보 · 사용자 확정 대기」 꼬리표가 더는 안 붙는다.
|
||||
assert labels["criteria"] == (
|
||||
"공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m · 기울기 잴 지반 5m 고름"
|
||||
@@ -248,36 +249,88 @@ def test_비중_값_칸_직접_입력(results):
|
||||
}
|
||||
|
||||
|
||||
def test_추천_갈래_여섯_이름과_가중치_없음(results):
|
||||
def test_추천_갈래_여덟_이름_알약_순서(results):
|
||||
six = results["sixth"]
|
||||
assert six["labels"] == {
|
||||
"follow": "예상노선 추적",
|
||||
"gradeline": "영선 노선",
|
||||
"earthwork": "토공 최소",
|
||||
"gentle": "기울기 순한",
|
||||
"short": "짧은 노선",
|
||||
"balance": "흙 균형",
|
||||
"safe": "안전 우선",
|
||||
"crossings": "계류 횡단 최소",
|
||||
"cost": "공사비 어림 최소",
|
||||
"weighted": "비중 계산",
|
||||
}
|
||||
# 짧은 노선 · 흙 균형은 비중이 없다(weights 0/0/0 이 와도 이름만)
|
||||
assert six["shortLabel"] == "짧은 노선" and six["balanceLabel"] == "흙 균형"
|
||||
# 짧은 노선 · 흙 균형은 뺐다
|
||||
assert six["removedLogics"] == [False, False]
|
||||
page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts")
|
||||
with open(page, encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
start = text.index("const PILLS")
|
||||
pills = text[start : text.index("];", start)]
|
||||
order = [
|
||||
text.index(f'"{name}"') for name in ("follow", "earthwork", "gentle", "short", "balance")
|
||||
pills.index(f'"{name}"')
|
||||
for name in (
|
||||
"follow",
|
||||
"gradeline",
|
||||
"earthwork",
|
||||
"gentle",
|
||||
"safe",
|
||||
"crossings",
|
||||
"cost",
|
||||
"weighted",
|
||||
)
|
||||
]
|
||||
assert order == sorted(order) # 알약 순서: 추적 · 토공 · 기울기 · 짧은 · 균형 · 비중
|
||||
assert order == sorted(order) # 알약 순서
|
||||
assert '"short"' not in pills and '"balance"' not in pills
|
||||
assert (
|
||||
"grid-template-columns: repeat(4"
|
||||
in open(
|
||||
os.path.join(
|
||||
HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Style_RouteRecommend.css"
|
||||
),
|
||||
encoding="utf-8",
|
||||
).read()
|
||||
) # 네 칸 두 줄
|
||||
|
||||
|
||||
def test_비교값_남는_흙(results):
|
||||
def test_옛_저장값_short_balance는_추적으로_읽음(results):
|
||||
six = results["sixth"]
|
||||
assert six["legacyBase"] == [
|
||||
{"logic": "follow", "weights": None, "criteria": None},
|
||||
{"logic": "follow", "weights": None, "criteria": None},
|
||||
]
|
||||
assert six["legacyCache"] is None # 캐시의 옛 갈래는 버림 → 서버 기억(추적)을 씀
|
||||
|
||||
|
||||
def test_비교값_새_칸_두_묶음(results):
|
||||
six = results["sixth"]
|
||||
assert six["surplus"] == ["+122㎥(남음)", "−578㎥(모자람)", "0㎥(균형)", "0㎥(균형)"]
|
||||
assert six["metricsPlus"].endswith(
|
||||
"성토 900㎥ · 남는 흙 +122㎥(남음) · 기준 초과 2구간 · 이격 3.3m"
|
||||
groups = six["groups"]
|
||||
assert groups["route"] == (
|
||||
"연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 목표 기울기 편차 8.5%p"
|
||||
" · 옆경사 평균 65.8% · 최대 104.1% · 기준 초과 2구간 · 예상노선과 이격 3.3m"
|
||||
)
|
||||
assert "남는 흙 −578㎥(모자람)" in six["metricsMinus"]
|
||||
# 서버가 안 주면 칸이 없다(옛 답) — 기존 비교값 글 그대로
|
||||
assert "남는 흙" not in six["metricsNone"]
|
||||
assert groups["work"] == (
|
||||
"절토 1,500㎥ · 성토 900㎥ · 남는 흙 −578㎥(모자람) · 위험 구간 통과 458m"
|
||||
" · 계류 횡단 0회 · 계류 이격 평균 312.4m · 공사비 어림 9,691(어림 · 상대값)"
|
||||
)
|
||||
assert "남는 흙 −578㎥(모자람)" in six["groupsMinus"]["work"]
|
||||
# 서버가 안 준 칸(옛 답)은 빠진다
|
||||
assert "옆경사" not in six["groupsOld"]["route"] and "공사비" not in six["groupsOld"]["work"]
|
||||
|
||||
|
||||
def test_영선_목표_기울기(results):
|
||||
six = results["sixth"]
|
||||
assert six["targetParsed"] == 12
|
||||
assert six["criteriaTarget"].endswith("영선 목표 기울기 12.0% · 기울기 잴 지반 5m 고름") or (
|
||||
"영선 목표 기울기 12.0%" in six["criteriaTarget"]
|
||||
)
|
||||
assert "영선 목표" not in six["criteriaNoTarget"] # 영선이 아니면 안 보임
|
||||
# 요청에 실은 값은 겹침으로 남고 · 서버 기억과 견줘 다르면 캐시를 버림
|
||||
assert six["targetOverride"] == {"target_grade_pct": 10}
|
||||
assert six["targetBase"]["criteria"] == {"target_grade_pct": 10}
|
||||
assert six["sameTarget"] == [True, False]
|
||||
|
||||
|
||||
def test_같은_노선_알림_줄(results):
|
||||
@@ -287,13 +340,13 @@ def test_같은_노선_알림_줄(results):
|
||||
"토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)"
|
||||
)
|
||||
assert six["similarOne"] == (
|
||||
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 흙 균형과 거의 같은 노선(가장 먼 거리 6.1m)"
|
||||
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 안전 우선과 거의 같은 노선(가장 먼 거리 6.1m)"
|
||||
)
|
||||
# 띠 폭이 없어도 줄은 선다 · 받침 없는 이름은 「와」
|
||||
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"] == [] # 이름 · 거리를 모르는 항목은 버림
|
||||
assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목(옛 short 포함)은 버림
|
||||
assert six["bandParsed"] == 13.5
|
||||
assert "제약 값을 바꾸면" in six["hint"]
|
||||
|
||||
|
||||
@@ -43,9 +43,11 @@ LOGICS: list[tuple[str, dict[str, float] | None]] = [
|
||||
("follow", None),
|
||||
("earthwork", None),
|
||||
("gentle", None),
|
||||
# PLAN 23-5 — 서버는 짧은 노선 · 흙 균형을 빼고 넷(gradeline · safe · crossings · cost)을
|
||||
# 더함. 새 넷은 화면(23-5b)이 알약으로 알게 되면 여기 더함 — 지금 화면은 다시 열 때 모르는
|
||||
# 갈래를 추적으로 읽음. 서버 쪽 저장 · 다시 읽기는 `test_route_initial` 이 지킴.
|
||||
# PLAN 23-5 — 새 갈래 넷(영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소)도 같은 왕복.
|
||||
("gradeline", None),
|
||||
("safe", None),
|
||||
("crossings", None),
|
||||
("cost", None),
|
||||
("weighted", {"expected": 50, "earthwork": 25, "grade": 25}),
|
||||
]
|
||||
|
||||
@@ -202,7 +204,18 @@ def test_비교값이_응답에_다_있음(rounds):
|
||||
assert isinstance(metrics["over_limit"]["total"], int)
|
||||
assert isinstance(metrics["offset_avg_m"], (int, float))
|
||||
# 화면 한 줄에 빈 칸(—)이 없다.
|
||||
assert "—" not in item["applied"]["metrics"], item["applied"]["metrics"]
|
||||
shown = item["applied"]["metrics"]
|
||||
assert "—" not in shown["route"] + shown["work"], shown
|
||||
# 새 비교값 칸도 화면 글에 다 있음(계획서 23-5)
|
||||
for label in (
|
||||
"옆경사 평균",
|
||||
"목표 기울기 편차",
|
||||
"위험 구간 통과",
|
||||
"계류 횡단",
|
||||
"계류 이격 평균",
|
||||
"공사비 어림",
|
||||
):
|
||||
assert label in shown["route"] + shown["work"], (item["logic"], label)
|
||||
|
||||
|
||||
def test_제약_못_지킨_구간이_목록과_화면_줄에(rounds):
|
||||
|
||||
Reference in New Issue
Block a user