From 2a67a7092dd2aa30d71e88dce647f7d7dbe98b46 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 20:31:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(774-1):=20=EB=8B=A4=EC=8B=9C=20=EA=B8=8B?= =?UTF-8?q?=EA=B8=B0=20=3D=20=EC=A4=91=EA=B0=84=20=EB=85=B8=EB=93=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EC=BC=AC=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=A4=91=EA=B0=84=20=EB=85=B8=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=9D=B4=EC=96=B4=EC=95=BC=20=EB=81=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노드 속성 판 · 우클릭 = 중간 노드에 [다시 긋기](선 토막 고름의 [다시 긋기] 뺌) · B05 긋기 중 중간 노드 누름 = 그대로 켬 - 끝 = 같은 선 다른 중간 노드만(rejoinMiss) · 시작 노드 · 끝점 = 못 찍음 + 안내(계속 긋기) · 빈 곳 = 점 찍고 계속 - 고리: 시작 노드 · 이을 중간 노드 · 마우스 올린 중간 노드(paintRejoin · --draw-map-*) · 옛 구간 흐림(dim) 뺌 - 시험: test_771_2 에 끝 판정 · 중간 노드 대조 더함 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 110 ++++++++---------- .../D03_PlanAnalysis_UI_DrawPaint.ts | 50 +++++--- D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 2 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts | 14 ++- D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts | 21 ++-- .../logic/D03_PlanAnalysis_Span.ts | 14 +++ resources/tester/test_771_2_rejoin_span.py | 14 +++ 7 files changed, 127 insertions(+), 98 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index df9a17e65..02d0791d7 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -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; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index a6077940d..3fb6a45f4 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -168,8 +168,7 @@ export function lineAt( return best; } -/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기). - * `active` = 그리는 선(굵은 보라) · `dim` = 다시 긋는 구간(599-3 · 흐리게). */ +/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기) · `active` = 그리는 선(굵은 보라). */ export function paintNetwork( context: CanvasRenderingContext2D, map: ScreenMap, @@ -177,7 +176,6 @@ export function paintNetwork( active: number | null, z: HeightAt, extra: () => void, - dim: { line: number; from: number; to: number } | null = null, ): void { const colors = planColors(); context.lineCap = "round"; @@ -186,22 +184,13 @@ export function paintNetwork( if (line.vertices.length < 2) return; const color = index === active ? colors.active : routeColor(index); const width = 3; - const path = (vertices: ReadonlyArray): void => { - context.beginPath(); - vertices.forEach((v, i) => { - const [sx, sy] = map.toScreen(v); - if (i) context.lineTo(sx, sy); - else context.moveTo(sx, sy); - }); - strokeCased(context, color, width); // 밝은 바탕 대비 = 검은 테(600-1 · 463-24) - }; - if (dim?.line === index) { - path(line.vertices.slice(0, dim.from + 1)); - path(line.vertices.slice(dim.to)); - context.globalAlpha = 0.3; - path(line.vertices.slice(dim.from, dim.to + 1)); - context.globalAlpha = 1; - } else path(line.vertices); + context.beginPath(); + line.vertices.forEach((v, i) => { + const [sx, sy] = map.toScreen(v); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + strokeCased(context, color, width); // 밝은 바탕 대비 = 검은 테(600-1 · 463-24) if (map.stations) drawStationTicks( context, @@ -245,6 +234,29 @@ export function paintNodes( }); } +/** 다시 긋기 고리(774) — `start` 시작 노드 = 굵은 그리기 색 · 그 밖 중간 노드 = 이을 수 있음(얇은 재기 색) · + * `hover` = 마우스 올린 중간 노드(그리기 색) · 끝점 = 고리 없음. */ +export function paintRejoin( + context: CanvasRenderingContext2D, + map: Pick, + vertices: ReadonlyArray, + start: number | null, + hover: number | null, +): void { + const colors = planColors(); + const ring = (i: number, pad: number, color: string, width: number): void => { + const [sx, sy] = map.toScreen(vertices[i]); + context.beginPath(); + context.arc(sx, sy, NODE_DOT_PX + pad, 0, Math.PI * 2); + strokeCased(context, color, width); + }; + if (start !== null) + for (let i = 1; i < vertices.length - 1; i += 1) + if (i !== start) ring(i, 4, colors.measure, 1.5); + if (start !== null) ring(start, 5, colors.active, 3); + if (hover !== null) ring(hover, 6, colors.active, 2.5); +} + /** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */ export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer { return { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index f6449012e..2066c4f52 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -227,7 +227,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Backspace = 마지막 점 빼기", "Tab(긋는 중) = 자유지정 ↔ 길이·기울기 고정", "Shift 누른 채(길이·기울기 고정) = 부채꼴 사이 기울기 자리 · 길이 그대로", - "[다시 긋기] = 앞 노드부터 긋고 뒤 노드에 붙으면 끝", + "[다시 긋기](중간 노드 고름) = 그 노드부터 긋고 다른 중간 노드에 이으면 끝", "누름 = 노드 · 선 고름 · 다시 누름 = 선 → 구역 → 전체로 넓힘", "Shift+누름 = 선 더함 · 뺌", "빈 곳 오른쪽으로 끌기 = 창 안 선 · 왼쪽으로 = 걸친 선까지", diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts index 7d247a210..aaabb696a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -2,8 +2,8 @@ * D03_PlanAnalysis_UI_Menu.ts * D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목. * 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 · - * 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 끝 = 잇기 · 여기에 잇기(638-3) · - * 선 위 = 갈래 시작 · 여기서 구간 나눔 · 다시 긋기(599) · + * 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 중간 노드 = 다시 긋기(774) · 선 끝 = 잇기 · 여기에 잇기(638-3) · + * 선 위 = 갈래 시작 · 여기서 구간 나눔 · * 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음). * 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔. * 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음. @@ -11,6 +11,7 @@ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { isMiddleNode } from "./logic/D03_PlanAnalysis_Span"; import { lineLimitBlock, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features"; @@ -27,8 +28,8 @@ export interface PlanMenuParams { edit: PlanEdit; draw: PlanDraw; editable: () => boolean; - /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ - respan: (line: string, from: number, to: number) => boolean; + /** [다시 긋기](774) — 구역 id · 시작 중간 노드. */ + respan: (line: string, vertex: number) => boolean; } export interface PlanMenu { @@ -82,6 +83,9 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { const point = params.network().lines.find((l) => l.id === node.line)!.vertices[node.vertex]; const [sx, sy] = map.toScreen(point); start(sx, sy); + const count = params.network().lines.find((l) => l.id === node.line)!.vertices.length; + if (isMiddleNode(count, node.vertex)) + items.push(["다시 긋기", () => params.respan(node.line, node.vertex)]); items.push("-", ["노드 지움", () => edit.dropNode(node)]); if (featureOn(page, "join") && edit.isEnd(node)) items.push( @@ -92,11 +96,9 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { if (marks && !marks.full() && !points().some((p) => samePoint(p, point))) items.push(["지점으로", () => marks.place(sx, sy, map)]); } else if (at?.segment) { - const seg = at.segment; start(px, py); if (featureOn(page, "years")) items.push(["여기서 구간 나눔", () => edit.splitAt(px, py, map)]); - items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]); } else start(px, py); if (marks && pin === null && !at?.node && !at?.mark && !marks.full()) items.push("-", ["지점 놓기", () => marks.place(px, py, map)]); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index eb238d456..2554ddfe0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -3,9 +3,9 @@ * D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈. * * 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂. - * 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김) · - * [잇기](선 끝 · 638-3). - * 선(토막) — 측점 · 길이 · 지반 기울기 · 세트 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘). + * 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [다시 긋기](중간 노드 · 774) [노드 지움] + * [지점으로](그 자리에 지점 · 이미 있으면 숨김) · [잇기](선 끝 · 638-3). + * 선(토막) — 측점 · 길이 · 지반 기울기 · 세트 · [선 지움](가운데 = 구역 둘). * 구역 — 길이 · 노드 수 · 세트(구역 모든 구간) · 구간 줄 · [구역 지움]. * 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨). * 세트 구간(왼쪽 해 · 세트 · 구간 줄) — 세트(모두 같으면 그 세트 · 다르면 「여러 세트」) · 구간 줄 · 마커 — 앞뒤 세트 · [마커 지움]. @@ -34,7 +34,7 @@ import { segmentPieces, segmentRange, } from "./logic/D03_PlanAnalysis_Select"; -import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span"; +import { isMiddleNode, nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span"; import { createDrawKindFields, DRAW_DEFAULT_LENGTH_M, @@ -71,8 +71,8 @@ export interface PlanPropsParams { addPoint?: (point: Vertex) => void; /** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */ commitPoints: (points: Vertex[], next: PlanNetwork) => void; - /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ - respan: (line: string, from: number, to: number) => boolean; + /** [다시 긋기](774) — 구역 id · 시작 중간 노드. */ + respan: (line: string, vertex: number) => boolean; } export interface PlanProps { @@ -238,6 +238,9 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { title: `${nameOf(line)} · 노드 ${vertex + 1}`, parts, actions: [ + ...(isMiddleNode(vertices.length, vertex) + ? [button("다시 긋기", "props-respan", () => params.respan(line, vertex))] + : []), button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)), toPoint, ...(featureOn(page, "join") && edit.isEnd(picked.node!) @@ -265,12 +268,6 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { const parts: HTMLElement[] = [createKv(rows)]; if (yearsShown(network)) parts.push(setField(inSegments, ranges)); const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)]; - if (single) - actions.unshift( - button("다시 긋기", "props-respan", () => - params.respan(single.line, single.index, single.index + 1), - ), - ); return { title: single ? `${nameOf(single.line)} · 선 ${single.index + 1}` diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts index 2f74a8d29..c34e430e3 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts @@ -2,6 +2,7 @@ * D03_PlanAnalysis_Span.ts * D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈. * 중간 노드에서 긋기(771-2 · 요구 613) = `rejoinSpan` — 닿은 노드가 앞이면 거꾸로 · 같은 노드 = 까닭 글. + * 끝 판정(774 · 요구 616) = `rejoinMiss` — 다른 중간 노드만 끝 · 시작 노드 · 끝점 = 계속 긋기. * 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 · * 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠). * 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유. @@ -127,3 +128,16 @@ export function rejoinSpan( ...back.slice(0, -1), ]); } + +/** 다시 긋기 시작 · 끝 자리 = 중간 노드(774 · 요구 616) — 꼭짓점 `count` 개 선의 `vertex` 가 끝점 아님. */ +export const isMiddleNode = (count: number, vertex: number): boolean => + vertex > 0 && vertex < count - 1; + +export const REJOIN_TIP = "끝점은 끝이 아닙니다 — 다른 중간 노드에 이어야 끝납니다."; +export const REJOIN_HOLD = "시작 노드는 끝이 아닙니다 — 다른 중간 노드에 이어야 끝납니다."; + +/** 다시 긋기 끝 판정(774) — 닿은 `vertex` 가 다른 중간 노드 = null(끝) · 시작 노드 · 끝점 = 계속 긋기 까닭 글. */ +export function rejoinMiss(count: number, start: number, vertex: number): string | null { + if (vertex === start) return REJOIN_HOLD; + return isMiddleNode(count, vertex) ? null : REJOIN_TIP; +} diff --git a/resources/tester/test_771_2_rejoin_span.py b/resources/tester/test_771_2_rejoin_span.py index e9cd85f2d..461f79bc2 100644 --- a/resources/tester/test_771_2_rejoin_span.py +++ b/resources/tester/test_771_2_rejoin_span.py @@ -3,6 +3,7 @@ 중간 노드에서 그은 점이 같은 선 다른 노드에 닿음 = 두 노드 사이 옛 구간을 새 점으로 갈음 · 한 줄 그대로 · 거꾸로(앞 노드에 닿음) = 점 차례 뒤집어 넣음 · 같은 노드 = 까닭 글(꼴 그대로) · 끝점에 닿음 = 끝까지 갈음 · 노드 기록 = 꼭짓점과 같은 길이. +774-1(요구 616) 끝 판정 `rejoinMiss` = 다른 중간 노드만 끝 · 시작 노드 · 끝점 = 계속 긋기 까닭 글 · 시작 = 중간 노드만. ./venv/Scripts/python.exe -m pytest resources/tester/test_771_2_rejoin_span.py -q """ @@ -36,6 +37,10 @@ writeFileSync( tail: span.rejoinSpan(n, 0, 2, 4, [[300, 50]]), recorded: span.rejoinSpan(n, 0, 3, 1, [[250, 50], [150, 50]], [L, S]), same_text: span.REJOIN_SAME, + miss: [[2, 1], [2, 3], [2, 2], [2, 0], [2, 4], [1, 3]].map(([s, v]) => span.rejoinMiss(5, s, v)), + middle: [0, 1, 2, 3, 4].map((v) => span.isMiddleNode(5, v)), + tip_text: span.REJOIN_TIP, + hold_text: span.REJOIN_HOLD, }), ); """ @@ -108,3 +113,12 @@ def test_backward_records(tmp_path: Path) -> None: assert nodes[2] is None assert nodes[3]["kind"] == "slope" assert nodes[4]["kind"] == "free" + + +def test_end_only_other_middle_node(tmp_path: Path) -> None: + """774-1 — 다른 중간 노드 = 끝(null) · 시작 노드 · 머리 · 꼬리 끝점 = 계속 긋기 글 · 시작 = 중간 노드만.""" + r = _run_ts(tmp_path) + tip, hold = r["tip_text"], r["hold_text"] + assert r["miss"] == [None, None, hold, tip, tip, None] + assert "중간 노드" in tip and "중간 노드" in hold + assert r["middle"] == [False, True, True, True, False]