/* 계획노선 편집 창 **초기노선 갈래**(계획서 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, }), );