/* ============================================================================= * 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 = { follow: "예상노선 추종", earthwork: "토공 최소", gentle: "기울기 순한 노선", weighted: "비중 계산", }; /** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */ export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, slope: 25 }; export const WEIGHT_KEYS = ["expected", "earthwork", "slope"] 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, slope: out.slope }; } /** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 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; } /** 공통 제약 기준값 — 서버가 준 값. `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; /** 여러 이름 중 먼저 있는 숫자 — 서버 낱말이 정해지기 전이라 흔한 이름을 함께 본다. */ function pick(source: Record, ...names: string[]): number | null { for (const name of names) { const value = num(source[name]); if (value !== null) return value; } return null; } /** 노드 목록을 편집값 한 벌로 편다 — 곡선 성분이 있으면 `/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 | 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 { const body = (response ?? {}) as Record; const nodes = Array.isArray(body.nodes) ? body.nodes : Array.isArray(body.vertices) ? body.vertices : []; const snapshot = snapshotOf(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; const c = (body.criteria ?? {}) as Record; return { logic, weights, snapshot, metrics: body.metrics ? { lengthM: pick(m, "length_m", "total_length_m"), maxGradePct: pick(m, "max_grade_pct", "max_slope_pct"), meanGradePct: pick(m, "mean_grade_pct", "mean_slope_pct", "avg_grade_pct"), cutM3: pick(m, "cut_m3", "cut_volume_m3"), fillM3: pick(m, "fill_m3", "fill_volume_m3"), overLimitCount: pick(m, "over_limit_count", "violation_count", "exceed_count"), offsetM: pick(m, "offset_mean_m", "expected_offset_m", "offset_m"), } : null, criteria: body.criteria ? { maxGradePct: pick(c, "max_grade_pct", "max_slope_pct"), minRadiusM: pick(c, "min_radius_m"), streamOffsetM: pick(c, "stream_offset_m", "stream_clearance_m"), confirmed: c.confirmed === true, } : null, }; } /** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */ 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.slope}`; } 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("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); }