From ed7f7fa67443aaa7c451486af17ac8d5852d7429 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:02:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):?= =?UTF-8?q?=20=EB=85=B8=EB=93=9C=20=ED=8C=90=20=E2=80=94=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=C2=B7=20=EA=B0=92=20=EA=B3=A0=EC=B9=A8(=EC=95=9E?= =?UTF-8?q?=20=EB=85=B8=EB=93=9C=20=EA=B8=B0=EC=A4=80=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EB=86=93=EC=9D=8C=20=C2=B7=20=EB=92=A4=20=EB=85=B8=EB=93=9C?= =?UTF-8?q?=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20=EC=96=B4=EA=B8=8B?= =?UTF-8?q?=EB=82=A8=20=E2=9A=A0)=20=C2=B7=20[=EC=A7=80=EC=A0=90=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C]=20=C2=B7=20[=EB=8B=A4=EC=8B=9C=20=EA=B8=8B=EA=B8=B0]?= =?UTF-8?q?=20=EC=9D=B4=EC=9D=8C=20(PLAN=20598-4=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20463-11=20=C2=B7=20464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/Select placeNode — 지점 자리 노드면 지점 같이 · 첫 노드 = 까닭 글 - UI_Props 노드 판 = 599-1 방식 칸(createDrawKindFields) · 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 - 판 · 메뉴 [다시 긋기] = draw.respan(599-3) - 시험 598_1 placeNode 줄 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts | 7 +-- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 3 + D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts | 62 ++++++++++++++++--- .../logic/D03_PlanAnalysis_Select.ts | 50 ++++++++++++++- .../tester/test_598_1_plan_select_layers.py | 10 +++ 5 files changed, 120 insertions(+), 12 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts index a121b8816..da66c2a69 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -22,8 +22,8 @@ export interface PlanMenuParams { edit: PlanEdit; draw: PlanDraw; editable: () => boolean; - /** [다시 긋기](599 · 없으면 항목 없음) — 구역 id · 앞 노드 · 뒤 노드. */ - respan?: (line: string, from: number, to: number) => boolean; + /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ + respan: (line: string, from: number, to: number) => boolean; } export interface PlanMenu { @@ -80,8 +80,7 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { const seg = at.segment; start(px, py); items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]); - if (params.respan) - items.push(["다시 긋기", () => params.respan!(seg.line, seg.index, seg.index + 1)]); + items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]); } else start(px, py); if (pin === null && !at?.node && !at?.mark && !marks.full()) items.push("-", ["지점 놓기", () => marks.place(px, py, map)]); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 760fa990a..5dc63870f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -155,6 +155,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { epsg: () => epsg, points: () => points, addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }), + commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }), + respan: draw.respan, }); const pieces = createPlanPieces({ network: () => network, edit }); const measure = createPlanMeasure( @@ -183,6 +185,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { edit, draw, editable: () => editable, + respan: draw.respan, }); // 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4) const viewWatch: PlanMapLayer = { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index 3c567223c..e5d33520a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -12,7 +12,7 @@ * 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임). * ========================================================================== */ -import { createButton } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { createFloatPanel } from "@ui/ui_template_float_panel"; import { createSelectField } from "@ui/ui_template_input"; import { createKv, type KvRow } from "@ui/ui_template_kv"; @@ -26,8 +26,20 @@ import { type PlanPiece, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; -import { segmentPath, segmentPieces, segmentRange } from "./logic/D03_PlanAnalysis_Select"; +import { + placeNode, + segmentPath, + segmentPieces, + segmentRange, +} from "./logic/D03_PlanAnalysis_Select"; +import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; +import { + createDrawKindFields, + DRAW_DEFAULT_LENGTH_M, + DRAW_DEFAULT_SLOPE_PCT, + type DrawKindValue, +} from "./D03_PlanAnalysis_UI_DrawPanel"; import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { selectionLength } from "./D03_PlanAnalysis_UI_Select"; @@ -50,8 +62,10 @@ export interface PlanPropsParams { /** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */ points: () => Vertex[]; addPoint: (point: Vertex) => void; - /** [다시 긋기](599 · 없으면 단추 없음) — 구역 id · 앞 노드 · 뒤 노드. */ - respan?: (line: string, from: number, to: number) => boolean; + /** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */ + commitPoints: (points: Vertex[], next: PlanNetwork) => void; + /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ + respan: (line: string, from: number, to: number) => boolean; } export interface PlanProps { @@ -125,6 +139,36 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { return rows; } + /** 노드 방식 · 값(598-4 · 599-1 부품) — 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 · 앞 노드 기준 다시 놓음. */ + function nodeKind(line: number, vertex: number): HTMLElement { + const record = params.network().lines[line].nodes?.[vertex] ?? null; + let value: DrawKindValue = { + kind: record?.kind ?? "free", + lengthM: record?.length_m ?? DRAW_DEFAULT_LENGTH_M, + slopePct: record?.slope_pct ?? DRAW_DEFAULT_SLOPE_PCT, + }; + const apply = (): void => { + const network = params.network(); + const next = nodeRecord(value.kind, value.lengthM, value.slopePct); + const out = placeNode(network, params.points(), line, vertex, next, params.heightAt()); + if (typeof out === "string") return void showToast(out, "info"); + if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines)) + params.commitPoints(out.points, out.network); + }; + const fields = createDrawKindFields({ + value, + onChange: (next) => { + const kindChanged = next.kind !== value.kind; + value = next; + if (kindChanged) apply(); + }, + }); + fields.root.addEventListener("change", apply); + fields.root.dataset.role = "props-node-kind"; + fields.setDisabled(!params.editable()); + return fields.root; + } + /** 고름 꼴마다 — 머리 글 · 몸 · 단추. */ function body( picked: PlanSelection, @@ -159,13 +203,17 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { { label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) }, ); } + const entry = network.lines.find((l) => l.id === line)!; + if (nodeOff(entry, vertex, z)) rows.push({ label: "기록", value: "⚠ 지금 자리와 어긋남" }); + const parts: HTMLElement[] = [createKv(rows)]; + if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex)); const toPoint = button("지점으로", "props-to-point", () => params.addPoint([point[0], point[1]]), ); toPoint.hidden = params.points().some((p) => samePoint(p, point)); return { title: `${nameOf(line)} · 노드 ${vertex + 1}`, - parts: [createKv(rows)], + parts, actions: [ button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)), toPoint, @@ -191,10 +239,10 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { } rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)]; - if (single && params.respan) + if (single) actions.unshift( button("다시 긋기", "props-respan", () => - params.respan!(single.line, single.index, single.index + 1), + params.respan(single.line, single.index, single.index + 1), ), ); return { diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts index ba25df693..4b44d001a 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts @@ -4,15 +4,26 @@ * 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음. * 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감). * 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기. + * 노드 판(598-4) 방식 · 값 바꿈 = 앞 노드 기준 같은 방향으로 다시 놓음 · 뒤 노드 그대로(어긋남 ⚠ = 599-2 `nodeOff`). * DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`). * ========================================================================== */ -import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw"; import { + compassPoint, + fixedLengthPoint, + fixedSlopePoint, + lineLength, + segmentLength, + type HeightAt, +} from "./D03_PlanAnalysis_Draw"; +import { + moveNodesAt, + moveVertex, piecesInBox, removeLine, samePoint, orderLines, + type NodeRecord, type PlanLine, type PlanNetwork, type PlanPiece, @@ -237,3 +248,40 @@ export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] { export function movePointsAt(points: ReadonlyArray, from: Vertex, to: Vertex): Vertex[] { return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p)); } + +/** 노드 판(598-4) — `vertex` 노드 기록을 `record` 로 · 앞 노드 기준 지금 방향으로 다시 놓음(자유 = 자리 그대로) · + * 뒤 노드 그대로 · 그 자리 지점 · 같은 자리 노드도 같이 · 첫 노드 · 자리 못 찾음 = 까닭 글. */ +export function placeNode( + network: PlanNetwork, + points: ReadonlyArray, + line: number, + vertex: number, + record: NodeRecord, + heightAt: HeightAt, +): { network: PlanNetwork; points: Vertex[] } | string { + const entry = network.lines[line]; + if (!entry || vertex < 1 || vertex >= entry.vertices.length) return "첫 노드는 방식이 없습니다."; + const [prev, here] = [entry.vertices[vertex - 1], entry.vertices[vertex]]; + const { kind, length_m: lengthM, slope_pct: slopePct } = record; + const to = + kind === "free" + ? here + : kind === "length" + ? fixedLengthPoint(prev, here, lengthM ?? 0) + : kind === "slope" + ? fixedSlopePoint(prev, here, slopePct ?? 0, heightAt) + : compassPoint(prev, here, lengthM ?? 0, slopePct ?? 0, heightAt); + if (!to) return "그 값으로 놓을 자리가 없습니다(지형 · 길이를 보세요)."; + const bound = points.some((p) => samePoint(p, here)); + let next = bound ? moveNodesAt(network, here, to) : moveVertex(network, line, vertex, to); + next = { + ...next, + lines: next.lines.map((l) => { + if (l.id !== entry.id) return l; + const nodes = l.nodes ? [...l.nodes] : l.vertices.map(() => null); + nodes[vertex] = record; + return { ...l, nodes }; + }), + }; + return { network: next, points: bound ? movePointsAt(points, here, to) : [...points] }; +} diff --git a/resources/tester/test_598_1_plan_select_layers.py b/resources/tester/test_598_1_plan_select_layers.py index 0ae23beea..153a37311 100644 --- a/resources/tester/test_598_1_plan_select_layers.py +++ b/resources/tester/test_598_1_plan_select_layers.py @@ -52,6 +52,11 @@ writeFileSync(process.argv[2], JSON.stringify({ all: show(sel.removeAll(n)), nodes: sel.nodesAt(n, [200, 0]), moved: sel.movePointsAt([[200, 0], [5, 5]], [200, 0], [210, 5]), + placed: (() => { + const out = sel.placeNode(n, [[100, 0], [9, 9]], 0, 1, { kind: "length", length_m: 50, slope_pct: null }, (x) => x * 0.1); + return [out.network.lines[0].vertices, out.network.lines[0].nodes, out.points]; + })(), + placeFirst: sel.placeNode(n, [], 0, 0, { kind: "free", length_m: null, slope_pct: null }, () => 0), nodes594: (() => { const r = { kind: "length", length_m: 100, slope_pct: null }; const withNodes = { ...n, lines: n.lines.map((l, i) => (i ? l : { ...l, nodes: [null, r, r, r] })) }; @@ -133,6 +138,11 @@ def test_끌어_고르기_전체_지점_노드(tmp_path: Path) -> None: assert got["all"] == {"lines": [], "pieces": []} assert got["nodes"] == [{"line": "main", "vertex": 2}] # 갈래 첫 점 뺌 assert got["moved"] == [[210, 5], [5, 5]] + vertices, nodes, points = got["placed"] + assert vertices == [[0, 0], [50, 0], [200, 0], [300, 0]] # 앞 노드 기준 50 m · 뒤 노드 그대로 + assert nodes[1] == {"kind": "length", "length_m": 50, "slope_pct": None} and nodes[0] is None + assert points == [[50, 0], [9, 9]] # 그 자리 지점 같이 + assert got["placeFirst"] == "첫 노드는 방식이 없습니다." rec = {"kind": "length", "length_m": 100, "slope_pct": None} assert got["nodes594"] == [ ["main", [None, rec]],