- 알약: 예상노선 추적 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중 계산 — 짧은 노선 · 흙 균형 뺌 · 네 칸 두 줄(좁으면 두 칸 네 줄) - 영선 노선일 때만 「목표 기울기」 칸(criteria.target_grade_pct · 비우면 상한) · 응답에서 쓴 값을 기준값 줄에 보임 - 비교값 두 묶음: 노선 · 기울기(목표 기울기 편차 · 옆경사) / 흙 · 안전 · 비용(위험 구간 통과 · 계류 횡단 · 이격 · 공사비 어림 상대값) - 옛 저장값 short · balance 는 예상노선 추적으로 읽음 · 시험: 새 이름 · 비교값 · 목표 기울기 · 옛 값 · 왕복에 새 넷 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
554 lines
25 KiB
TypeScript
554 lines
25 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 {
|
||
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);
|
||
}
|