Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts
T
eomsangdonandClaude Sonnet 5.5 58ba4545f2 feat(B05): 노선 추천 알약 여덟(영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소) · 영선 목표 기울기 칸 · 비교값 두 묶음 (PLAN 23-5b)
- 알약: 예상노선 추적 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중 계산 — 짧은 노선 · 흙 균형 뺌 · 네 칸 두 줄(좁으면 두 칸 네 줄)
- 영선 노선일 때만 「목표 기울기」 칸(criteria.target_grade_pct · 비우면 상한) · 응답에서 쓴 값을 기준값 줄에 보임
- 비교값 두 묶음: 노선 · 기울기(목표 기울기 편차 · 옆경사) / 흙 · 안전 · 비용(위험 구간 통과 · 계류 횡단 · 이격 · 공사비 어림 상대값)
- 옛 저장값 short · balance 는 예상노선 추적으로 읽음 · 시험: 새 이름 · 비교값 · 목표 기울기 · 옛 값 · 왕복에 새 넷

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 09:24:38 +09:00

554 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial_Model.ts
* 초기노선 갈래(계획서 20)의 **셈만** — 비중 맞추기 · 서버 답 펴기 · 글 만들기 · 캐시.
* 화면 배선은 `_RouteEdit_Initial` 몫이다(여기는 DOM 을 안 만진다 — 시험이 Node 로 돈다).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type {
RouteInitialCriteria,
RouteInitialLogic,
RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
type Vertex = [number, number];
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
follow: "예상노선 추적",
gradeline: "영선 노선",
earthwork: "토공 최소",
gentle: "기울기 순한",
safe: "안전 우선",
crossings: "계류 횡단 최소",
cost: "공사비 어림 최소",
weighted: "비중 계산",
};
/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */
export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, grade: 25 };
export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const;
export type WeightKey = (typeof WEIGHT_KEYS)[number];
/** 공통 제약 겹침 네 칸 — 서버 몸통 이름 그대로(계획서 20 확정). */
export const CRITERIA_KEYS = [
"max_grade_pct",
"min_radius_m",
"stream_offset_m",
"grade_smooth_m",
] 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,
key: WeightKey,
value: number,
): RouteInitialWeights {
const fixed = Math.min(100, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
const [first, second] = WEIGHT_KEYS.filter((name) => name !== key);
const rest = 100 - fixed;
const pool = weights[first] + weights[second];
const firstShare = pool > 0 ? Math.round((rest * weights[first]) / pool) : Math.round(rest / 2);
const out = { ...weights, [key]: fixed, [first]: firstShare, [second]: rest - firstShare };
return { expected: out.expected, earthwork: out.earthwork, grade: out.grade };
}
/** 비중마다 고정 표시 — 고정한 값은 다른 비중을 움직여도 안 바뀐다. */
export type WeightLocks = Record<WeightKey, boolean>;
export const NO_WEIGHT_LOCKS: WeightLocks = { expected: false, earthwork: false, grade: false };
const lockedKeys = (locks: WeightLocks): WeightKey[] => WEIGHT_KEYS.filter((name) => locks[name]);
/** 나머지 둘이 고정돼 저절로 정해지는 비중 — 없으면 null. 이 비중은 잠김으로 보인다. */
export function derivedWeightKey(locks: WeightLocks): WeightKey | null {
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
return free.length === 1 ? free[0] : null;
}
/** 이 비중의 고정 단추를 새로 누를 수 있나 — 셋 다 고정은 막는다(마지막 하나는 저절로 잠김).
* 이미 고정한 것을 푸는 것은 늘 된다. */
export function canLockWeight(locks: WeightLocks, key: WeightKey): boolean {
return locks[key] || lockedKeys(locks).length < 2;
}
/** 슬라이더로 한 비중을 옮긴다 — 고정한 것은 그대로, 안 고정한 것끼리 합 100 을 맞춘다.
* 고정하지 않은 것이 셋이면 나머지 둘이 지금 비율대로(`rebalanceWeights`), 둘이면 다른 하나가 남는 몫을
* 받고, 이 비중 혼자면(나머지 둘 고정) 값이 정해져 있어 안 움직인다. 값은 남는 몫 안으로 잘린다. */
export function moveWeight(
weights: RouteInitialWeights,
locks: WeightLocks,
key: WeightKey,
value: number,
): RouteInitialWeights {
if (locks[key]) return { ...weights };
const fixedSum = lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
const room = 100 - fixedSum;
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
if (free.length === WEIGHT_KEYS.length) return rebalanceWeights(weights, key, value);
const out = { ...weights };
if (free.length === 1) {
out[key] = room;
return out;
}
const other = free.find((name) => name !== key)!;
const next = Math.min(room, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
out[key] = next;
out[other] = room - next;
return out;
}
/** 값 칸에 직접 넣은 숫자 — 0~100 정수이고 고정 규칙 아래 합 100 을 맞출 수 있을 때만 받는다.
* 못 맞추면 `ok: false`(칸에 빨간 테두리 · 서버에 안 보냄). 고정한 비중은 고칠 수 없다. */
export function inputWeight(
weights: RouteInitialWeights,
locks: WeightLocks,
key: WeightKey,
value: number,
): { ok: boolean; weights: RouteInitialWeights } {
const same = { ok: false, weights: { ...weights } };
if (locks[key] || !Number.isInteger(value) || value < 0 || value > 100) return same;
const room = 100 - lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
if (free.length === 1) return value === room ? { ok: true, weights: { ...weights } } : same;
if (value > room) return same;
return { ok: true, weights: moveWeight(weights, locks, key, value) };
}
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
export interface InitialMetrics {
lengthM: number | null;
maxGradePct: number | null;
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;
offsetM: number | null;
/** 제약 못 지킨 구간 — 서버 `metrics.violations`. 칸이 없으면 null(옛 답). */
violations: InitialViolation[] | null;
}
/** 제약 못 지킨 구간 하나 — 종단기울기 · 곡선반지름 · 계류 이격. */
export interface InitialViolation {
kind: string;
station: string;
value: number | null;
limit: number | null;
reason: string;
}
/** 공통 제약 기준값 — 서버가 실제로 쓴 값(기본값이든 화면 겹침이든, 계획서 20 확정). */
export interface InitialCriteria {
maxGradePct: number | null;
minRadiusM: number | null;
streamOffsetM: number | null;
/** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */
gradeSmoothM: number | null;
/** 영선 노선이 실제로 쓴 목표 기울기(%) — 서버 `target_grade_pct`. */
targetGradePct: number | null;
}
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
export interface InitialChoice {
logic: RouteInitialLogic;
weights: RouteInitialWeights | null;
snapshot: RouteEditSnapshot;
metrics: InitialMetrics | null;
criteria: InitialCriteria | null;
/** 화면 제약 칸에 **사용자가 직접 넣은** 겹침 값만(비운 칸은 안 담김). [확인]·재열기에 쓴다. */
criteriaOverrides: RouteInitialCriteria | null;
/** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */
terrainBoundPct?: number | null;
/** 이 노선과 거의 같은(10 m 안) 다른 갈래 — 서버 `similar`. 없으면 null(옛 답 · 묶음 없음). */
similar?: SimilarRoute[] | null;
/** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */
bandWidthM?: number | null;
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
export interface SimilarRoute {
logic: RouteInitialLogic;
gapM: number;
}
const num = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const pick = (source: Record<string, unknown>, name: string): number | null => num(source[name]);
/** 노드 목록을 편집값 한 벌로 편다 — 곡선 성분이 있으면 `/route/plan` 과 같은 규칙으로. */
function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
if (Array.isArray(curves) && curves.length) {
const flat = flattenServerPlan(nodes as never, curves as never);
return { ...flat, picked: -1 };
}
const rows = nodes as Array<Record<string, unknown> | Vertex>;
const planned = rows.map((row): Vertex =>
Array.isArray(row) ? [row[0], row[1]] : [Number(row.x), Number(row.y)],
);
return {
planned,
curveOn: rows.map((row) => (Array.isArray(row) ? true : row.curve !== false)),
curveRadius: rows.map((row) => (Array.isArray(row) ? null : num(row.radius_m))),
curveLock: planned.map(() => null),
curveArc: planned.map(() => null),
apexLock: planned.map(() => false),
picked: -1,
};
}
/** 서버 `/route/plan` 의 `initial_route`(전처리 초기값 노선)를 편집값으로 편다. 없거나 모자라면 null. */
export function initialRouteSnapshot(initialRoute: unknown): RouteEditSnapshot | null {
const body = (initialRoute ?? {}) as Record<string, unknown>;
if (!Array.isArray(body.nodes) || body.nodes.length < 2) return null;
return snapshotOf(body.nodes, body.curves);
}
/** 미리 보기 지도 한 겹 — 점 목록과 종류. */
export interface MapLayer {
kind: "expected" | "current" | "preview";
points: Vertex[];
}
/** 미리 보기 지도의 SVG 선 — 모든 겹이 들어가게 폭 `width` · 높이 `height`(px) 안에 맞춘다.
* 북쪽이 위(y 뒤집기) · 가로세로 같은 축척 · 가장자리 `pad`. 점이 없으면 빈 목록. */
export function mapPaths(
layers: MapLayer[],
width: number,
height: number,
pad = 12,
): Array<{ kind: MapLayer["kind"]; d: string }> {
const all = layers.flatMap((layer) => layer.points);
if (!all.length) return [];
const xs = all.map(([x]) => x);
const ys = all.map(([, y]) => y);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
const spanX = Math.max(1e-6, Math.max(...xs) - minX);
const spanY = Math.max(1e-6, Math.max(...ys) - minY);
const scale = Math.min((width - 2 * pad) / spanX, (height - 2 * pad) / spanY);
// 남는 쪽은 가운데로.
const offX = (width - spanX * scale) / 2;
const offY = (height - spanY * scale) / 2;
return layers
.filter((layer) => layer.points.length >= 2)
.map((layer) => ({
kind: layer.kind,
d: layer.points
.map(
([x, y], index) =>
`${index ? "L" : "M"}${(offX + (x - minX) * scale).toFixed(1)} ${(height - offY - (y - minY) * scale).toFixed(1)}`,
)
.join(""),
}));
}
/** 서버 `similar` — 갈래 이름을 아는 것만 담는다. 목록이 아니면 null · 빈 목록이면 빈 배열. */
function parseSimilar(raw: unknown): SimilarRoute[] | null {
if (!Array.isArray(raw)) return null;
const out: SimilarRoute[] = [];
for (const item of raw as Array<Record<string, unknown>>) {
const logic = typeof item?.logic === "string" ? item.logic : "";
const gap = num(item?.max_gap_m);
if (logic in LOGIC_LABEL && gap !== null)
out.push({ logic: logic as RouteInitialLogic, gapM: gap });
}
return out;
}
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다).
* `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */
export function normalizeRouteInitial(
response: unknown,
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteriaOverrides: RouteInitialCriteria | null = null,
): InitialChoice {
// 모양은 계획서 20 「API」 줄 — 노드 · 곡선은 `/route/plan` 과 같은 꼴이다.
const body = (response ?? {}) as Record<string, unknown>;
const snapshot = snapshotOf(Array.isArray(body.nodes) ? body.nodes : [], body.curves);
if (snapshot.planned.length < 2 || snapshot.planned.some(([x, y]) => !Number.isFinite(x + y))) {
throw new Error("서버가 낸 초기노선에 노드가 모자랍니다.");
}
const m = (body.metrics ?? {}) as Record<string, unknown>;
const c = (body.criteria ?? {}) as Record<string, unknown>;
const over = (m.over_limit ?? {}) as Record<string, unknown>;
return {
logic,
weights,
snapshot,
metrics: body.metrics
? {
lengthM: pick(m, "length_m"),
maxGradePct: pick(m, "max_grade_pct"),
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"),
violations: Array.isArray(m.violations)
? (m.violations as Array<Record<string, unknown>>).map((item) => ({
kind: String(item.kind ?? ""),
station: String(item.station ?? ""),
value: num(item.value),
limit: num(item.limit),
reason: String(item.reason ?? ""),
}))
: null,
}
: null,
terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null,
similar: parseSimilar(body.similar),
bandWidthM: num(body.band_width_m),
criteria: body.criteria
? {
maxGradePct: pick(c, "max_grade_pct"),
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,
};
}
/** 이름만 아는 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 `current_initial` 의 꼴. */
export interface InitialBase {
logic: RouteInitialLogic;
/** 비중 계산일 때만. 나머지 갈래는 null. */
weights: RouteInitialWeights | null;
/** 사용자가 그때 화면 칸에 넣었던 제약 겹침만(비운 칸은 안 담김). */
criteria: RouteInitialCriteria | null;
}
/** 겹침 값 원시 객체를 편다 — 유한수인 칸만 담는다. 빈 결과는 null. */
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 OVERRIDE_KEYS) {
const value = num(source[key]);
if (value !== null) out[key] = value;
}
return Object.keys(out).length ? out : null;
}
/** 두 겹침 값이 같은가 — 없는 칸은 서로 같다고 본다. */
function criteriaEqual(a: RouteInitialCriteria | null, b: RouteInitialCriteria | null): boolean {
return OVERRIDE_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null));
}
/** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추적. */
export function serverBase(server: unknown): InitialBase {
const body = (server ?? {}) as Record<string, unknown>;
const logic =
typeof body.logic === "string" && body.logic in LOGIC_LABEL
? (body.logic as RouteInitialLogic)
: "follow";
const criteria = parseCriteriaOverrides(body.criteria);
if (logic !== "weighted") return { logic, weights: null, criteria };
const raw = (body.weights ?? {}) as Record<string, unknown>;
const weights = {
expected: Math.round(Number(raw.expected)),
earthwork: Math.round(Number(raw.earthwork)),
grade: Math.round(Number(raw.grade)),
};
const valid = WEIGHT_KEYS.every((key) => Number.isFinite(weights[key]));
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria };
}
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중 · 같은 제약 겹침인가. */
export function sameBase(choice: InitialChoice, base: InitialBase): boolean {
if (choice.logic !== base.logic) return false;
if (
base.logic === "weighted" &&
!WEIGHT_KEYS.every((key) => choice.weights?.[key] === base.weights?.[key])
) {
return false;
}
return criteriaEqual(choice.criteriaOverrides, base.criteria);
}
/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */
export function choiceLabel(logic: RouteInitialLogic, weights: RouteInitialWeights | null): string {
if (logic !== "weighted" || !weights) return LOGIC_LABEL[logic];
return `${LOGIC_LABEL.weighted} ${weights.expected}/${weights.earthwork}/${weights.grade}`;
}
const fixed = (value: number | null, digits: number, unit: string): string =>
value === null
? "—"
: `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`;
/** 남는 흙(절토 − 성토) — 부호를 늘 붙여 + 남음 / − 모자람으로 읽힌다. */
export function surplusText(surplusM3: number): string {
const rounded = Math.round(surplusM3);
if (rounded === 0) return "0㎥(균형)";
const sign = rounded > 0 ? "+" : "−";
return `${sign}${Math.abs(rounded).toLocaleString("ko-KR")}㎥(${rounded > 0 ? "남음" : "모자람"})`;
}
/** 조사 「과 / 와」 — 받침이 있으면 과. */
const withParticle = (name: string): string => {
const code = name.charCodeAt(name.length - 1);
const hasFinal = code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0;
return `${name}${hasFinal ? "과" : "와"}`;
};
/** 같은 노선 알림 한 줄 — 이 지형은 제약 안에서 갈 길이 한 띠뿐일 때. 없으면 빈 글(줄을 숨긴다).
* 「이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 토공 최소 · 짧은 노선과 거의 같은 노선(가장 먼 거리 6.1m)」 */
export function similarLine(choice: InitialChoice | null): string {
const similar = choice?.similar;
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));
const band =
choice.bandWidthM === null || choice.bandWidthM === undefined
? ""
: `폭 ${fixed(choice.bandWidthM, 1, "m")} `;
return `이 지형은 제약 안에서 갈 길이 ${band}한 띠뿐 — ${withParticle(names)} 거의 같은 노선(가장 먼 거리 ${fixed(gap, 1, "m")})`;
}
/** 같은 노선 알림 아래 흐린 글 — 제약을 바꾸면 갈래가 벌어질 수 있다. */
export const SIMILAR_HINT =
"기울기 · 최소R · 계류 제약 값을 바꾸면 갈래가 서로 벌어질 수 있습니다(다시 셈).";
/** 결과 비교값 — 두 묶음. 노선 · 기울기 / 흙 · 안전 · 비용. 서버가 안 준 칸(옛 답)은 빠진다. */
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, "%")}`,
...(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")}`,
].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,
/** 영선 노선일 때만 — 서버가 쓴 목표 기울기를 함께 보인다. */
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")} 고름`);
return `공통 제약 ${parts.join(" · ")}`;
}
/** 목록 한 줄에 적을 구간 수 — 넘으면 「외 N곳」(전부는 글상자 풍선에). */
const VIOLATION_SHOWN = 5;
/** 구간 하나 — 「No.9 29.1%(지형상 불가능)」. 사유는 「 — 」 앞 머리만. */
export function violationText(item: InitialViolation): string {
const value =
item.kind === "grade"
? fixed(item.value, 1, "%")
: item.kind === "radius"
? `R ${fixed(item.value, 1, "m")}`
: item.kind === "stream"
? `계류 ${fixed(item.value, 1, "m")}`
: fixed(item.value, 1, "");
const head = item.reason.split(" — ")[0].trim();
return `${item.station} ${value}${head ? `(${head})` : ""}`;
}
/** 제약 못 지킨 구간 한 줄 — 없으면 「모두 지킴」. 서버가 목록을 안 줬으면 빈 글. */
export function violationsLine(metrics: InitialMetrics | null): string {
const items = metrics?.violations;
if (!items) return "";
if (!items.length) return "제약 못 지킨 구간 없음 — 모두 지킴";
const shown = items.slice(0, VIOLATION_SHOWN).map(violationText).join(" · ");
const more = items.length > VIOLATION_SHOWN ? ` 외 ${items.length - VIOLATION_SHOWN}곳` : "";
return `제약 못 지킨 구간 ${items.length}곳: ${shown}${more}`;
}
/** 상한을 풀었을 때만 — 「지형상 14% 로 못 이어져 상한을 23.5% 로 풂」. 아니면 빈 글. */
export function relaxedLine(choice: InitialChoice | null): string {
const bound = choice?.terrainBoundPct;
if (bound === null || bound === undefined) return "";
const limit = fixed(choice?.criteria?.maxGradePct ?? null, 1, "%");
return `지형상 ${limit} 로 못 이어져 상한을 ${fixed(bound, 1, "%")} 로 풂`;
}
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추적). */
export function readInitialChoice(projectId: string): InitialChoice | null {
const saved = readState<InitialChoice>("routeedit-initial", projectId);
if (!saved || !saved.snapshot || !Array.isArray(saved.snapshot.planned)) return null;
if (!(saved.logic in LOGIC_LABEL) || saved.snapshot.planned.length < 2) return null;
return saved;
}
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
writeState("routeedit-initial", choice, projectId);
}