/* ============================================================================= * 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 = { 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]; /** 공통 제약 겹침 네 칸 — 서버 몸통 이름 그대로(계획서 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]; /** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 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; /** 제약 못 지킨 구간 — 서버 `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; } /** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */ export interface InitialChoice { logic: RouteInitialLogic; weights: RouteInitialWeights | null; snapshot: RouteEditSnapshot; metrics: InitialMetrics | null; criteria: InitialCriteria | null; /** 화면 제약 칸에 **사용자가 직접 넣은** 겹침 값만(비운 칸은 안 담김). [확인]·재열기에 쓴다. */ criteriaOverrides: RouteInitialCriteria | null; /** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */ terrainBoundPct?: number | null; } const num = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; const pick = (source: Record, 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 | 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; 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(""), })); } /** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). * `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; 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; const c = (body.criteria ?? {}) as Record; const over = (m.over_limit ?? {}) as Record; 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"), violations: Array.isArray(m.violations) ? (m.violations as Array>).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, 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"), } : 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; const out: RouteInitialCriteria = {}; for (const key of CRITERIA_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 CRITERIA_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; 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; 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 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(" · "); } /** 공통 제약 기준값 한 줄 — 서버가 실제로 쓴 값(계획서 20 확정 · 화면 칸에서 바꿀 수 있음). */ 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")}`, ]; // 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다. 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("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); }