feat(B05): 계획노선 편집 창 — 측점 사이 기울기 라벨 · 1m 등고선 켬끔 · 보던 화면 기억

- 측점 사이 종단기울기(지반고 %) 라벨 — 측점 간격이 화면 90px 이상일 때만 · 측점 라벨 맞은편 · 회전해도 글 바로 섬 · 끄는 동안 안 물음
- 「1m 등고선」 단추(기본 꺼짐 · LAS 없으면 잠김) — 1m 선 얇고 옅게 · 켰을 때 처음 한 번만 준비 · 5m 선은 한 단 짙게(--map-contour-major)
- 회전 · 가운데 · 배율을 화면 취향(routeedit-view · 프로젝트마다 · 계정에도 올라감)에 적고 다시 열면 그대로
- 700줄 한도 — 화면 셈·기억은 _View, 재기 배선은 _Measure, 바탕 읽기는 _Contour, 노드 끼우기·빼기는 _Edits 로 나눔
- 시험 test_b05_routeedit_view.py (창 크기 달라도 같은 자리 · 저장소 왕복 · 노드 편집값 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
This commit is contained in:
2026-09-29 09:33:57 +09:00
co-authored by Claude Opus 5.5
parent 1bba901834
commit 207c2e40eb
13 changed files with 840 additions and 115 deletions
@@ -0,0 +1,129 @@
/* 계획노선 편집 창 **보던 화면 기억**(계획서 18 ③)과 노드 끼우기·빼기 단위검증 헬퍼 —
* `B05_Profile_UI_RouteEdit_View.ts` · `_Edits.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();
// 브라우저 저장소 흉내 — `b_page_state` 가 읽고 쓰는 자리.
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 local = memoryStorage();
globalThis.window = { localStorage: local, sessionStorage: memoryStorage() };
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 viewMod = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_View.ts"));
const render = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapRender.ts"));
const edits = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Edits.ts"));
// 도엽 한 장 — 1200m × 900m 사업지 좌표.
const meta = {
x_min: 200000,
y_min: 500000,
width_meters: 1200,
height_meters: 900,
lon_min: 127,
lon_max: 127.01,
lat_min: 37,
lat_max: 37.008,
};
function viewOf(width, height, scale, offsetX, offsetY) {
return {
width,
height,
scale,
offsetX,
offsetY,
mapRect: render.computeMapRect(meta, width, height),
};
}
// ① 한 창 크기에서 잡은 화면을 **다른 창 크기**로 되살린다 — 가운데 자리와 1m 당 픽셀이 같아야 한다.
const before = viewOf(1000, 700, 3.2, -140, 85);
const saved = viewMod.captureView(meta, before, (30 * Math.PI) / 180);
const resized = viewOf(1400, 820, 1, 0, 0);
const restored = { ...resized, ...viewMod.restoreSavedView(meta, resized, saved) };
const again = viewMod.captureView(meta, restored, (30 * Math.PI) / 180);
// ② 저장소 왕복 — 적은 값을 같은 프로젝트로 다시 읽고, 다른 프로젝트에는 없다.
const memory = viewMod.createViewMemory("p-1", () => saved);
memory.flush();
const readBack = viewMod.readSavedView("p-1");
const otherProject = viewMod.readSavedView("p-2");
const storedKeys = [...local._map.keys()];
// ③ 보던 화면이 없으면 노선 전체에 맞춘다(회전 0).
const fresh = viewMod.openingView("p-none", meta, viewOf(1000, 700, 1, 0, 0), [
[200300, 500300],
[200600, 500500],
]);
const withSaved = viewMod.openingView("p-1", meta, viewOf(1000, 700, 1, 0, 0), [[200300, 500300]]);
// ④ 노드 끼우기·빼기 — 편집값 여섯 줄이 같은 자리에서 함께 움직인다.
const columns = {
planned: [
[0, 0],
[10, 0],
[20, 0],
],
curveOn: [true, false, true],
curveRadius: [null, 30, null],
curveLock: [null, "radius", null],
curveArc: [null, 12, null],
apexLock: [false, true, false],
};
edits.spliceNode(columns, 1, [5, 5]);
const afterInsert = JSON.parse(JSON.stringify(columns));
edits.spliceNode(columns, 1);
const afterRemove = JSON.parse(JSON.stringify(columns));
process.stdout.write(
JSON.stringify({
saved,
again,
readBack,
otherProject,
storedKeys,
fresh: { rotationRad: fresh.rotationRad, scale: fresh.view.scale },
withSaved: { rotationRad: withSaved.rotationRad },
afterInsert,
afterRemove,
}),
);
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **보던 화면 기억**(계획서 18 ③)과 노드 끼우기·빼기 회귀검증.
화면은 창 크기와 무관한 꼴(가운데 m · 1m 당 픽셀 · 회전각)로 기억한다. 창 크기가 바뀌어도
같은 자리를 같은 배율로 다시 열어야 한다. 자리는 화면 취향 통(`aislo:pref:`)의 프로젝트 칸.
Node 헬퍼(`helper_b05_routeedit_view.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
"""
import json
import math
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b05_routeedit_view.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_다른_창_크기에서도_같은_가운데와_배율(results):
saved, again = results["saved"], results["again"]
assert again["cx"] == pytest.approx(saved["cx"], abs=1e-6)
assert again["cy"] == pytest.approx(saved["cy"], abs=1e-6)
assert again["pxPerMeter"] == pytest.approx(saved["pxPerMeter"], rel=1e-9)
assert saved["rotationDeg"] == pytest.approx(30.0)
def test_저장소_왕복은_프로젝트마다(results):
assert results["readBack"] == pytest.approx(results["saved"])
assert results["otherProject"] is None
assert results["storedKeys"] == ["aislo:pref:routeedit-view:p-1"]
def test_보던_화면이_없으면_노선에_맞추고_회전_0(results):
assert results["fresh"]["rotationRad"] == 0
assert results["fresh"]["scale"] > 0
assert results["withSaved"]["rotationRad"] == pytest.approx(math.radians(30))
def test_노드_끼우기_빼기는_편집값이_같은_자리로(results):
inserted = results["afterInsert"]
assert inserted["planned"][1] == [5, 5]
assert inserted["curveOn"] == [True, True, False, True]
assert inserted["curveRadius"] == [None, None, 30, None]
assert inserted["curveLock"] == [None, None, "radius", None]
assert inserted["curveArc"] == [None, None, 12, None]
assert inserted["apexLock"] == [False, False, True, False]
removed = results["afterRemove"]
assert removed["planned"] == [[0, 0], [10, 0], [20, 0]]
assert removed["curveRadius"] == [None, 30, None]
assert removed["apexLock"] == [False, True, False]