feat(D 예정노선): 노드마다 긋기 방식 · 값 기록 — 꼭짓점과 같이 움직임 · 옮긴 노드 기록 남기고 경고 고리 (PLAN 599-2 · 요구 463-11)

- 점 찍을 때 그때 방식 · 값을 nodes 칸에(594-1 꼴 · 붙은 점 = 자유 · 처음 기록 = 앞 노드 기록 없음)
- 끼움 · 더함 · 뺌 · 지움 · 뒤집기(토막 끝 노드 몫 한 칸 밀림) · 잇기 · 다시 긋기(뒤 노드 = 자유)가 기록 길이를 꼭짓점과 맞춤 — [저장] 400 막음
- nodeRecord · nodeOff(길이 1 cm · 기울기 0.1 % 넘게 어긋남) — Span.ts(Plan 700 줄 넘지 않게) · 598 노드 판이 씀
- 시험 599_3 에 기록 따라감 · 어긋남

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:49:21 +09:00
co-authored by Claude Opus 5.5
parent b92aae3254
commit 9febb63e13
5 changed files with 175 additions and 32 deletions
+28 -7
View File
@@ -41,10 +41,11 @@ import {
removeLine,
reverseLine,
samePoint,
type NodeRecord,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
import { nodeRecord, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
import {
fmt,
lineAt,
@@ -122,8 +123,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let kind: DrawConstraint = "free";
let lengthM = DRAW_DEFAULT_LENGTH_M;
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점
let span: { line: string; from: number; to: number; through: Vertex[] } | null = null;
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록
let span: {
line: string;
from: number;
to: number;
through: Vertex[];
records: NodeRecord[];
} | null = null;
const spanLine = (): number =>
span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1;
@@ -272,7 +279,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return false;
}
setMode("draw");
span = { line: lineId, from, to, through: [] };
span = { line: lineId, from, to, through: [], records: [] };
panelState();
params.onMode();
return true;
@@ -381,9 +388,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
return false;
}
// 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2)
const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct);
if (span) {
if (!preview.snapped) return (span.through.push(preview.point), true);
const next = replaceSpan(params.network(), spanLine(), span.from, span.to, span.through);
if (!preview.snapped) {
span.through.push(preview.point);
span.records.push(record);
return true;
}
const next = replaceSpan(
params.network(),
spanLine(),
span.from,
span.to,
span.through,
span.records,
);
if (typeof next === "string") showToast(next, "info");
else params.commit(next);
span = null;
@@ -396,7 +416,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let next = params.network();
if (preview.onLine)
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
params.commit(appendVertex(next, line, preview.point));
params.commit(appendVertex(next, line, preview.point, record));
if (ended) {
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
setMode("idle");
@@ -494,6 +514,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return true;
}
if (mode === "draw" && span && keyIs(event, "plan.drawBack")) {
span.records.pop();
if (!span.through.pop()) setMode("idle");
return true;
}
@@ -18,6 +18,7 @@ import {
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import {
planColors,
PLAN_STATION_INTERVAL_M,
@@ -159,15 +160,22 @@ export function paintNetwork(
);
if (map.slopes) segmentLabels(context, map, line.vertices, z);
});
// 꺾임점
// 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
context.fillStyle = "#ffffff";
context.strokeStyle = colors.warn;
context.lineWidth = 2;
for (const line of network.lines)
for (const v of line.vertices.slice(1)) {
line.vertices.forEach((v, i) => {
if (!i) return;
const [sx, sy] = map.toScreen(v);
context.beginPath();
context.arc(sx, sy, 3, 0, Math.PI * 2);
context.fill();
}
if (!nodeOff(line, i, z)) return;
context.beginPath();
context.arc(sx, sy, 7, 0, Math.PI * 2);
context.stroke();
});
drawPieces(context, map, network);
extra();
}
+41 -11
View File
@@ -2,7 +2,8 @@
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1).
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1) ·
* 노드 기록이 꼭짓점과 같이 움직임(599-2 · 기록 만들기 · 어긋남 = `D03_PlanAnalysis_Span`).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
* ========================================================================== */
@@ -21,6 +22,15 @@ export interface NodeRecord {
slope_pct: number | null;
}
/** 꼭짓점이 바뀐 선 — 기록 있는 선만 `edit` 로 기록도 같이(없는 선 = 그대로 없음). */
function withNodes(
line: PlanLine,
vertices: Vertex[],
edit: (nodes: Array<NodeRecord | null>) => Array<NodeRecord | null>,
): PlanLine {
return line.nodes ? { ...line, vertices, nodes: edit([...line.nodes]) } : { ...line, vertices };
}
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0) · `name` 없음 = 차례 이름(`lineNames`) ·
* `nodes` = 꼭짓점마다 기록(같은 길이 · 칸 없음 = 기록 없음 · 594-1). */
@@ -201,7 +211,7 @@ export function insertVertex(
if (i === line) {
const vertices = [...entry.vertices];
vertices.splice(segment + 1, 0, point);
return { ...entry, vertices };
return withNodes(entry, vertices, (nodes) => (nodes.splice(segment + 1, 0, null), nodes));
}
const branch = entry.branch;
return branch && branch.line === id && branch.vertex > segment
@@ -211,13 +221,21 @@ export function insertVertex(
};
}
/** 선 끝에 점을 더함. */
export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork {
/** 선 끝에 점을 더함 — `record` = 그 노드 긋기 방식 · 값(599-2 · 처음 기록이면 앞 노드 = 기록 없음). */
export function appendVertex(
network: PlanNetwork,
line: number,
point: Vertex,
record: NodeRecord | null = null,
): PlanNetwork {
return {
...network,
lines: network.lines.map((entry, i) =>
i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry,
),
lines: network.lines.map((entry, i) => {
if (i !== line) return entry;
const vertices = [...entry.vertices, point];
const nodes = entry.nodes ?? (record ? entry.vertices.map(() => null) : null);
return nodes ? { ...entry, vertices, nodes: [...nodes, record] } : { ...entry, vertices };
}),
};
}
@@ -233,7 +251,9 @@ export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork
return {
...network,
lines: network.lines.map((other, i) =>
i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other,
i === line
? withNodes(other, other.vertices.slice(0, last), (nodes) => nodes.slice(0, last))
: other,
),
};
}
@@ -430,7 +450,8 @@ export function removeVertex(
};
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices };
if (i === line)
return withNodes(other, vertices, (nodes) => nodes.filter((_, k) => k !== vertex));
const branch = other.branch;
return branch?.line === entry.id && branch.vertex > vertex
? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } }
@@ -448,7 +469,10 @@ export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | s
const length = round3(lineLength(entry.vertices));
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices: [...other.vertices].reverse() };
if (i === line)
return withNodes(other, [...other.vertices].reverse(), (nodes) =>
nodes.map((_, k) => (k ? nodes[n - k] : null)),
);
const branch = other.branch;
return branch?.line === entry.id
? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } }
@@ -502,7 +526,13 @@ function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork {
);
const lines = network.lines
.map((line, i) => {
if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] };
if (i === a) {
const vertices = [...line.vertices, ...tail.vertices.slice(1)];
if (!line.nodes && !tail.nodes) return { ...line, vertices };
const nodesOf = (entry: PlanLine): Array<NodeRecord | null> =>
entry.nodes ?? entry.vertices.map(() => null);
return { ...line, vertices, nodes: [...nodesOf(line), ...nodesOf(tail).slice(1)] };
}
const branch = line.branch;
return branch?.line === tail.id
? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } }
@@ -3,14 +3,35 @@
* D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈.
* 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 ·
* 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠).
* 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유.
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* ========================================================================== */
import { lineLength } from "./D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
import { groundSlopePct, lineLength, segmentLength, type HeightAt } from "./D03_PlanAnalysis_Draw";
import type { NodeKind, NodeRecord, PlanLine, PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** 그리기 칸 방식 · 값 → 노드 기록(안 쓴 값 = null · 599-2). */
export function nodeRecord(kind: NodeKind, lengthM: number, slopePct: number): NodeRecord {
return {
kind,
length_m: kind === "length" || kind === "both" ? lengthM : null,
slope_pct: kind === "slope" || kind === "both" ? slopePct : null,
};
}
/** 노드 기록과 지금 앞 토막이 어긋남(옮긴 노드 ⚠ · 599-2) — 길이 1 cm · |기울기| 0.1 % 넘게 · 지형 밖 = 안 봄. */
export function nodeOff(line: PlanLine, vertex: number, z: HeightAt): boolean {
const record = line.nodes?.[vertex];
if (!record || vertex < 1) return false;
const [a, b] = [line.vertices[vertex - 1], line.vertices[vertex]];
if (record.length_m !== null && Math.abs(segmentLength(a, b) - record.length_m) > 0.01)
return true;
const slope = record.slope_pct === null ? null : groundSlopePct(a, b, z);
return slope !== null && Math.abs(Math.abs(slope) - Math.abs(record.slope_pct!)) > 0.1;
}
/** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */
export function respanBlocked(
network: PlanNetwork,
@@ -30,13 +51,15 @@ export function respanBlocked(
return null;
}
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글.
* `records` = 속 점 기록(`through` 와 같은 길이) · 뒤 노드 기록 = 자유 · 기록 없는 선 + 기록 없음 = 그대로 없음. */
export function replaceSpan(
network: PlanNetwork,
line: number,
from: number,
to: number,
through: ReadonlyArray<Vertex>,
records: ReadonlyArray<NodeRecord | null> = [],
): PlanNetwork | string {
const blocked = respanBlocked(network, line, from, to);
if (blocked) return blocked;
@@ -56,13 +79,22 @@ export function replaceSpan(
: m >= oldEndM
? m - oldEndM + newEndM
: startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM);
const branched = (other: PlanLine): PlanLine =>
other.branch?.line === entry.id && other.branch.vertex >= to
? { ...other, branch: { ...other.branch, vertex: other.branch.vertex + shift } }
: other;
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices };
const branch = other.branch;
return branch?.line === entry.id && branch.vertex >= to
? { ...other, branch: { ...branch, vertex: branch.vertex + shift } }
: other;
if (i !== line) return branched(other);
if (!other.nodes && !records.some(Boolean)) return { ...other, vertices };
const nodes = other.nodes ?? old.map(() => null);
const inner = through.map((_, k) => records[k] ?? null);
const end = nodeRecord("free", 0, 0);
return {
...other,
vertices,
nodes: [...nodes.slice(0, from + 1), ...inner, end, ...nodes.slice(to + 1)],
};
}),
pieces: network.pieces.map((piece) =>
piece.line === entry.id
+55 -3
View File
@@ -1,7 +1,8 @@
"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`.
"""D03 다시 긋기 · 노드 기록(PLAN 599-2 · 599-3 · 요구 463-11 · 12) — TS `logic/D03_PlanAnalysis_Span.ts` · `_Plan.ts`.
두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 ·
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로.
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로 · 노드 기록 = 꼭짓점과 같은 길이로 따라감
(더함 · 끼움 · 뺌 · 지움 · 뒤집기 · 잇기 · 다시 긋기) · 옮긴 노드 = 기록 남고 어긋남.
./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q
"""
@@ -34,7 +35,32 @@ const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]);
const bad = span.replaceSpan(n, 0, 2, 2, []);
const straight = span.replaceSpan(n, 0, 0, 2, []);
writeFileSync(process.argv[2], JSON.stringify({ before: n, done, wide, bad, straight }));
// 599-2 노드 기록
const L = span.nodeRecord("length", 20, 9);
const S = span.nodeRecord("slope", 20, 9);
let r = plan.newLine(plan.emptyNetwork(), [0, 0]);
r = plan.appendVertex(r, 0, [10, 0]);
r = plan.appendVertex(r, 0, [30, 0], L);
r = plan.appendVertex(r, 0, [30, 5], S);
const recorded = r;
const inserted = plan.insertVertex(r, 0, 0, [5, 0]);
const dropped = plan.dropLastVertex(r, 0);
const removed = plan.removeVertex(r, 0, 1);
const reversed = plan.reverseLine(r, 0);
let t = plan.appendVertex(plan.newLine(r, [30, 5]), 1, [40, 5], L);
const joined = plan.attachHead(t, 1);
const moved = plan.moveVertex(r, 0, 2, [33, 0]);
const flat = () => 0;
const off = [1, 2, 3].map((v) => span.nodeOff(moved.lines[0], v, flat));
const respanned = span.replaceSpan(r, 0, 1, 3, [[20, 9]], [L]);
const plain = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
writeFileSync(
process.argv[2],
JSON.stringify({
before: n, done, wide, bad, straight,
recorded, inserted, dropped, removed, reversed, joined, moved, off, respanned, plain,
}),
);
"""
@@ -99,3 +125,29 @@ def test_blocked_and_straight(tmp_path: Path) -> None:
straight = r["straight"]["lines"][0]["vertices"]
assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게
assert r["straight"]["lines"][1]["branch"]["vertex"] == 1
L = {"kind": "length", "length_m": 20, "slope_pct": None}
S = {"kind": "slope", "length_m": None, "slope_pct": 9}
F = {"kind": "free", "length_m": None, "slope_pct": None}
def _nodes(network: dict, i: int = 0) -> list:
line = network["lines"][i]
assert len(line.get("nodes") or line["vertices"]) == len(line["vertices"]) # 서버 400 막음
return line.get("nodes")
def test_node_records_follow_vertices(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
assert _nodes(r["recorded"]) == [None, None, L, S] # 첫 기록 = 앞 노드 기록 없음
assert _nodes(r["inserted"]) == [None, None, None, L, S]
assert _nodes(r["dropped"]) == [None, None, L]
assert _nodes(r["removed"]) == [None, L, S]
assert _nodes(r["reversed"]) == [None, S, L, None] # 토막 끝 노드 몫 = 한 칸 밀림
assert len(r["joined"]["lines"]) == 1 and _nodes(r["joined"]) == [None, None, L, S, L]
assert _nodes(r["moved"]) == [None, None, L, S] # 옮겨도 기록 남음
# 2 = 길이 20 기록 · 지금 23 · 3 = 기울기 9 % 기록 · 평지 0 % → 둘 다 어긋남 · 1 = 기록 없음
assert r["off"] == [False, True, True]
assert _nodes(r["respanned"]) == [None, None, L, F] # 속 점 = 그때 기록 · 뒤 노드 = 자유
assert "nodes" not in r["plain"]["lines"][0] # 기록 없는 선 = 그대로 없음