feat(774-1): 다시 긋기 = 중간 노드 고름으로 켬 · 다른 중간 노드에 이어야 끝

- 노드 속성 판 · 우클릭 = 중간 노드에 [다시 긋기](선 토막 고름의 [다시 긋기] 뺌) · B05 긋기 중 중간 노드 누름 = 그대로 켬
- 끝 = 같은 선 다른 중간 노드만(rejoinMiss) · 시작 노드 · 끝점 = 못 찍음 + 안내(계속 긋기) · 빈 곳 = 점 찍고 계속
- 고리: 시작 노드 · 이을 중간 노드 · 마우스 올린 중간 노드(paintRejoin · --draw-map-*) · 옛 구간 흐림(dim) 뺌
- 시험: test_771_2 에 끝 판정 · 중간 노드 대조 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 20:31:21 +09:00
co-authored by Claude Opus 5.5
parent ea6207d587
commit 2a67a7092d
7 changed files with 127 additions and 98 deletions
+50 -60
View File
@@ -8,10 +8,9 @@
* 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래 · 본선 꼬리가 다른 구역에
* 닿아 끝나면 저절로 그 점의 갈래(No.0 = 닿은 점 · 599 · 요구서 463 3장 10).
* 다시 긋기(599-3 · `respan`) = 고른 구간 앞 노드부터 긋고 원래 뒤 노드에 붙으면 끝(속 구간 흐리게 ·
* Enter 로 끝내면 취소) — 끝나면 `replaceSpan` 한 번 = 되돌리기 한 걸음.
* 중간 노드에서 긋기(771-2 · 갈래 숨긴 페이지 = B05) = 다시 긋기의 뒤 노드 열림 — 같은 선 다른 노드에 닿으면
* 그 사이 갈음(`rejoinSpan` · 거꾸로 됨) · 같은 노드 · 안 닿고 끝냄 = 꼴 그대로 + 안내.
* 다시 긋기(774 · 요구 616 · `respan`) = 고른 중간 노드(속성 판 · 우클릭 · B05 긋기 중 누름)부터 긋고 같은 선
* 다른 중간 노드에 닿으면 그 사이 갈음(`rejoinSpan` · 거꾸로 됨 · 되돌리기 한 걸음) · 시작 노드 · 끝점 = 끝 아님
* (계속 긋기 · 안내) · Enter 로 안 닿고 끝냄 = 꼴 그대로 + 안내 · 시작 · 이을 노드 · 마우스 올린 노드 = 고리.
* Esc(771-1) = 이번 긋기 직전 꼴 · 지움 바로 뒤 긋기면 지움 전 꼴(`dropBase`) · 다시 긋기 = 꼴 그대로.
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중
@@ -49,12 +48,13 @@ import {
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { nodeRecord, rejoinSpan, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
import { isMiddleNode, nodeRecord, rejoinMiss, rejoinSpan } from "./logic/D03_PlanAnalysis_Span";
import {
fmt,
lineAt,
paintNetwork,
paintPreview,
paintRejoin,
screenGap,
SNAP_PX,
} from "./D03_PlanAnalysis_UI_DrawPaint";
@@ -69,10 +69,9 @@ import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」.";
/** 중간 노드에서 긋다 다른 노드에 안 닿고 끝냄(771-2). */
const RESPAN_MISS = "다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.";
/** 다시 긋다 다른 중간 노드에 안 닿고 끝냄(771-2 · 774). */
const REJOIN_MISS =
"다른 노드에 닿지 않아 반영하지 않았습니다 — 다른 노드에 닿으면 그 사이가 바뀝니다.";
"다른 중간 노드에 닿지 않아 반영하지 않았습니다 — 다른 중간 노드에 이으면 그 사이가 바뀝니다.";
export type DrawMode = "idle" | "draw";
@@ -82,7 +81,7 @@ export function lineLimitBlock(
network: PlanNetwork,
): string | null {
if (kind === "line" || kind === "point")
return "예정노선은 한 선입니다 — 선 끝에서 이어 긋거나 노드에서 시작해 다른 노드에 닿게 긋습니다.";
return "예정노선은 한 선입니다 — 선 끝에서 이어 긋거나 중간 노드에서 시작해 다른 중간 노드에 잇습니다.";
if (kind === "empty" && network.lines.some((line) => line.vertices.length > 1))
return "예정노선은 한 선입니다 — 지운 뒤 새로 긋습니다.";
return null;
@@ -137,8 +136,8 @@ export interface PlanDraw {
stroke: () => { line: number; flip: boolean } | null;
constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number };
setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void;
/** 다시 긋기 시작(599-3 · 598 판 [다시 긋기]) — `lineId` 선 `fromVertex`~`toVertex` · 참 = 시작. */
respan: (lineId: string, fromVertex: number, toVertex: number) => boolean;
/** 다시 긋기 시작(774 · 노드 판 [다시 긋기] · 우클릭) — `lineId` 선 중간 노드 `vertex` · 참 = 시작. */
respan: (lineId: string, vertex: number) => boolean;
}
export function createPlanDraw(params: PlanDrawParams): PlanDraw {
@@ -154,11 +153,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let hoverAt: { px: number; py: number; map: ScreenMap; shift: boolean } | null = null; // Tab · Shift 뒤 미리보기 다시
let lengthM = DRAW_DEFAULT_LENGTH_M;
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점(null = 중간 노드에서 긋기 · 같은 선 어느 노드든 · 771-2) · 새로 찍은 속 점 · 그 기록
// 다시 긋기 — 선 id · 시작 중간 노드 · 새로 찍은 속 점 · 그 기록(끝 = 같은 선 다른 중간 노드 · 774)
let span: {
line: string;
from: number;
to: number | null;
through: Vertex[];
records: NodeRecord[];
} | null = null;
@@ -217,18 +215,20 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
: null;
}
/** 중간 노드에서 긋기 붙을 자리(771-2) — 같은 선 노드 중 가장 가까운 것 · 시작 노드는 한 점 찍은 뒤만. */
/** 다시 긋기 닿은 노드(774) — 같은 선 노드 중 가장 가까운 것 · 다른 중간 노드 = 붙음 · 시작 · 끝점 = 막힘 글. */
function nodeSnap(
vertices: Vertex[],
px: number,
py: number,
map: ScreenMap,
): { point: Vertex; note: string; onLine: null } | null {
): { point: Vertex; note: string; onLine: null; miss: string | null } | null {
const best = vertices
.map((point, i) => ({ point, i, gap: screenGap(map, point, px, py) }))
.filter((item) => item.gap <= SNAP_PX && (item.i !== span!.from || span!.through.length > 0))
.filter((item) => item.gap <= SNAP_PX)
.sort((a, b) => a.gap - b.gap)[0];
return best ? { point: best.point, note: "노드에 닿음", onLine: null } : null;
if (!best) return null;
const miss = rejoinMiss(vertices.length, span!.from, best.i);
return { point: best.point, note: miss ?? "중간 노드에 이음 — 끝", onLine: null, miss };
}
/** 앞 구간 시작 — 정방향 부채꼴(625-2 B5) · 첫 구간 = null · 갈래 첫 구간 = 붙은 선 들어온 쪽. */
@@ -268,20 +268,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
const z = params.heightAt();
const cursor = map.toMetric(px, py);
fan = kind === "both" ? compassFan(from, lengthM, slopePct, z, prevOf(), cursor) : null;
// 다시 긋기 = 원래 뒤 노드에만 붙음 · 중간 노드에서 긋기 = 같은 선 노드(시작 노드는 한 점 찍은 뒤부터)
const end = span?.to === null ? undefined : spanVertices?.[span!.to];
const snap = end
? screenGap(map, end, px, py) <= SNAP_PX
? { point: end, note: "뒤 노드에 붙음", onLine: null }
: null
: spanVertices
? nodeSnap(spanVertices, px, py, map)
: snapAt(px, py, map);
// 다시 긋기 = 같은 선 다른 중간 노드에만 붙음(시작 · 끝점 = 못 찍음 · 안내)
const snap = spanVertices ? nodeSnap(spanVertices, px, py, map) : snapAt(px, py, map);
let point: Vertex | null = cursor;
let note: string | null = null;
let lengthOnly = false;
if (snap) {
[point, note] = [snap.point, snap.note];
if ("miss" in snap && snap.miss) point = null;
} else if (kind === "length") {
point = fixedLengthPoint(from, cursor, lengthM);
} else if (kind === "slope") {
@@ -309,7 +303,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
// ── 모드 ────────────────────────────────────────────────
/** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */
function stop(): void {
if (span) showToast(span.to === null ? REJOIN_MISS : RESPAN_MISS, "info");
if (span) showToast(REJOIN_MISS, "info");
let network = params.network();
const line = active;
const entry = line === null ? null : network.lines[line];
@@ -348,16 +342,15 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
span = null;
}
function respan(lineId: string, from: number, to: number): boolean {
function respan(lineId: string, from: number): boolean {
if (!editable) return false;
const index = params.network().lines.findIndex((line) => line.id === lineId);
const blocked = respanBlocked(params.network(), index, from, to);
if (blocked) {
showToast(blocked, "info");
const line = params.network().lines.find((entry) => entry.id === lineId);
if (!line || !isMiddleNode(line.vertices.length, from)) {
showToast("다시 긋기는 중간 노드를 골라 시작합니다.", "info");
return false;
}
setMode("draw");
span = { line: lineId, from, to, through: [], records: [] };
span = { line: lineId, from, through: [], records: [] };
restore = params.dropBase?.() ?? null;
panelState();
params.onMode();
@@ -413,12 +406,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
const network = params.network();
const target = aim(px, py, map);
const branches = featureOn(params.page, "branch");
// 한 선 페이지(B05) 중간 노드 = 그 노드에서 긋기 · 다른 노드에 닿으면 그 사이 갈음(771-2 · 닿기 전엔 꼴 그대로)
// 한 선 페이지(B05) 중간 노드 = 다시 긋기(771-2 · 774 · 닿기 전엔 꼴 그대로) · 선 위 = 아래 막힘 글
const vertex = target.kind === "line" ? target.hit.vertex : null;
if (!branches && target.kind === "line" && vertex !== null && vertex > 0) {
if (!branches && target.kind === "line" && vertex !== null) {
const line = network.lines[target.hit.line];
if (vertex < line.vertices.length - 1) {
span = { line: line.id, from: vertex, to: null, through: [], records: [] };
if (isMiddleNode(line.vertices.length, vertex)) {
span = { line: line.id, from: vertex, through: [], records: [] };
restore = params.dropBase?.() ?? null;
return true;
}
@@ -495,12 +488,8 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return true;
}
const vertices = params.network().lines[spanLine()].vertices;
const end =
span.to ??
(samePoint(vertices[span.from], preview.point)
? span.from
: vertices.findIndex((v) => samePoint(v, preview!.point)));
const next = (span.to === null ? rejoinSpan : replaceSpan)(
const end = vertices.findIndex((v, i) => i !== span!.from && samePoint(v, preview!.point));
const next = rejoinSpan(
params.network(),
spanLine(),
span.from,
@@ -533,10 +522,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let network = params.network();
let stroke = active;
const index = spanLine();
const dim =
span && span.to !== null && index >= 0
? { line: index, from: span.from, to: span.to }
: null;
if (span && index >= 0) {
// 다시 긋는 선 = 앞 노드 + 찍은 점(그리는 선 꼴)
const head = network.lines[index].vertices[span.from];
@@ -544,19 +529,24 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
network = { ...network, lines: [...network.lines, temp] };
stroke = network.lines.length - 1;
}
paintNetwork(
context,
map,
network,
stroke,
params.heightAt(),
() => {
const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1);
if (from && preview)
paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
},
dim,
);
paintNetwork(context, map, network, stroke, params.heightAt(), () => {
const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1);
if (from && preview)
paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
});
// 다시 긋기 고리(774) — 시작 · 이을 중간 노드 · 마우스 올린 중간 노드(쉴 때 · B05 긋기 첫 누름 전)
const plan = params.network();
const at =
hoverAt && editable && active === null ? lineAt(plan, hoverAt.px, hoverAt.py, map) : null;
const vertices = plan.lines[span ? index : (at?.line ?? -1)]?.vertices;
if (!vertices) return;
const v = at && (!span || at.line === index) ? at.vertex : null;
const open = span
? v !== null && !rejoinMiss(vertices.length, span.from, v)
: v !== null &&
isMiddleNode(vertices.length, v) &&
(mode === "idle" || !featureOn(params.page, "branch"));
paintRejoin(context, map, vertices, span?.from ?? null, open ? v : null);
},
press(px, py, map, button, shift) {
if (!editable) return false;