feat(D 예정노선): 고름 층 노드 → 선 → 구역 → 전체 · 판 하나 재활용 · 우클릭 메뉴 · 지점 ↔ 노드 붙음 (PLAN 598-1~3 · 요구 463)

- 새 logic/D03_PlanAnalysis_Select.ts — 토막 판정 · 끌어 고르기 토막 · 토막 지움(가운데 = 구역 둘 · 뒤쪽 새 구역) · 전체 지움 · 지점 ↔ 노드 같은 자리
- 새 UI_Select.ts — 고름 상태(다시 누름 = 한 단 넓힘 · 같은 해 = 왼쪽 해 줄로만)
- UI_Edit.ts — 우클릭 즉시 지움 걷음 · 노드 끌면 지점 같이 · 지점 12 px 안 붙음 · 지움 창구(dropNode · dropRegion · dropAll · splitAt)
- UI_Props.ts — 판 = 노드 · 선 · 구역 · 전체 · 구간 · 마커 몸 · [방향 바꾸기] · [갈래 시점 지정] 걷음
- 새 UI_Menu.ts — 우클릭 메뉴(Points 에서 옮김 · 노드 지움 · 지점으로 · 연도 구역 나눔)
- UI_Points.ts — 핀 놓을 때 노드 12 px 안 붙음(nodeNear)
- key_registry plan 줄 · 시험 585_2 갈래 시점 지정 줄 지움(요구 463-15 걷음) · 553_4 메뉴 자리 · 새 시험 598_1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:47:39 +09:00
co-authored by Claude Opus 5.5
parent ad8506b186
commit ff49f2c8a2
11 changed files with 1252 additions and 427 deletions
+4 -3
View File
@@ -17,10 +17,11 @@ def _no_hex(path: str) -> None:
def test_d03_points_parts():
"""591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품."""
"""591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품(598-2 = `UI_Menu`)."""
assert not (ROOT / "D02_PlanBase" / "D02_PlanBase_UI_Page.ts").exists()
ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts")
assert "createListRow(" in ts and "createIconButton(" in ts and "createMapContextMenu(" in ts
assert "createListRow(" in ts and "createIconButton(" in ts
assert "createMapContextMenu(" in _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts")
for raw in ('createElement("button")', 'createElement("input")', 'el("input"'):
assert raw not in ts
@@ -78,7 +79,7 @@ def test_d03_parts():
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props
assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not in props
assert 'createElement("select")' not in three and "createSelectField" in three
assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음
+4 -17
View File
@@ -1,5 +1,5 @@
"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침 · 갈래 시점 지정
(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`.
"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침
(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`. 갈래 시점 지정은 걷음(요구 463-15 · PLAN 598-3).
창(오른쪽으로) = 구간 꺾은선 점이 모두 상자 안 · 걸침(왼쪽으로) = 점 하나라도 안 또는 토막이 상자 변과 만남 ·
돌린 화면도 보이는 px 로 셈 · 여러 구간 연도 한 번에.
@@ -46,7 +46,7 @@ writeFileSync(process.argv[2], JSON.stringify({
...marks(),
}));
/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해 · 갈래 시점 지정. */
/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해. */
function marks() {
const span = (net) => net.pieces.filter((p) => p.line === "main").map((p) => [p.id, p.from_m, p.to_m, p.year]);
let m = plan.newLine(plan.emptyNetwork(), [0, 0]);
@@ -58,17 +58,9 @@ function marks() {
// 갈래 처음 해 = 붙은 자리(60 m · p2 = 2028) 구간 해
let b = plan.normalizePieces(plan.insertVertex(yeared, 0, 0, [60, 0]), Y);
b = plan.normalizePieces(plan.appendVertex(plan.addBranch(b, 0, 1), 1, [60, 50]), Y);
// 갈래 시점 지정 — (60,90) 에서 그어 (60,0) 꺾임점에 닿은 본선 → 닿은 끝이 시점인 갈래
let t = plan.normalizePieces(plan.insertVertex(m, 0, 0, [60, 0]), Y);
t = plan.normalizePieces(plan.appendVertex(plan.newLine(t, [60, 90]), 1, [60, 0]), Y);
const branched = plan.branchFromEnd(t, 1);
const lone = plan.normalizePieces(plan.appendVertex(plan.newLine(m, [0, 50]), 1, [30, 80]), Y);
return {
cut: span(cut), edgeSame: edge === cut, merged: span(merged),
branchYear: b.pieces.filter((p) => p.line !== "main").map((p) => p.year),
branched: typeof branched === "string" ? branched : branched.lines.map((l) => [l.id, l.vertices[0], l.branch]),
again: typeof branched === "string" ? null : plan.branchFromEnd(branched, 1),
lone: plan.branchFromEnd(lone, 1),
};
}
"""
@@ -118,14 +110,9 @@ def test_여러_구간_연도(tmp_path: Path) -> None:
assert years["p1"] == years["p3"] == 2028 and years["p2"] == 2026
def test_마커_구역_나눔_합침_갈래(tmp_path: Path) -> None:
def test_마커_구역_나눔_합침_갈래_해(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
assert got["cut"] == [["p1", 0, 40, 2026], ["p2", 40, 100, 2026]] # 구역만 나눔 · 해 그대로
assert got["edgeSame"] # 다른 마커 1 m 안 = 안 놓음
assert got["merged"] == [["p1", 0, 100, 2026]] # 지움 = 앞 구간 해 · id 가 먹음
assert got["branchYear"] == [2028] # 갈래 처음 해 = 붙은 자리 구간 해
lines = {line_id: (head, branch) for line_id, head, branch in got["branched"]}
head, branch = lines[next(k for k in lines if k != "main")]
assert head == [60, 0] and branch == {"line": "main", "vertex": 1} # 닿은 끝 = 시점
assert got["again"] == "이미 갈래입니다 — 갈라지는 점이 시점입니다."
assert got["lone"].startswith("다른 선에 닿은 끝이 없습니다")
@@ -0,0 +1,129 @@
"""598-1 예정노선 고름 층 노드 → 선(토막) → 구역 → 전체 · 토막 지움(요구 463 9 · 12 · 13·16 · 15)
— TS `logic/D03_PlanAnalysis_Select.ts`.
끝 토막 지움 = 끝 점 뺌 · 가운데 토막 지움 = 구역 둘(뒤쪽 = 새 구역 · 갈래 고리 · 조각 따라감) ·
사라지는 노드에 갈래 = 까닭 글 · 끌어 고르기 토막 판정 · 지점 ↔ 노드 같은 자리.
./venv/Scripts/python.exe -m pytest resources/tester/test_598_1_plan_select_layers.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Select.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
import * as sel from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.js";
const Y = 2026;
// 본선 (0,0)→(100,0)→(200,0)→(300,0) · 마크 150 → p1 [0,150] p2 [150,300] · 갈래 at 노드 2 (200,0)→(200,50)
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
for (const x of [100, 200, 300]) n = plan.appendVertex(n, 0, [x, 0]);
n = plan.normalizePieces(plan.splitPiece(plan.normalizePieces(n, Y), "main", 150), Y);
n = plan.normalizePieces(plan.setPiecesYear(n, ["p2"], 2028), Y);
n = plan.normalizePieces(plan.appendVertex(plan.addBranch(n, 0, 2), 1, [200, 50]), Y);
const show = (net) => typeof net === "string" ? net : ({
lines: net.lines.map((l) => [l.id, l.vertices, l.branch]),
pieces: plan.normalizePieces(net, Y).pieces.map((p) => [p.id, p.line, p.from_m, p.to_m, p.year]),
});
const seg = (line, index) => ({ line, index });
const view = ([x, y]) => [x, -y];
writeFileSync(process.argv[2], JSON.stringify({
segs: sel.allSegments(n),
range: sel.segmentRange(n, seg("main", 1)),
at: [sel.segmentAt(n.lines[0].vertices, 0), sel.segmentAt(n.lines[0].vertices, 100), sel.segmentAt(n.lines[0].vertices, 300)],
nodeSeg: [sel.nodeSegment(n, { line: "main", vertex: 1 }), sel.nodeSegment(n, { line: "main", vertex: 3 })],
pieces: sel.segmentPieces(n, [seg("main", 1)]).sort(),
middle: show(sel.removeSegment(n, seg("main", 0 + 1))),
tail: show(sel.removeSegment(n, seg("main", 2))),
head: show(sel.removeSegment(n, seg("main", 0))),
only: show(sel.removeSegment(n, seg("branch-1", 0))),
parentOnly: sel.removeSegments(n, [seg("main", 0), seg("main", 1), seg("main", 2)]),
many: show(sel.removeSegments(n, [seg("main", 0), seg("main", 2)])),
box: sel.segmentsInBox(n, view, { x0: 90, y0: -10, x1: 210, y1: 10 }, false),
cross: sel.segmentsInBox(n, view, { x0: 150, y0: 10, x1: 120, y1: -10 }, true),
all: show(sel.removeAll(n)),
nodes: sel.nodesAt(n, [200, 0]),
moved: sel.movePointsAt([[200, 0], [5, 5]], [200, 0], [210, 5]),
}));
"""
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT),
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler",
"--ignoreConfig"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_토막_자리(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
assert [s["line"] for s in got["segs"]] == ["main"] * 3 + ["branch-1"]
assert got["range"] == [100, 200]
assert got["at"] == [0, 1, 2] # 꺾임점 위 = 뒤 토막 · 끝 = 마지막
assert got["nodeSeg"] == [{"line": "main", "index": 1}, {"line": "main", "index": 2}]
assert got["pieces"] == ["p1", "p2"] # 100~200 토막에 마크 150 = 두 구간
def test_가운데_토막_지움_구역_둘(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
lines = {i: (v, b) for i, v, b in got["middle"]["lines"]}
assert lines["main"] == ([[0, 0], [100, 0]], None) # 앞쪽 = 같은 id
back = next(k for k in lines if k not in ("main", "branch-1"))
assert lines[back] == ([[200, 0], [300, 0]], None) # 뒤쪽 = 새 구역(본선)
assert lines["branch-1"][1] == {"line": back, "vertex": 0} # 갈래 고리가 따라감
pieces = {(p[1], p[2], p[3]): (p[0], p[4]) for p in got["middle"]["pieces"]}
assert pieces[("main", 0, 100)] == ("p1", 2026) # 앞 조각 id 지님
assert pieces[(back, 0, 100)][1] == 2028 # 뒤 조각 해 그대로
def test_끝_토막_지움_끝_점_뺌(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
tail = {i: v for i, v, _ in got["tail"]["lines"]}
assert tail["main"] == [[0, 0], [100, 0], [200, 0]]
head = {i: (v, b) for i, v, b in got["head"]["lines"]}
assert head["main"][0] == [[100, 0], [200, 0], [300, 0]]
assert head["branch-1"][1] == {"line": "main", "vertex": 1} # 갈래 차례 −1
assert [p[2:4] for p in got["head"]["pieces"] if p[1] == "main"] == [[0, 50], [50, 200]]
def test_갈래_붙은_노드_막음_하나뿐_구역_지움(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
only = got["only"]["lines"]
assert [i for i, _, _ in only] == ["main"] # 토막 하나뿐 = 구역 지움
assert got["parentOnly"] == "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요."
many = {i: v for i, v, _ in got["many"]["lines"]}
assert many["main"] == [[100, 0], [200, 0]] # 뒤부터 지움 = 차례 안 꼬임
def test_끌어_고르기_전체_지점_노드(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
assert got["box"] == [{"line": "main", "index": 1}] # 창 = 두 끝 모두 안
assert got["cross"] == [{"line": "main", "index": 1}] # 걸침 = 상자와 만남
assert got["all"] == {"lines": [], "pieces": []}
assert got["nodes"] == [{"line": "main", "vertex": 2}] # 갈래 첫 점 뺌
assert got["moved"] == [[210, 5], [5, 5]]