- 알약: 예상노선 추적 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중 계산 — 짧은 노선 · 흙 균형 뺌 · 네 칸 두 줄(좁으면 두 칸 네 줄) - 영선 노선일 때만 「목표 기울기」 칸(criteria.target_grade_pct · 비우면 상한) · 응답에서 쓴 값을 기준값 줄에 보임 - 비교값 두 묶음: 노선 · 기울기(목표 기울기 편차 · 옆경사) / 흙 · 안전 · 비용(위험 구간 통과 · 계류 횡단 · 이격 · 공사비 어림 상대값) - 옛 저장값 short · balance 는 예상노선 추적으로 읽음 · 시험: 새 이름 · 비교값 · 목표 기울기 · 옛 값 · 왕복에 새 넷 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
453 lines
16 KiB
JavaScript
453 lines
16 KiB
JavaScript
/* 계획노선 편집 창 **초기노선 갈래**(계획서 20) 셈 단위검증 헬퍼 —
|
|
* `B05_Profile_UI_RouteEdit_Initial_Model.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
|
*
|
|
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다.
|
|
* 브라우저 전용인 저장소는 가짜로 채운다 — 셈만 본다.
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
|
|
|
const ROOT = path.join(__dirname, "..", "..");
|
|
const cache = new Map();
|
|
|
|
function memoryStorage() {
|
|
const map = new Map();
|
|
return {
|
|
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
|
setItem: (key, value) => map.set(key, String(value)),
|
|
removeItem: (key) => map.delete(key),
|
|
key: (index) => [...map.keys()][index] ?? null,
|
|
get length() {
|
|
return map.size;
|
|
},
|
|
_map: map,
|
|
};
|
|
}
|
|
const session = memoryStorage();
|
|
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
|
|
globalThis.fetch = () => Promise.resolve({ ok: false });
|
|
|
|
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
|
|
|
|
function loadTs(file) {
|
|
const full = path.resolve(file);
|
|
if (cache.has(full)) return cache.get(full);
|
|
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
|
}).outputText;
|
|
const box = { exports: {} };
|
|
cache.set(full, box.exports);
|
|
const localRequire = (request) => {
|
|
if (STUBS[request]) return STUBS[request];
|
|
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
|
return require(request);
|
|
};
|
|
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
|
cache.set(full, box.exports);
|
|
return box.exports;
|
|
}
|
|
|
|
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
|
|
const { DEFAULT_WEIGHTS } = model;
|
|
|
|
// ① 비중 맞추기 — 하나를 옮기면 나머지 둘이 비율대로 나눠 합 100.
|
|
const moves = [
|
|
model.rebalanceWeights(DEFAULT_WEIGHTS, "expected", 70),
|
|
model.rebalanceWeights(DEFAULT_WEIGHTS, "earthwork", 40),
|
|
model.rebalanceWeights({ expected: 100, earthwork: 0, grade: 0 }, "expected", 60),
|
|
model.rebalanceWeights({ expected: 20, earthwork: 30, grade: 50 }, "grade", 100),
|
|
model.rebalanceWeights({ expected: 33, earthwork: 33, grade: 34 }, "earthwork", 33.4),
|
|
model.rebalanceWeights(DEFAULT_WEIGHTS, "grade", -5),
|
|
];
|
|
|
|
// ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다.
|
|
const plainResponse = {
|
|
nodes: [
|
|
{ x: 0, y: 0 },
|
|
{ x: 50, y: 10, curve: true, radius_m: 30 },
|
|
{ x: 100, y: 0, curve: false },
|
|
],
|
|
// 계획서 20 「API」 줄 모양.
|
|
metrics: {
|
|
length_m: 1234.56,
|
|
max_grade_pct: 12.34,
|
|
avg_grade_pct: 6.1,
|
|
cut_m3: 1500.4,
|
|
fill_m3: 900,
|
|
over_limit: { grade: 1, radius: 0, stream: 1, total: 2 },
|
|
offset_avg_m: 3.25,
|
|
offset_max_m: 9.8,
|
|
raw_max_grade_pct: 41.2,
|
|
violations: [
|
|
{
|
|
kind: "grade",
|
|
from_m: 180,
|
|
to_m: 220,
|
|
station: "No.9+0.0",
|
|
value: 29.13,
|
|
limit: 14,
|
|
reason: "지형상 불가능 — 복도 안 어떤 길도 23.5% 이상 기울기를 지나야 이어짐",
|
|
},
|
|
{
|
|
kind: "radius",
|
|
from_m: 400,
|
|
to_m: 412,
|
|
station: "No.20+0.0",
|
|
value: 9.5,
|
|
limit: 12,
|
|
reason: "곡선 둘 자리 모자람 — 앞뒤 직선이 짧아 최소 반지름 원호가 안 들어감",
|
|
},
|
|
{
|
|
kind: "stream",
|
|
from_m: 600,
|
|
to_m: 700,
|
|
station: "No.30+0.0",
|
|
value: 32,
|
|
limit: 100,
|
|
reason: "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 넘음",
|
|
},
|
|
],
|
|
},
|
|
criteria: {
|
|
max_grade_pct: 14,
|
|
min_radius_m: 12,
|
|
stream_offset_m: 5,
|
|
road_width_m: 4,
|
|
corridor_m: 100,
|
|
grade_smooth_m: 5,
|
|
// 기준값 확정(계획서 20) — 서버가 이제 늘 빈 목록으로 준다.
|
|
pending: [],
|
|
},
|
|
relaxed: true,
|
|
terrain_bound_pct: 23.46,
|
|
};
|
|
const plain = model.normalizeRouteInitial(plainResponse, "earthwork", null);
|
|
// 화면 칸에 겹침을 넣어 물었을 때 — `criteriaOverrides` 로 그대로 남는다(빈 칸은 안 담김).
|
|
const withOverrides = model.normalizeRouteInitial(plainResponse, "earthwork", null, {
|
|
stream_offset_m: 50,
|
|
});
|
|
const planShape = model.normalizeRouteInitial(
|
|
{
|
|
nodes: [
|
|
{ x: 0, y: 0, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] },
|
|
{ x: 40, y: 30, radius_m: 25, inner_angle_deg: 140, tangent_m: 9, violations: [] },
|
|
{ x: 90, y: 30, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] },
|
|
],
|
|
curves: [
|
|
{
|
|
apex: [40, 30],
|
|
radius_m: 25,
|
|
tangent_m: 9,
|
|
inner_angle_deg: 140,
|
|
start: [33, 25],
|
|
end: [49, 30],
|
|
node_first: 1,
|
|
node_last: 1,
|
|
violations: [],
|
|
},
|
|
],
|
|
},
|
|
"weighted",
|
|
{ expected: 60, earthwork: 20, grade: 20 },
|
|
);
|
|
let tooShort = "";
|
|
try {
|
|
model.normalizeRouteInitial({ nodes: [{ x: 1, y: 1 }] }, "gentle", null);
|
|
} catch (error) {
|
|
tooShort = error.message;
|
|
}
|
|
|
|
// ③ 글 — 현재 초기값 · 비교값 · 기준값.
|
|
const labels = {
|
|
follow: model.choiceLabel("follow", null),
|
|
weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, grade: 20 }),
|
|
metrics: model.metricsLines(plain.metrics),
|
|
metricsNone: model.metricsLines(null).route,
|
|
criteria: model.criteriaLine(plain.criteria, 0),
|
|
criteriaFallback: model.criteriaLine(null, 15),
|
|
violations: model.violationsLine(plain.metrics),
|
|
violationsNone: model.violationsLine({ ...plain.metrics, violations: [] }),
|
|
violationsOld: model.violationsLine({ ...plain.metrics, violations: null }),
|
|
violationsMany: model.violationsLine({
|
|
...plain.metrics,
|
|
violations: Array.from({ length: 7 }, (_, i) => ({
|
|
kind: "grade",
|
|
station: `No.${i}+0.0`,
|
|
value: 15 + i,
|
|
limit: 14,
|
|
reason: "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음",
|
|
})),
|
|
}),
|
|
relaxed: model.relaxedLine(plain),
|
|
notRelaxed: model.relaxedLine(planShape),
|
|
};
|
|
|
|
// ⑤ 서버가 적어 둔 현재 초기값(`current_initial`) — 펴기와 캐시 견주기(제약 겹침 포함).
|
|
const bases = {
|
|
follow: model.serverBase({
|
|
logic: "follow",
|
|
weights: { expected: 100, earthwork: 0, grade: 0 },
|
|
criteria: null,
|
|
}),
|
|
weighted: model.serverBase({
|
|
logic: "weighted",
|
|
weights: { expected: 70.0, earthwork: 15.0, grade: 15.0 },
|
|
criteria: { stream_offset_m: 50.0 },
|
|
}),
|
|
unknown: model.serverBase({ logic: "nope" }),
|
|
none: model.serverBase(null),
|
|
};
|
|
const sameAsServer = {
|
|
earthVsFollow: model.sameBase(plain, bases.follow),
|
|
earthVsEarth: model.sameBase(plain, model.serverBase({ logic: "earthwork", criteria: null })),
|
|
// 같은 갈래라도 그때 넣었던 제약 겹침이 다르면(캐시엔 없음, 서버는 50m) 다른 것으로 본다.
|
|
earthVsEarthDiffCriteria: model.sameBase(
|
|
withOverrides,
|
|
model.serverBase({ logic: "earthwork", criteria: null }),
|
|
),
|
|
weightedSame: model.sameBase(
|
|
planShape,
|
|
model.serverBase({ logic: "weighted", weights: { expected: 60, earthwork: 20, grade: 20 } }),
|
|
),
|
|
weightedOther: model.sameBase(planShape, bases.weighted),
|
|
};
|
|
|
|
// ⑥ 전처리 초기값 노선(`/route/plan` 의 `initial_route`)과 미리 보기 지도 선.
|
|
const initialRoute = {
|
|
ok: model.initialRouteSnapshot({
|
|
nodes: [
|
|
{ x: 0, y: 0 },
|
|
{ x: 10, y: 5, radius_m: 20 },
|
|
],
|
|
curves: [],
|
|
}),
|
|
none: model.initialRouteSnapshot(undefined),
|
|
tooShort: model.initialRouteSnapshot({ nodes: [{ x: 0, y: 0 }], curves: [] }),
|
|
};
|
|
const mapLines = {
|
|
fit: model.mapPaths(
|
|
[
|
|
{ kind: "expected", points: [[0, 0], [100, 0]] },
|
|
{ kind: "preview", points: [[0, 0], [100, 50]] },
|
|
],
|
|
460,
|
|
340,
|
|
),
|
|
empty: model.mapPaths([], 460, 340),
|
|
oneDot: model.mapPaths([{ kind: "preview", points: [[5, 5]] }], 460, 340),
|
|
};
|
|
|
|
// ⑦ 되돌리기 · 다시하기가 걸음마다 실은 「현재 추천」을 되살린다(노선과 짝).
|
|
const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_History.ts"));
|
|
const step = (x, rec) => ({
|
|
planned: [[x, 0], [x + 1, 1]],
|
|
curveOn: [true, true],
|
|
curveRadius: [null, null],
|
|
curveLock: [null, null],
|
|
curveArc: [null, null],
|
|
apexLock: [false, false],
|
|
picked: -1,
|
|
rec,
|
|
});
|
|
const trail = history.createRouteEditHistory(step(0, { logic: "follow" }));
|
|
trail.commit(step(1, { logic: "earthwork" }));
|
|
trail.commit(step(2, null));
|
|
const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map((one) => one.rec);
|
|
|
|
// ⑧ 비중 고정(계획서 26-2c) — 고정한 값은 안 움직이고 나머지가 합 100 을 맞춘다.
|
|
const W = { expected: 70, earthwork: 15, grade: 15 };
|
|
const L = (...keys) => ({ expected: false, earthwork: false, grade: false, ...Object.fromEntries(keys.map((k) => [k, true])) });
|
|
const sweep = [];
|
|
for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected", "grade")]) {
|
|
for (const key of ["expected", "earthwork", "grade"]) {
|
|
for (let v = -5; v <= 105; v += 5) {
|
|
const out = model.moveWeight(W, locks, key, v);
|
|
sweep.push({
|
|
sum: out.expected + out.earthwork + out.grade,
|
|
lockedSame: Object.keys(locks).every((k) => !locks[k] || out[k] === W[k]),
|
|
range: [out.expected, out.earthwork, out.grade].every((n) => Number.isInteger(n) && n >= 0 && n <= 100),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const weightLocks = {
|
|
freeThree: model.moveWeight(W, L(), "expected", 40),
|
|
lockExpected: model.moveWeight(W, L("expected"), "earthwork", 20),
|
|
lockExpectedClamp: model.moveWeight(W, L("expected"), "earthwork", 90),
|
|
twoLocked: model.moveWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 50),
|
|
movingLocked: model.moveWeight(W, L("expected"), "expected", 10),
|
|
derived: [model.derivedWeightKey(L()), model.derivedWeightKey(L("expected")), model.derivedWeightKey(L("expected", "earthwork"))],
|
|
canLock: {
|
|
none: model.canLockWeight(L(), "grade"),
|
|
one: model.canLockWeight(L("expected"), "grade"),
|
|
two: model.canLockWeight(L("expected", "earthwork"), "grade"),
|
|
unlockOfTwo: model.canLockWeight(L("expected", "earthwork"), "expected"),
|
|
},
|
|
input: {
|
|
free: model.inputWeight(W, L(), "earthwork", 55),
|
|
lockedOk: model.inputWeight(W, L("expected"), "earthwork", 20),
|
|
lockedTooBig: model.inputWeight(W, L("expected"), "earthwork", 40),
|
|
fraction: model.inputWeight(W, L(), "earthwork", 1.5),
|
|
negative: model.inputWeight(W, L(), "earthwork", -1),
|
|
over: model.inputWeight(W, L(), "earthwork", 101),
|
|
nan: model.inputWeight(W, L(), "earthwork", Number.NaN),
|
|
onLocked: model.inputWeight(W, L("earthwork"), "earthwork", 15),
|
|
derivedSame: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 10),
|
|
derivedOther: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 30),
|
|
},
|
|
};
|
|
|
|
// ⑨ 추천 갈래 여덟 · 새 비교값 · 영선 목표 기울기 · 같은 노선 알림(계획서 23-5b 화면).
|
|
const newMetrics = {
|
|
side_slope_avg_pct: 65.8,
|
|
side_slope_max_pct: 104.1,
|
|
hazard_m: 458.4,
|
|
stream_crossings: 0,
|
|
stream_offset_avg_m: 312.44,
|
|
grade_dev_pct: 8.53,
|
|
cost_index: 9691.4,
|
|
surplus_m3: -578.2,
|
|
};
|
|
const withExtras = (extra, metricExtra = {}, logic = "gradeline") =>
|
|
model.normalizeRouteInitial(
|
|
{ ...plainResponse, ...extra, metrics: { ...plainResponse.metrics, ...metricExtra } },
|
|
logic,
|
|
null,
|
|
);
|
|
const gradeline = withExtras(
|
|
{ criteria: { ...plainResponse.criteria, target_grade_pct: 12 } },
|
|
newMetrics,
|
|
);
|
|
const sixth = {
|
|
labels: Object.fromEntries(
|
|
["follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"].map((k) => [
|
|
k,
|
|
model.LOGIC_LABEL[k],
|
|
]),
|
|
),
|
|
removedLogics: ["short", "balance"].map((k) => k in model.LOGIC_LABEL),
|
|
// 옛 저장값(short · balance)은 예상노선 추적으로 읽는다 — 서버 기억 · 캐시 둘 다.
|
|
legacyBase: ["short", "balance"].map((k) => model.serverBase({ logic: k })),
|
|
legacyCache: undefined, // 아래 ④ 캐시 왕복 뒤에 채운다(저장소를 같이 쓰므로)
|
|
surplus: [122.4, -578.2, 0, 0.4].map((v) => model.surplusText(v)),
|
|
groups: model.metricsLines(gradeline.metrics),
|
|
groupsMinus: model.metricsLines(withExtras({}, { surplus_m3: -578 }).metrics),
|
|
groupsOld: model.metricsLines(withExtras({}).metrics),
|
|
criteriaTarget: model.criteriaLine(gradeline.criteria, 0, true),
|
|
criteriaNoTarget: model.criteriaLine(gradeline.criteria, 0, false),
|
|
targetParsed: gradeline.criteria.targetGradePct,
|
|
targetOverride: model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 })
|
|
.criteriaOverrides,
|
|
targetBase: model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }),
|
|
sameTarget: [
|
|
model.sameBase(
|
|
model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 }),
|
|
model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }),
|
|
),
|
|
model.sameBase(
|
|
model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 12 }),
|
|
model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }),
|
|
),
|
|
],
|
|
similarTwo: model.similarLine(
|
|
withExtras({
|
|
band_width_m: 13.5,
|
|
similar: [
|
|
{ logic: "earthwork", max_gap_m: 0.0 },
|
|
{ logic: "gentle", max_gap_m: 6.1 },
|
|
],
|
|
}),
|
|
),
|
|
similarOne: model.similarLine(
|
|
withExtras({ band_width_m: 13.54, similar: [{ logic: "safe", max_gap_m: 6.1 }] }),
|
|
),
|
|
similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "cost", max_gap_m: 9.1 }] })),
|
|
similarEmpty: model.similarLine(withExtras({ band_width_m: 13.5, similar: [] })),
|
|
similarMissing: model.similarLine(withExtras({})),
|
|
similarNull: model.similarLine(null),
|
|
similarUnknown: withExtras({
|
|
similar: [
|
|
{ logic: "nope", max_gap_m: 1 },
|
|
{ logic: "short", max_gap_m: 2 },
|
|
{ logic: "cost", max_gap_m: "x" },
|
|
],
|
|
}).similar,
|
|
bandParsed: withExtras({ band_width_m: 13.5 }).bandWidthM,
|
|
hint: model.SIMILAR_HINT,
|
|
weightedViolation: model.violationsLine({
|
|
...plain.metrics,
|
|
violations: [
|
|
{
|
|
kind: "grade",
|
|
station: "No.9+0.0",
|
|
value: 29.1,
|
|
limit: 14,
|
|
reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)",
|
|
},
|
|
],
|
|
}),
|
|
weightedViolationReason: model.violationText({
|
|
kind: "grade",
|
|
station: "No.9+0.0",
|
|
value: 29.1,
|
|
limit: 14,
|
|
reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음",
|
|
}),
|
|
};
|
|
|
|
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
|
|
model.writeInitialChoice("p-1", plain);
|
|
const readBack = model.readInitialChoice("p-1");
|
|
const other = model.readInitialChoice("p-2");
|
|
model.writeInitialChoice("p-3", withOverrides);
|
|
const readBackOverrides = model.readInitialChoice("p-3");
|
|
const keys = [...session._map.keys()];
|
|
session._map.set(
|
|
keys[0],
|
|
JSON.stringify({
|
|
logic: "nope",
|
|
snapshot: {
|
|
planned: [
|
|
[0, 0],
|
|
[1, 1],
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
const badLogic = model.readInitialChoice("p-1");
|
|
model.writeInitialChoice("p-1", null);
|
|
const cleared = model.readInitialChoice("p-1");
|
|
|
|
// 옛 저장값(short)이 캐시에 있어도 읽지 않는다 — 서버 기억(추적)을 쓴다.
|
|
session._map.clear();
|
|
session.setItem(
|
|
"aislo:result:routeedit-initial:p-9",
|
|
JSON.stringify({ logic: "short", weights: null, snapshot: { planned: [[0, 0], [1, 1]] } }),
|
|
);
|
|
sixth.legacyCache = model.readInitialChoice("p-9");
|
|
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
moves,
|
|
plain,
|
|
planShape,
|
|
tooShort,
|
|
labels,
|
|
readBack,
|
|
readBackOverrides,
|
|
other,
|
|
keys,
|
|
badLogic,
|
|
cleared,
|
|
bases,
|
|
sameAsServer,
|
|
initialRoute,
|
|
mapLines,
|
|
historyRecs,
|
|
sweep,
|
|
weightLocks,
|
|
sixth,
|
|
}),
|
|
);
|