- /route/plan 의 current_initial {logic, weights} 를 읽어 이름 · 비중 슬라이더에 보임(없으면 예상노선 추종)
- 캐시가 서버와 다른 갈래 · 비중이면 캐시를 버림(서버가 정본 — [확인] 때만 적힘)
- 노드 없이 이름만 아는 초기값의 [초기화]는 그 갈래를 서버에 물어 노드를 실음
- 시험 test_b05_routeedit_initial.py — 서버 초기값 펴기 · 캐시 견주기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
215 lines
9.4 KiB
TypeScript
215 lines
9.4 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Initial_Model.ts
|
|
* 초기노선 갈래(계획서 20)의 **셈만** — 비중 맞추기 · 서버 답 펴기 · 글 만들기 · 캐시.
|
|
* 화면 배선은 `_RouteEdit_Initial` 몫이다(여기는 DOM 을 안 만진다 — 시험이 Node 로 돈다).
|
|
* ========================================================================== */
|
|
|
|
import { readState, writeState } from "../A00_Common/b_page_state";
|
|
import type { 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: "예상노선 추종",
|
|
earthwork: "토공 최소",
|
|
gentle: "기울기 순한 노선",
|
|
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];
|
|
|
|
/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 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 };
|
|
}
|
|
|
|
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
|
|
export interface InitialMetrics {
|
|
lengthM: number | null;
|
|
maxGradePct: number | null;
|
|
meanGradePct: number | null;
|
|
cutM3: number | null;
|
|
fillM3: number | null;
|
|
overLimitCount: number | null;
|
|
offsetM: number | null;
|
|
}
|
|
|
|
/** 공통 제약 기준값 — 서버가 준 값. `pending` 이 남아 `confirmed` 가 거짓이면 지식DB 후보. */
|
|
export interface InitialCriteria {
|
|
maxGradePct: number | null;
|
|
minRadiusM: number | null;
|
|
streamOffsetM: number | null;
|
|
confirmed: boolean;
|
|
}
|
|
|
|
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
|
|
export interface InitialChoice {
|
|
logic: RouteInitialLogic;
|
|
weights: RouteInitialWeights | null;
|
|
snapshot: RouteEditSnapshot;
|
|
metrics: InitialMetrics | null;
|
|
criteria: InitialCriteria | null;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). */
|
|
export function normalizeRouteInitial(
|
|
response: unknown,
|
|
logic: RouteInitialLogic,
|
|
weights: RouteInitialWeights | 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"),
|
|
overLimitCount: pick(over, "total"),
|
|
offsetM: pick(m, "offset_avg_m"),
|
|
}
|
|
: null,
|
|
criteria: body.criteria
|
|
? {
|
|
maxGradePct: pick(c, "max_grade_pct"),
|
|
minRadiusM: pick(c, "min_radius_m"),
|
|
streamOffsetM: pick(c, "stream_offset_m"),
|
|
// 확정 대기 칸 이름 목록이 비어야 확정이다.
|
|
confirmed: Array.isArray(c.pending) && c.pending.length === 0,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/** 이름만 아는 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 `current_initial` 의 꼴. */
|
|
export interface InitialBase {
|
|
logic: RouteInitialLogic;
|
|
/** 비중 계산일 때만. 나머지 갈래는 null. */
|
|
weights: RouteInitialWeights | null;
|
|
}
|
|
|
|
/** 서버 `current_initial {logic, weights}` 를 편다. 모르는 값이면 예상노선 추종. */
|
|
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";
|
|
if (logic !== "weighted") return { logic, weights: null };
|
|
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 } };
|
|
}
|
|
|
|
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중인가. */
|
|
export function sameBase(choice: InitialChoice, base: InitialBase): boolean {
|
|
if (choice.logic !== base.logic) return false;
|
|
if (base.logic !== "weighted") return true;
|
|
return WEIGHT_KEYS.every((key) => choice.weights?.[key] === base.weights?.[key]);
|
|
}
|
|
|
|
/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */
|
|
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 metricsLine(metrics: InitialMetrics | null): string {
|
|
if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다.";
|
|
return [
|
|
`연장 ${fixed(metrics.lengthM, 1, "m")}`,
|
|
`기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`,
|
|
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
|
|
`기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`,
|
|
`이격 ${fixed(metrics.offsetM, 1, "m")}`,
|
|
].join(" · ");
|
|
}
|
|
|
|
/** 공통 제약 기준값 한 줄 — 서버 값만 보인다. 서버 갈래가 없으면 `/route/plan` 의 최소 R 만. */
|
|
export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): 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")}`,
|
|
];
|
|
const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)";
|
|
return `공통 제약 ${parts.join(" · ")}${tail}`;
|
|
}
|
|
|
|
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 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);
|
|
}
|