diff --git a/A00_Common/key_registry.ts b/A00_Common/key_registry.ts index a42902472..c460cc0ff 100644 --- a/A00_Common/key_registry.ts +++ b/A00_Common/key_registry.ts @@ -138,17 +138,18 @@ export const KEY_ACTIONS: readonly KeyAction[] = [ A("plan.redo", "plan", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]), A("plan.drawFinish", "plan", ["Enter"], ["그리기 끝", "Finish drawing"]), A("plan.drawBack", "plan", ["Backspace"], ["그리기 마지막 점 빼기", "Drawing: remove last point"]), - A("plan.delete", "plan", ["Delete"], ["고른 것 지움", "Delete selection"]), + A("plan.delete", "plan", ["Delete"], ["고른 노드 · 선 · 구역 · 전체 · 마커 지움", "Delete picked node · segment · region · all · marker"]), A("plan.escape", "plan", ["Escape"], ["그리기 취소 · 고름 풀기 · 재기 끝", "Cancel drawing · clear selection · end measure"], F), A("plan.zoom", "plan", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F), A("plan.rotate", "plan", ["Ctrl+휠"], ["지도 돌림", "Rotate map"], F), A("plan.pan", "plan", [PAN], ["화면 옮김", "Pan"], F), - A("plan.vertex", "plan", ["고른 선 누름", "꼭짓점 끌기"], ["꼭짓점 넣고 끌기 · 옮김", "Insert · drag vertex"], F), - A("plan.unvertex", "plan", ["꼭짓점 · 마커 오른쪽 누름"], ["지움", "Delete"], F), + A("plan.select", "plan", ["누름 · 다시 누름"], ["노드 → 선 → 구역 → 전체 고름", "Pick node → segment → region → all"], F), + A("plan.vertex", "plan", ["고른 선 끌기", "노드 끌기"], ["꼭짓점 넣고 끌기 · 노드 옮김(지점 같이)", "Insert · drag vertex (point follows)"], F), + A("plan.unvertex", "plan", ["노드 · 마커 오른쪽 누름"], ["메뉴 [노드 지움] · [마커 지움]", "Menu: delete node · marker"], F), A("plan.measure", "plan", ["재기: 누름 · 누름"], ["시점 · 종점", "Start · end"], F), - A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 시작 · 지점 놓기 · 갈래", "Map menu — draw · place point · branch"], F), - A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이)", "Move point (with its node)"], F), + A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 · 지점 놓기 · 노드 지움 · 연도 구역 나눔", "Map menu — draw · place point · delete node · split year"], F), + A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이 · 노드 12 px 안 = 붙음)", "Move point (with its node · snaps within 12 px)"], F), A("b04.zoom", "b04", ["휠"], ["지도 · 3D 확대 · 축소", "Map · 3D zoom"], F), A("b04.pan", "b04", [PAN], ["지도 · 3D 옮김", "Map · 3D pan"], F), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index abe6298fe..259eb0f29 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -1,57 +1,67 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Edit.ts - * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만. + * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 598 · 요구 463 9 · 12 · 13·16 · 15) — 쉴 때(긋기 · 재기 아님)만. * - * 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위 - * (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. - * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) · - * 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함. - * 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) · - * 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · - * Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움. - * 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 · - * 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침). - * 잠금 = 지점에 놓인 점(지점 핀을 끌면 같이 감 · 오른쪽 누름 = 지도 메뉴 591-2) · 갈래 첫 점(붙은 선 점을 끌면 같이 감). - * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함). - * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름). + * 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`UI_Select`) — 노드 누름 = 노드 · 선 위 누름 = 토막 · + * 고른 것 다시 누름 = 한 단 넓힘(전체 다음 = 처음) · Shift+누름 = 토막 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. + * 「같은 해」 = 왼쪽 해 줄로만(`selectYear`). + * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 토막) — 오른쪽으로 = 창(실선 · 다 든 토막만) · + * 왼쪽으로 = 걸침(점선 · 걸친 토막까지) · Shift = 지금 고름에 더함. + * 노드 끌기 = 옮김(붙은 갈래 첫 점 같이) · 지점 자리 노드 = 지점도 같이 · 지점 12 px 안에 놓으면 그 자리로 붙음 · + * 고른 토막 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 옮김 · + * 선 밖으로 끌어냄 = 지움(합침). + * 지움 = Delete · 속성 판 · 왼쪽 목록 · 우클릭 메뉴가 같은 함수 — 노드 · 토막(가운데 = 구역 둘) · 구역 · 전체 · 마크. + * 우클릭 = 메뉴(`UI_Menu` — 이 겹은 오른쪽 누름을 안 받음). + * 연도 구역 마커(585-3) = 「연도 마커」를 선 위로 끌어 놓음 · 메뉴 「여기서 연도 구역 나눔」(`splitAt`) = 그 자리 구간을 둘로. + * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 = 화면 기억(저장 안 함). + * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 동작을 부름). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { themeColor } from "@ui/ui_template_palette"; import { keyIs } from "../A00_Common/key_registry"; -import { - distanceAlong, - lineLength, - pointAtDistance, - sliceLine, -} from "./logic/D03_PlanAnalysis_Draw"; +import { distanceAlong, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw"; import { insertVertex, moveMark, + moveNodesAt, moveVertex, - piecesInBox, removeLine, removeMark, removeVertex, - reverseLine, samePoint, splitPiece, - branchFromEnd, type PlanNetwork, type Vertex, type ViewBox, } from "./logic/D03_PlanAnalysis_Plan"; -import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { + movePointsAt, + nodeSegment, + removeAll, + removeSegments, + segmentsInBox, + type PlanNode, + type PlanSegment, +} from "./logic/D03_PlanAnalysis_Select"; +import { + lineAt, + paintMark, + planLabel, + screenGap, + SNAP_PX, + strokeCased, +} from "./D03_PlanAnalysis_UI_DrawPaint"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select"; + +export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select"; /** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */ const DRAG_PX = 4; /** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */ const MARK_OFF_PX = 3 * SNAP_PX; -export type PlanScope = "piece" | "line" | "year"; -const NEXT_SCOPE: Record = { piece: "line", line: "year", year: "piece" }; - export interface PlanEditParams { network: () => PlanNetwork; /** 지점(긋기에 붙는 점 · 591-2). */ @@ -59,6 +69,9 @@ export interface PlanEditParams { commit: (next: PlanNetwork) => void; /** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */ show: (next: PlanNetwork) => void; + /** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */ + commitPoints: (points: Vertex[], next: PlanNetwork) => void; + showPoints: (points: Vertex[], next: PlanNetwork) => void; /** 고르기 받는 때(쉴 때 · 재기 아님). */ enabled: () => boolean; /** 고치기 됨(산림청). */ @@ -67,151 +80,123 @@ export interface PlanEditParams { onSelect: () => void; } -/** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */ -export interface PlanSelection { - pieces: string[]; - scope: PlanScope; - seed: string | null; +/** 누른 자리 — 마크(구간 id) · 노드 · 토막(선 위 · 노드면 넓힐 토막). */ +export interface PlanHit { mark: string | null; + node: PlanNode | null; + segment: PlanSegment | null; } export interface PlanEdit { layer: PlanMapLayer; selected: () => PlanSelection | null; - /** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */ + /** 밝힐 꺾은선(고른 토막 · 구역 · 전체 · 구간). */ + paths: () => Vertex[][]; + /** 구간 id 묶음 고름(빈 묶음 = 풂) · 마크 고름. */ select: (pieces: string[], mark?: string | null) => void; - /** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */ + /** 구간 고름 넓이 바꿈(구간 · 선 · 같은 해 — 왼쪽 목록). */ setScope: (scope: PlanScope) => void; - /** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */ - flipLine: () => void; - dropLine: () => void; + selectNode: (node: PlanNode) => void; + selectRegion: (line: string) => void; + selectYear: (year: number) => void; + selectAll: () => void; + /** 고른 것 지움(Delete · 판 [지움]). */ + dropSelected: () => void; + dropNode: (node: PlanNode) => void; dropMark: () => void; - /** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */ - branchLine: () => void; - /** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */ - lineIndex: () => number; + dropRegion: (line: string) => void; + /** 전체 지움 — 선 · 구역 모두(지점 남김) · 되돌리기 한 걸음. */ + dropAll: () => void; + /** 이 자리 선 위에 연도 구역 마커 — 놓음 = 참. */ + splitAt: (px: number, py: number, map: ScreenMap) => boolean; + /** 이 자리에 무엇이 있나(우클릭 메뉴). */ + hit: (px: number, py: number, map: ScreenMap) => PlanHit; /** 키 — 받았으면 참(Delete · Esc). */ key: (event: KeyboardEvent) => boolean; - /** 되돌리기 · 저장 뒤 — 사라진 구간은 뺌 · 다 사라지면 풂. */ + /** 되돌리기 · 저장 뒤 — 사라진 것은 뺌 · 다 사라지면 풂. */ sync: () => void; /** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */ box: () => (ViewBox & { crossing: boolean }) | null; } type Drag = - | { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null } - | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } - | { kind: "locked"; note: string; told: boolean } - /** 고른 구간 다시 누름 — 놓으면 넓힘 · 끌면(고칠 수 있을 때) 꺾임점 더해 옮김. */ | { - kind: "grow"; - piece: string; + kind: "vertex"; + line: number; + vertex: number; + /** 끌기 앞 자리 · 같이 가는 지점(같은 자리)이 있나. */ + from: Vertex; + follow: boolean; + before: PlanNetwork; + points: Vertex[]; + last: [Vertex[], PlanNetwork] | null; + } + | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } + /** 노드 · 선 누름 — 놓으면(고른 것이었으면) 넓힘 · 끌면 노드 옮김 · 고른 토막이면 꺾임점 더해 옮김. */ + | { + kind: "press"; x: number; y: number; - hit: { line: number; segment: number; point: Vertex } | null; + node: PlanNode | null; + on: { line: number; segment: number; point: Vertex } | null; + grow: boolean; } /** 빈 곳 끌기 — 보이는 px 시작 · 지금. */ | { kind: "box"; box: ViewBox; shift: boolean }; export function createPlanEdit(params: PlanEditParams): PlanEdit { - let selected: PlanSelection | null = null; + const sel = createPlanSelect(params.network, params.onSelect); let drag: Drag | null = null; /** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */ let markDrop: { point: Vertex; ok: boolean; text: string } | null = null; - const pieceIds = (network: PlanNetwork): Set => - new Set(network.pieces.map((piece) => piece.id)); + const lineIndexOf = (network: PlanNetwork, id: string): number => + network.lines.findIndex((line) => line.id === id); - /** 씨 구간 → 넓이만큼 구간 묶음. */ - function expand(seed: string, scope: PlanScope): string[] { - const network = params.network(); - const piece = network.pieces.find((p) => p.id === seed); - if (!piece) return []; - if (scope === "piece") return [seed]; - return network.pieces - .filter((p) => (scope === "line" ? p.line === piece.line : p.year === piece.year)) - .map((p) => p.id); - } - - function choose(next: PlanSelection | null): void { - selected = next && next.pieces.length ? next : null; - params.onSelect(); - } - - function select(pieces: string[], mark: string | null = null): void { - choose({ pieces: [...new Set(pieces)], scope: "piece", seed: pieces[0] ?? null, mark }); - } - - function setScope(scope: PlanScope): void { - const seed = selected?.seed ?? selected?.pieces[0]; - if (!seed) return; - choose({ pieces: expand(seed, scope), scope, seed, mark: null }); - } - - function lineIndex(): number { - const network = params.network(); - if (!selected) return -1; - const lines = new Set( - selected.pieces.map((id) => network.pieces.find((p) => p.id === id)?.line), - ); - if (lines.size !== 1) return -1; - const [id] = lines; - return network.lines.findIndex((line) => line.id === id); - } - - /** 고른 구간의 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */ - function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void { - const network = params.network(); - const index = lineIndex(); + /** 꼴 바꿈 하나 — 까닭 글이면 알림. */ + function apply(next: PlanNetwork | string): void { if (!params.editable()) return; - if (index < 0) return void showToast("선 하나의 구간만 골랐을 때 됩니다.", "info"); - const next = step(network, index); if (typeof next === "string") return void showToast(next, "info"); params.commit(next); - sync(); + sel.sync(); } function dropMark(): void { const network = params.network(); - const piece = network.pieces.find((p) => p.id === selected?.mark); - if (!piece || !params.editable()) return; - params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); - sync(); + const piece = network.pieces.find((p) => p.id === sel.get()?.mark); + if (piece) apply(removeMark(network, piece.line, piece.from_m, 1e-3)); } - /** Delete — 마크 · 선 통째 고름 = 그 선. */ - function dropSelected(): void { - if (selected?.mark) return dropMark(); + function dropNode(node: PlanNode): void { const network = params.network(); - const index = lineIndex(); - const whole = - index >= 0 && - network.pieces - .filter((p) => p.line === network.lines[index].id) - .every((p) => selected!.pieces.includes(p.id)); - if (!whole) - return void showToast("선 넓이로 고르면 지웁니다(다시 누름 · 속성 판 [선]).", "info"); - apply((net, i) => removeLine(net, i)); + const index = lineIndexOf(network, node.line); + if (index >= 0) apply(removeVertex(network, index, node.vertex)); } - /** 사라진 구간 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onSelect 를 부르면 돌고 돎). */ - function sync(): void { - if (!selected) return; - const ids = pieceIds(params.network()); - const pieces = selected.pieces.filter((id) => ids.has(id)); - selected = pieces.length - ? { - ...selected, - pieces, - seed: selected.seed && ids.has(selected.seed) ? selected.seed : pieces[0], - mark: selected.mark && ids.has(selected.mark) ? selected.mark : null, - } - : null; + function dropRegion(line: string): void { + const network = params.network(); + const index = lineIndexOf(network, line); + if (index >= 0) apply(removeLine(network, index)); } - const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null => - network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6) - ?.id ?? null; + function dropAll(): void { + const network = params.network(); + if (!network.lines.length) return; + apply(removeAll(network)); + showToast("노선을 모두 지웠습니다 — 되돌리기(Ctrl+Z) · [저장] 때 정본.", "info"); + } + + /** Delete · 판 [지움] — 고름 꼴마다. */ + function dropSelected(): void { + const picked = sel.get(); + if (!picked) return; + if (picked.kind === "mark") return dropMark(); + if (picked.kind === "node") return dropNode(picked.node!); + if (picked.kind === "segment") return apply(removeSegments(params.network(), picked.segments)); + if (picked.kind === "line") return dropRegion(picked.line!); + if (picked.kind === "all") return dropAll(); + showToast("연도 구간은 지우지 않습니다 — 노드 · 선 · 구역으로 고르세요.", "info"); + } /** 화면 px 둘레 마크(조각 시작) — 조각 차례. */ function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null { @@ -245,12 +230,33 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { return best; } - /** 잠금 까닭 — 지점에 놓인 점. */ - function lockOf(network: PlanNetwork, line: number, vertex: number): string | null { - const point = network.lines[line].vertices[vertex]; - return params.base().some((p) => samePoint(p, point)) - ? "지점 자리 점은 지점 핀을 끌어 옮깁니다." - : null; + /** 지점 12 px 안이면 그 자리(노드가 붙음) — `skip` 자리 지점(같이 가는 것) 빼고. */ + function pointNear(px: number, py: number, map: ScreenMap, skip: Vertex): Vertex | null { + const near = params + .base() + .filter((p) => !samePoint(p, skip)) + .map((p) => ({ p, gap: screenGap(map, p, px, py) })) + .filter((item) => item.gap <= SNAP_PX) + .sort((a, b) => a.gap - b.gap)[0]; + return near ? near.p : null; + } + + function hit(px: number, py: number, map: ScreenMap): PlanHit { + const network = params.network(); + const mark = markAt(network, px, py, map); + if (mark !== null) return { mark: network.pieces[mark].id, node: null, segment: null }; + const vertex = vertexAt(network, px, py, map); + if (vertex) { + const node = { line: network.lines[vertex.line].id, vertex: vertex.vertex }; + return { mark: null, node, segment: nodeSegment(network, node) }; + } + const on = lineAt(network, px, py, map); + if (!on) return { mark: null, node: null, segment: null }; + return { + mark: null, + node: null, + segment: { line: network.lines[on.line].id, index: on.segment }, + }; } /** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */ @@ -260,66 +266,62 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { map: ScreenMap, ): { point: Vertex; next: PlanNetwork | null; piece: string | null } { const network = params.network(); - const hit = lineAt(network, px, py, map); - if (!hit) return { point: map.toMetric(px, py), next: null, piece: null }; - const next = splitPiece(network, network.lines[hit.line].id, hit.atM); + const on = lineAt(network, px, py, map); + if (!on) return { point: map.toMetric(px, py), next: null, piece: null }; + const next = splitPiece(network, network.lines[on.line].id, on.atM); const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id)); - return { point: hit.point, next: added ? next : null, piece: added?.id ?? null }; + return { point: on.point, next: added ? next : null, piece: added?.id ?? null }; } - /** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */ - function pressPiece( - piece: string, - shift: boolean, - x: number, - y: number, - hit: { line: number; segment: number; point: Vertex } | null, - ): void { - if (shift && selected) { - const has = selected.pieces.includes(piece); - const pieces = has - ? selected.pieces.filter((id) => id !== piece) - : [...selected.pieces, piece]; - return choose({ pieces, scope: "piece", seed: has ? pieces[0] : piece, mark: null }); + function splitAt(px: number, py: number, map: ScreenMap): boolean { + if (!params.enabled() || !params.editable()) return false; + const place = markPlace(px, py, map); + if (!place.next || !place.piece) { + showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"); + return false; } - if (selected?.pieces.includes(piece) && !selected.mark) { - drag = { kind: "grow", piece, x, y, hit }; - return; - } - select([piece]); + params.commit(place.next); + sel.pieces([place.piece], place.piece); + return true; } - /** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */ - const pickedLines = (network: PlanNetwork): Set => - new Set( - (selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""), - ); + /** 노드 끌기 시작 — 지점 자리면 지점도 같이. */ + function startVertex(line: number, vertex: number): Drag { + const before = params.network(); + const from = before.lines[line].vertices[vertex]; + const points = params.base(); + const follow = points.some((p) => samePoint(p, from)); + return { kind: "vertex", line, vertex, from, follow, before, points, last: null }; + } + + /** 고른 노드 · 토막 · 구역이 든 선(꺾임점 손잡이). */ + function pickedLines(network: PlanNetwork): Set { + const picked = sel.get(); + if (!picked) return new Set(); + if (picked.kind === "all") return new Set(network.lines.map((line) => line.id)); + const ids = picked.segments.map((seg) => seg.line); + for (const id of picked.pieces) ids.push(network.pieces.find((p) => p.id === id)?.line ?? ""); + if (picked.line) ids.push(picked.line); + return new Set(ids); + } const layer: PlanMapLayer = { draw(context, map) { const network = params.network(); const colors = planColors(); - const path = (points: Vertex[]): void => { + const picked = sel.get(); + context.lineCap = "round"; + context.lineJoin = "round"; + for (const points of sel.paths()) { context.beginPath(); points.forEach((point, i) => { const [sx, sy] = map.toScreen(point); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); - context.stroke(); - }; - context.lineCap = "round"; - context.lineJoin = "round"; - context.strokeStyle = colors.active; - context.globalAlpha = 0.8; - context.lineWidth = 8; - for (const id of selected?.pieces ?? []) { - const piece = network.pieces.find((p) => p.id === id); - const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices; - if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m)); + strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1) } - context.globalAlpha = 1; - // 꺾임점 손잡이(고른 구간이 든 선) + // 꺾임점 손잡이(고른 것이 든 선) const lines = pickedLines(network); context.lineWidth = 2; context.fillStyle = "#ffffff"; @@ -331,11 +333,16 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { context.strokeRect(sx - 4, sy - 4, 8, 8); } } - // 고른 마크 테 - const mark = network.pieces.find((p) => p.id === selected?.mark); + // 고른 노드 · 마크 테 + const node = picked?.kind === "node" ? picked.node : null; + const nodePoint = node + ? network.lines.find((line) => line.id === node.line)?.vertices[node.vertex] + : null; + const mark = network.pieces.find((p) => p.id === picked?.mark); const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices; - if (mark && markLine) { - const [sx, sy] = map.toScreen(pointAtDistance(markLine, mark.from_m)); + const ring = nodePoint ?? (mark && markLine ? pointAtDistance(markLine, mark.from_m) : null); + if (ring) { + const [sx, sy] = map.toScreen(ring); context.beginPath(); context.arc(sx, sy, 12, 0, Math.PI * 2); context.lineWidth = 3; @@ -378,48 +385,28 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { } }, press(px, py, map, button, shift) { - if (!params.enabled()) return false; + if (!params.enabled() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu) const network = params.network(); - const editable = params.editable(); const mark = markAt(network, px, py, map); - const vertex = vertexAt(network, px, py, map); - if (button === 2) { - if (!editable) return false; - if (mark !== null) { - const piece = network.pieces[mark]; - params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); - sync(); - return true; - } - if (!vertex || lockOf(network, vertex.line, vertex.vertex)) return false; // 지점 = 지도 메뉴 - const next = removeVertex(network, vertex.line, vertex.vertex); - if (typeof next === "string") showToast(next, "info"); - else params.commit(next); - sync(); - return true; - } if (mark !== null) { const piece = network.pieces[mark]; - select([piece.id], piece.id); - if (editable) drag = { kind: "mark", index: mark, before: network, last: null }; + sel.pieces([piece.id], piece.id); + if (params.editable()) drag = { kind: "mark", index: mark, before: network, last: null }; return true; } - if (vertex) { - const id = network.lines[vertex.line].id; - const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1)); - const piece = pieceAt(network, id, atM); - if (piece && !selected?.pieces.includes(piece)) pressPiece(piece, shift, px, py, null); - const lock = lockOf(network, vertex.line, vertex.vertex); - if (editable && !shift) - drag = lock - ? { kind: "locked", note: lock, told: false } - : { kind: "vertex", ...vertex, before: network, last: null }; - return true; - } - const hit = lineAt(network, px, py, map); - if (hit) { - const piece = pieceAt(network, network.lines[hit.line].id, hit.atM); - if (piece) pressPiece(piece, shift, px, py, hit); + const at = hit(px, py, map); + if (at.segment) { + if (shift) return (sel.segments([at.segment], true), true); + const grow = sel.holds({ + node: at.node ?? undefined, + segment: at.node ? undefined : at.segment, + }); + if (!grow) { + if (at.node) sel.node(at.node); + else sel.segments([at.segment]); + } + const on = at.node ? null : lineAt(network, px, py, map); + drag = { kind: "press", x: px, y: py, node: at.node, on, grow }; return true; } const [vx, vy] = map.rerotate(px, py); @@ -432,31 +419,34 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { [drag.box.x1, drag.box.y1] = map.rerotate(px, py); return; } - if (drag.kind === "grow") { + if (drag.kind === "press") { if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return; - const hit = drag.hit; - if (!params.editable() || !hit) return void (drag = null); - params.commit(insertVertex(params.network(), hit.line, hit.segment, hit.point)); - const before = params.network(); - drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null }; - } - if (drag.kind === "locked") { - if (!drag.told) showToast(drag.note, "info"); - drag.told = true; - return; + const { node, on, grow } = drag; + const network = params.network(); + if (!params.editable()) return void (drag = null); + if (node) drag = startVertex(lineIndexOf(network, node.line), node.vertex); + else if (grow && on) { + params.commit(insertVertex(network, on.line, on.segment, on.point)); + drag = startVertex(on.line, on.segment + 1); + } else return void (drag = null); } const cursor = map.toMetric(px, py); if (drag.kind === "vertex") { - drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor); - } else { - const piece = drag.before.pieces[drag.index]; - const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices; - const atM = distanceAlong(vertices, cursor); - const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX; - drag.last = off - ? removeMark(drag.before, piece.line, piece.from_m, 1e-3) - : moveMark(drag.before, drag.index, atM); + const to = pointNear(px, py, map, drag.from) ?? cursor; + drag.last = drag.follow + ? [movePointsAt(drag.points, drag.from, to), moveNodesAt(drag.before, drag.from, to)] + : [drag.points, moveVertex(drag.before, drag.line, drag.vertex, to)]; + params.showPoints(...drag.last); + return; } + if (drag.kind !== "mark") return; + const piece = drag.before.pieces[drag.index]; + const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices; + const atM = distanceAlong(vertices, cursor); + const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX; + drag.last = off + ? removeMark(drag.before, piece.line, piece.from_m, 1e-3) + : moveMark(drag.before, drag.index, atM); params.show(drag.last); }, dragOver(px, py, map) { @@ -476,11 +466,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { drop(px, py, map) { markDrop = null; if (!params.enabled() || !params.editable()) return false; - const place = markPlace(px, py, map); - if (!place.next || !place.piece) - return (void showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"), true); - params.commit(place.next); - select([place.piece], place.piece); + splitAt(px, py, map); return true; }, leave() { @@ -489,56 +475,83 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { release(map) { const done = drag; drag = null; - if (!done || done.kind === "locked") return; - if (done.kind === "grow") { - if (selected) { - const scope = NEXT_SCOPE[selected.scope]; - choose({ pieces: expand(done.piece, scope), scope, seed: done.piece, mark: null }); - } + if (!done) return; + if (done.kind === "press") { + if (done.grow) sel.grow(); return; } if (done.kind === "box") { const { box, shift } = done; if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) { - if (!shift) choose(null); + if (!shift) sel.clear(); return; } const network = params.network(); const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point)); - const ids = piecesInBox(network, toView, box, box.x1 < box.x0); - select(shift && selected ? [...selected.pieces, ...ids] : ids); + const found = segmentsInBox(network, toView, box, box.x1 < box.x0); + const now = sel.get(); + sel.segments(shift && now?.kind === "segment" ? [...now.segments, ...found] : found); return; } if (!done.last) return; - params.show(done.before); - params.commit(done.last); - sync(); + if (done.kind === "vertex") { + params.showPoints(done.points, done.before); + params.commitPoints(...done.last); + } else { + params.show(done.before); + params.commit(done.last); + } + sel.sync(); }, }; + /** 왼쪽 목록 넓이 — 씨 구간(처음 고른 구간) 기준. */ + function setScope(scope: PlanScope): void { + const network = params.network(); + const seed = network.pieces.find((p) => p.id === sel.get()?.pieces[0]); + if (!seed) return; + if (scope === "line") return sel.region(seed.line); + if (scope === "piece") return sel.pieces([seed.id]); + const same = network.pieces.filter((p) => p.year === seed.year).map((p) => p.id); + sel.pieces([seed.id, ...same], null, "year"); + } + return { layer, - selected: () => selected, - select, + selected: sel.get, + paths: sel.paths, + select: (pieces, mark = null) => sel.pieces(pieces, mark), setScope, - flipLine: () => apply((network, index) => reverseLine(network, index)), - dropLine: () => apply((network, index) => removeLine(network, index)), + selectNode: sel.node, + selectRegion: sel.region, + selectYear(year) { + const ids = params + .network() + .pieces.filter((p) => p.year === year) + .map((p) => p.id); + sel.pieces(ids, null, "year"); + }, + selectAll: sel.all, + dropSelected, + dropNode, dropMark, - branchLine: () => apply((network, index) => branchFromEnd(network, index)), - lineIndex, + dropRegion, + dropAll, + splitAt, + hit, key(event) { - if (!selected || !params.enabled()) return false; + if (!sel.get() || !params.enabled()) return false; if (keyIs(event, "plan.delete") && params.editable()) { dropSelected(); return true; } if (event.key === "Escape") { - choose(null); + sel.clear(); return true; } return false; }, - sync, + sync: sel.sync, box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts new file mode 100644 index 000000000..da66c2a69 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Menu.ts + * D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목. + * 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 · + * 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 위 = 갈래 시작 · 여기서 연도 구역 나눔 · 다시 긋기(599) · + * 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음). + * 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔. + * 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음. + * ========================================================================== */ + +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; +import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; +import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; +import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points"; + +export interface PlanMenuParams { + network: () => PlanNetwork; + points: () => Vertex[]; + marks: PlanPoints; + edit: PlanEdit; + draw: PlanDraw; + editable: () => boolean; + /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ + respan: (line: string, from: number, to: number) => boolean; +} + +export interface PlanMenu { + layer: PlanMapLayer; + /** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */ + mount: (viewport: HTMLElement) => void; +} + +export function createPlanMenu(params: PlanMenuParams): PlanMenu { + const { marks, edit, draw } = params; + const menu = createMapContextMenu("d03-plan"); + let menuAt: [number, number] = [0, 0]; + + const layer: PlanMapLayer = { + draw: () => undefined, + press(px, py, map, button) { + if (button !== 2 || !params.editable()) return false; + const items: MapContextMenuItem[] = []; + const pin = marks.pinAt(px, py, map); + const at = pin === null ? edit.hit(px, py, map) : null; + const start = (x: number, y: number): void => { + const label = { + tip: "이어 긋기", + point: "지점에서 긋기 시작", + line: "갈래 시작", + empty: "여기서 긋기 시작", + }[draw.aim(x, y, map)]; + items.push([label, () => draw.startAt(x, y, map)]); + }; + if (draw.mode() === "draw" && draw.stroke()) + items.push(["긋기 끝", () => draw.setMode("idle")]); + else if (pin !== null) { + const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서 + items.push([`${marks.name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]); + items.push([`${marks.name(pin)} 지우기`, () => marks.remove(pin)]); + } else if (at?.mark) { + const mark = at.mark; + items.push([ + "마커 지움", + () => { + edit.select([mark], mark); + edit.dropMark(); + }, + ]); + } else if (at?.node) { + const node = at.node; + const point = params.network().lines.find((l) => l.id === node.line)!.vertices[node.vertex]; + const [sx, sy] = map.toScreen(point); + start(sx, sy); + items.push("-", ["노드 지움", () => edit.dropNode(node)]); + if (!marks.full() && !params.points().some((p) => samePoint(p, point))) + items.push(["지점으로", () => marks.place(sx, sy, map)]); + } else if (at?.segment) { + const seg = at.segment; + start(px, py); + items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]); + 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)]); + menu.open(menuAt[0], menuAt[1], items); + return true; + }, + }; + + return { + layer, + mount(viewport) { + viewport.append(menu.element); + viewport.addEventListener( + "contextmenu", + (event) => { + const rect = viewport.getBoundingClientRect(); + menuAt = [event.clientX - rect.left, event.clientY - rect.top]; + }, + true, + ); + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 34ba9669d..41cecdd06 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -57,6 +57,7 @@ import { type PlanMapLayer, } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; +import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu"; import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points"; import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; @@ -142,6 +143,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); }, }); + const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void => + step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로 const edit = createPlanEdit({ network: () => network, base: () => points, @@ -150,6 +153,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { network = normalizePieces(next, yearStart); map.redraw(); }, + commitPoints: stepPoints, + showPoints: (nextPoints, next) => { + [points, network] = [nextPoints, next]; + map.redraw(); + }, enabled: () => draw.mode() === "idle" && !measuring, editable: () => editable, onSelect: () => refresh(), @@ -160,6 +168,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { editable: () => editable, yearStart: () => yearStart, commit, + heightAt: () => z, + epsg: () => epsg, + points: () => points, + addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]), + commitPoints: stepPoints, + respan: draw.respan, }); const pieces = createPlanPieces({ network: () => network, @@ -177,9 +191,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { network: () => network, names: () => pointNames, epsg: () => epsg, - draw, - commit: (nextPoints, nextNames, next) => - step({ network: next, points: nextPoints, pointNames: nextNames, site }), + commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames), center: () => viewMid, show: (nextPoints, next) => { [points, network] = [nextPoints, next]; @@ -188,6 +200,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { idle: () => draw.mode() === "idle" && !measuring, editable: () => editable, }); + const menu = createPlanMenu({ + network: () => network, + points: () => points, + marks, + edit, + draw, + editable: () => editable, + respan: draw.respan, + }); // 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4) const viewWatch: PlanMapLayer = { draw(context, screen) { @@ -328,7 +349,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); } - /** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */ + /** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */ const layerList = (): PlanMapLayer[] => [ ...backdrop.layers, draw.layer, @@ -337,6 +358,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ...(measuring ? [measure.layer] : []), viewWatch, marks.pins, + menu.layer, ]; /** 점들이 받은 범위 안인가. */ @@ -637,7 +659,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { }; window.addEventListener("keydown", onKey); map.setLayers(layerList()); - marks.mount(map.root); + menu.mount(map.root); try { editable = !!(await fetchSessionUser())?.is_forest_service; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts index 73ddc73ff..c0824ac84 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts @@ -170,8 +170,7 @@ export function createPlanPieces(params: PlanPiecesParams): PlanPieces { danger: true, }); if (!ok) return; - edit.select([piecesNow()[0].id]); - edit.dropLine(); + edit.dropRegion(line.id); }, }); drop.dataset.role = "line-drop"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index 933685d29..18e0b57d1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -3,22 +3,21 @@ * D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로. * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). * [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음). - * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). - * 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 · - * 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝. - * 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 · - * 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움). + * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) · + * 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). + * 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림). + * 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위). * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; import { createButton } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; -import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { createListRow } from "@ui/ui_template_list_row"; import { pointColor } from "@ui/ui_template_palette"; import { moveNodesAt, nameError, + samePoint, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; @@ -32,7 +31,6 @@ import { type PointsState, } from "./logic/D03_PlanAnalysis_PointEdit"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; -import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; @@ -45,7 +43,6 @@ export interface PlanPointsParams { names: () => string[]; network: () => PlanNetwork; epsg: () => number; - draw: PlanDraw; /** 한 걸음 — 지점 · 이름 · 노선 함께. */ commit: (points: Vertex[], names: string[], network: PlanNetwork) => void; /** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */ @@ -63,8 +60,12 @@ export interface PlanPoints { input: PlanMapLayer; pins: PlanMapLayer; render: () => void; - /** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */ - mount: (viewport: HTMLElement) => void; + /** 우클릭 메뉴가 빌림 — 핀 집기 · 지점 놓기(노드 둘레면 그 노드) · 지우기 · 이름 · 더 놓을 수 있나. */ + pinAt: (px: number, py: number, map: ScreenMap) => number | null; + place: (px: number, py: number, map: ScreenMap) => void; + remove: (i: number) => void; + name: (i: number) => string; + full: () => boolean; } export function createPlanPoints(params: PlanPointsParams): PlanPoints { @@ -86,8 +87,6 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { let openIndex: number | null = null; // 펼친 줄(하나만) let syncEditor: (() => void) | null = null; let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) - const menu = createMapContextMenu("d03-plan"); - let menuAt: [number, number] = [0, 0]; let drag: { index: number; points: Vertex[]; @@ -129,61 +128,40 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { ); } - /** 지점 놓기 — 꺾임점 둘레(SNAP_PX)면 그 점 자리(노드에 붙음). */ - function place(px: number, py: number, map: ScreenMap): void { - if (params.points().length >= MAX_POINTS) return; + /** 붙음(598-3) — 화면 px 둘레(SNAP_PX) 가장 가까운 노드 자리 · `skip` 자리 노드(같이 가는 것) 빼고. */ + function nodeNear(px: number, py: number, map: ScreenMap, skip?: Vertex): Vertex | null { const node = params .network() .lines.flatMap((line) => line.vertices) + .filter((point) => !skip || !samePoint(point, skip)) .map((point) => ({ point, gap: screenGap(map, point, px, py) })) .filter((item) => item.gap <= SNAP_PX) .sort((a, b) => a.gap - b.gap)[0]; - const at = node?.point ?? map.toMetric(px, py); + return node ? node.point : null; + } + + /** 지점 놓기 — 노드 둘레면 그 노드 자리(노드에 붙음). */ + function place(px: number, py: number, map: ScreenMap): void { + if (params.points().length >= MAX_POINTS) return; + const at = nodeNear(px, py, map) ?? map.toMetric(px, py); params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network()); } - function openMenu(px: number, py: number, map: ScreenMap): boolean { - if (!params.editable()) return false; - const draw = params.draw; - const pin = pinAt(px, py, map); - const items: MapContextMenuItem[] = []; - if (draw.mode() === "draw" && draw.stroke()) - items.push(["긋기 끝", () => draw.setMode("idle")]); - else if (pin !== null) { - const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서 - items.push([`${name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]); - items.push([`${name(pin)} 지우기`, () => remove(pin)]); - } else { - const label = { - tip: "이어 긋기", - point: "지점에서 긋기 시작", - line: "갈래 시작", - empty: "여기서 긋기 시작", - }[draw.aim(px, py, map)]; - items.push([label, () => draw.startAt(px, py, map)]); - } - if (pin === null && params.points().length < MAX_POINTS) - items.push(["지점 놓기", () => place(px, py, map)]); - menu.open(menuAt[0], menuAt[1], items); - return true; - } - const pins = pointLayer(params.points, params.names); - pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false); const input: PlanMapLayer = { draw: () => undefined, press(px, py, map, button) { if (button !== 0 || !params.editable() || !params.idle()) return false; const index = pinAt(px, py, map); - if (index === null) return false; + if (index === null || nodeNear(px, py, map) !== null) return false; // 노드 위 = 고르기 겹(노드 고름 · 끌면 지점 같이) drag = { index, points: params.points(), network: params.network(), last: null }; return true; }, hover(px, py, map) { if (!drag) return; - const to = map.toMetric(px, py); const from = drag.points[drag.index]; + const to = nodeNear(px, py, map, from) ?? map.toMetric(px, py); const points = drag.points.map((p, k): Vertex => (k === drag!.index ? to : p)); drag.last = [points, moveNodesAt(drag.network, from, to)]; params.show(...drag.last); @@ -336,16 +314,10 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { input, pins, render, - mount(viewport) { - viewport.append(menu.element); - viewport.addEventListener( - "contextmenu", - (event) => { - const rect = viewport.getBoundingClientRect(); - menuAt = [event.clientX - rect.left, event.clientY - rect.top]; - }, - true, - ); - }, + pinAt, + place, + remove, + name, + full: () => params.points().length >= MAX_POINTS, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index 0efadf9c6..e5d33520a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -1,30 +1,55 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Props.ts - * D03 고른 구간 속성 판 · 좌측 「구역 · 연도」 목록(PLAN 585-2 · 요구서 445 A4) — 오토캐드 특성 창 꼴. + * D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈. * - * 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 — - * 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 → - * 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) · - * [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만. - * 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정] - * (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음. + * 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂. + * 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김). + * 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘). + * 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움]. + * 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨). + * 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움]. + * 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만. * 좌측 「구역 · 연도」 목록 = `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 { createSegment } from "@ui/ui_template_segment"; +import { createKv, type KvRow } from "@ui/ui_template_kv"; import { stationLabel } from "../common_util/common_util_station"; -import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan"; -import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit"; +import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw"; +import { + lineNames, + samePoint, + setPiecesYear, + type PlanNetwork, + type PlanPiece, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; +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"; /** 조각 연도 고를 수 — 사업연도 시작부터. */ const YEAR_SPAN = 10; const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); const meters = (m: number): string => `${m.toFixed(1)} m`; +const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`); export interface PlanPropsParams { network: () => PlanNetwork; @@ -32,6 +57,15 @@ export interface PlanPropsParams { editable: () => boolean; yearStart: () => number; commit: (next: PlanNetwork) => void; + heightAt: () => HeightAt; + epsg: () => number; + /** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */ + points: () => Vertex[]; + addPoint: (point: Vertex) => void; + /** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */ + commitPoints: (points: Vertex[], next: PlanNetwork) => void; + /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ + respan: (line: string, from: number, to: number) => boolean; } export interface PlanProps { @@ -46,7 +80,7 @@ export interface PlanProps { export function createPlanProps(params: PlanPropsParams): PlanProps { const { edit } = params; const float = createFloatPanel({ - title: "고른 구간", + title: "고름", draggable: true, onClose: () => edit.select([]), }); @@ -61,104 +95,237 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); } - function renderPanel(): void { - const picked = edit.selected(); - float.root.hidden = !picked; - if (!picked) return float.body.replaceChildren(); - const network = params.network(); - const names = lineNames(network.lines); - const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id)); - const lineIds = new Set(pieces.map((piece) => piece.line)); - const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); - const title = float.root.querySelector(".ui-float-panel__title"); - if (title) title.textContent = `구간 ${pieces.length} · 선 ${lineIds.size} · ${meters(total)}`; - const editable = params.editable(); - - const scope = createSegment({ - options: [ - { value: "piece", label: "구간" }, - { value: "line", label: "선" }, - { value: "year", label: "같은 해" }, - ], - value: picked.scope, - size: 24, - ariaLabel: "고름 넓이", - onChange: (value) => edit.setScope(value as PlanScope), - }); - scope.root.dataset.role = "props-scope"; + const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => { + const b = createButton({ label, variant: "ghost", size: 24, onClick }); + b.dataset.role = role; + return b; + }; + /** 연도 고름 — 구간 모두 그 해(되돌리기 한 걸음). */ + function yearField(pieces: PlanPiece[]): HTMLElement { const years = new Set(pieces.map((piece) => piece.year)); const options = yearOptions(); + const ids = pieces.map((piece) => piece.id); const year = createSelectField({ label: "연도", options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""), size: 24, ariaLabel: "고른 구간 연도", - disabled: !editable, + disabled: !params.editable(), onChange: (value) => - value && params.commit(setPiecesYear(params.network(), picked.pieces, Number(value))), + value && params.commit(setPiecesYear(params.network(), ids, Number(value))), }); year.select.dataset.role = "props-year"; + return year.root; + } + /** 구간 줄 — 누름 = 그 구간만. */ + function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement { + const names = lineNames(network.lines); const rows = document.createElement("div"); rows.className = "d03-plan__props-rows"; for (const piece of pieces) { const index = network.lines.findIndex((line) => line.id === piece.line); - const row = createButton({ - label: - `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + + const row = button( + `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + `${meters(piece.to_m - piece.from_m)} · ${piece.year}년`, - variant: "ghost", - size: 24, - onClick: () => edit.select([piece.id]), - }); + "props-piece", + () => edit.select([piece.id]), + ); row.dataset.piece = piece.id; rows.append(row); } + return rows; + } - const actions = document.createElement("div"); - actions.className = "d03-plan__row"; - const index = edit.lineIndex(); - if (editable && index >= 0) { - const flip = createButton({ - label: "방향 바꾸기", - variant: "ghost", - size: 24, - onClick: edit.flipLine, - }); - flip.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다."; - flip.disabled = !!network.lines[index].branch; - flip.dataset.role = "props-flip"; - const drop = createButton({ - label: "선 지움", - variant: "ghost", - size: 24, - onClick: edit.dropLine, - }); - drop.dataset.role = "props-drop"; - const branch = createButton({ - label: "갈래 시점 지정", - variant: "ghost", - size: 24, - onClick: edit.branchLine, - }); - branch.title = "다른 선에 닿은 끝을 시점(No.0)으로 · 그 선의 갈래로 붙입니다."; - branch.hidden = !!network.lines[index].branch; - branch.dataset.role = "props-branch"; - actions.append(flip, branch, drop); + /** 노드 방식 · 값(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, + network: PlanNetwork, + ): { title: string; parts: HTMLElement[]; actions: HTMLButtonElement[] } { + const names = lineNames(network.lines); + const nameOf = (id: string | null): string => + names[network.lines.findIndex((line) => line.id === id)] ?? ""; + const z = params.heightAt(); + const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id)); + const length = selectionLength(edit.paths()); + + if (picked.kind === "node") { + const { line, vertex } = picked.node!; + const vertices = network.lines.find((l) => l.id === line)!.vertices; + const point = vertices[vertex]; + const tm = tmParams(params.epsg()); + const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null; + const height = z(point[0], point[1]); + const rows: KvRow[] = [ + { + label: "경위도", + value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null, + }, + { label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null }, + { label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) }, + ]; + if (vertex > 0) { + const [a, b] = [vertices[vertex - 1], vertices[vertex]]; + rows.push( + { label: "앞 선 길이", value: meters(lineLength([a, b])) }, + { 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, + actions: [ + button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)), + toPoint, + ], + }; } - if (editable && picked.mark) { - const dropMark = createButton({ - label: "마크 지움", - variant: "ghost", - size: 24, - onClick: edit.dropMark, - }); - dropMark.dataset.role = "props-drop-mark"; - actions.append(dropMark); + + if (picked.kind === "segment") { + const single = picked.segments.length === 1 ? picked.segments[0] : null; + const years = [ + ...new Set( + segmentPieces(network, picked.segments).map( + (id) => network.pieces.find((p) => p.id === id)?.year, + ), + ), + ].sort(); + const rows: KvRow[] = [{ label: "길이", value: meters(length) }]; + if (single) { + const [from, to] = segmentRange(network, single)!; + const [a, b] = segmentPath(network, single)!; + rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` }); + rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) }); + } + rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); + 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}` + : `선 ${picked.segments.length}개`, + parts: [createKv(rows)], + actions, + }; } - float.body.replaceChildren(scope.root, year.root, rows, actions); + + if (picked.kind === "line") { + const vertices = network.lines.find((l) => l.id === picked.line)?.vertices ?? []; + return { + title: nameOf(picked.line), + parts: [ + createKv([ + { label: "길이", value: meters(length) }, + { label: "노드", value: `${vertices.length}개` }, + ]), + yearField(pieces), + pieceRows(network, pieces), + ], + actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))], + }; + } + + if (picked.kind === "all") { + const byYear = new Map(); + for (const p of network.pieces) + byYear.set(p.year, (byYear.get(p.year) ?? 0) + p.to_m - p.from_m); + return { + title: "전체", + parts: [ + createKv([ + { label: "길이", value: meters(length) }, + { label: "구역", value: `${network.lines.length}개` }, + ...[...byYear] + .sort(([a], [b]) => a - b) + .map(([year, m]) => ({ label: `${year}년`, value: meters(m) })), + ]), + ], + actions: [button("전체 지움", "props-drop-all", edit.dropAll)], + }; + } + + // 연도 구간 · 마커 + const lineIds = new Set(pieces.map((piece) => piece.line)); + const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); + const parts: HTMLElement[] = [yearField(pieces), pieceRows(network, pieces)]; + const actions: HTMLButtonElement[] = []; + const mark = network.pieces.find((p) => p.id === picked.mark); + if (mark) { + const before = network.pieces.find( + (p) => p.line === mark.line && Math.abs(p.to_m - mark.from_m) < 1e-3, + ); + parts.unshift( + createKv([ + { label: "측점", value: station(mark.from_m) }, + { label: "앞 · 뒤 해", value: `${before?.year ?? "-"}년 · ${mark.year}년` }, + ]), + ); + actions.push(button("마커 지움", "props-drop-mark", edit.dropMark)); + } + return { + title: `구간 ${pieces.length} · 구역 ${lineIds.size} · ${meters(total)}`, + parts, + actions, + }; + } + + function renderPanel(): void { + const picked = edit.selected(); + float.root.hidden = !picked; + if (!picked) return float.body.replaceChildren(); + const { title, parts, actions } = body(picked, params.network()); + const head = float.root.querySelector(".ui-float-panel__title"); + if (head) head.textContent = title; + float.root.dataset.kind = picked.kind; + const row = document.createElement("div"); + row.className = "d03-plan__row"; + if (params.editable()) row.append(...actions); + float.body.replaceChildren(...parts, row); } return { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts new file mode 100644 index 000000000..282f6cc13 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Select.ts + * D03 고름 상태(PLAN 598-1 · 요구 463 9 · 13) — 노드 → 선(토막) → 구역 → 전체 · 연도 구간(왼쪽 해 줄) · 마커. + * 넓힘 = 같은 것을 다시 누름 — 씨(처음 누른 노드 · 토막)에서 한 단 위 · 전체 다음 = 씨로 돌아옴. + * 「같은 해」 = 왼쪽 목록 해 줄로만(`year`). + * 상태 = 화면 기억(저장 안 함) · 꼴이 바뀌면 `sync` 가 사라진 것을 뺌. + * ========================================================================== */ + +import { lineLength, sliceLine } from "./logic/D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + lineSegments, + nodeSegment, + sameSegment, + segmentPath, + segmentPieces, + type PlanNode, + type PlanSegment, +} from "./logic/D03_PlanAnalysis_Select"; + +export type PlanSelectKind = "node" | "segment" | "line" | "all" | "pieces" | "mark"; +/** 왼쪽 목록 밝힘 꼴(구간 · 선 · 같은 해). */ +export type PlanScope = "piece" | "line" | "year"; + +export interface PlanSelection { + kind: PlanSelectKind; + /** 노드 고름 · 노드 씨. */ + node: PlanNode | null; + /** 토막 고름(Shift · 끌어 고르기 = 여럿). */ + segments: PlanSegment[]; + /** 구역 고름 · 씨가 든 구역. */ + line: string | null; + /** 고름에 든 연도 구간 id(구간 · 마커 고름 = 그대로 · 그 밖 = 걸친 구간). */ + pieces: string[]; + /** 고른 마커(그 마커가 여는 구간 id). */ + mark: string | null; + scope: PlanScope; + /** 넓힘 씨 — 처음 누른 노드 · 토막. */ + seed: { node: PlanNode } | { segment: PlanSegment } | null; +} + +const NEXT: Partial> = { + node: "segment", + segment: "line", + line: "all", +}; + +export interface PlanSelect { + get: () => PlanSelection | null; + clear: () => void; + node: (node: PlanNode) => void; + /** 토막 — `toggle` = 지금 토막 고름에 더함 · 뺌. */ + segments: (segments: PlanSegment[], toggle?: boolean) => void; + region: (line: string) => void; + all: () => void; + /** 연도 구간 — 마커 고름 = 그 마커가 여는 구간. */ + pieces: (ids: string[], mark?: string | null, scope?: PlanScope) => void; + /** 넓힘 한 단(씨 기준). */ + grow: () => void; + /** 이 노드 · 토막이 지금 고름 안인가(다시 누름 = 넓힘). */ + holds: (hit: { node?: PlanNode; segment?: PlanSegment }) => boolean; + /** 밝힐 꺾은선. */ + paths: () => Vertex[][]; + sync: () => void; +} + +export function createPlanSelect(network: () => PlanNetwork, onChange: () => void): PlanSelect { + let selected: PlanSelection | null = null; + + function piecesOf(next: Omit): string[] { + const net = network(); + if (next.kind === "segment") return segmentPieces(net, next.segments); + if (next.kind === "line") + return net.pieces.filter((p) => p.line === next.line).map((p) => p.id); + if (next.kind === "all") return net.pieces.map((p) => p.id); + return []; + } + + function choose( + next: Omit & Partial, + quiet = false, + ): void { + selected = { + ...next, + pieces: next.pieces ?? piecesOf(next), + scope: next.scope ?? (next.kind === "line" ? "line" : "piece"), + }; + if (!quiet) onChange(); + } + + const empty = { node: null, segments: [], line: null, mark: null, seed: null }; + + function node(at: PlanNode): void { + choose({ ...empty, kind: "node", node: at, line: at.line, seed: { node: at } }); + } + + function segments(list: PlanSegment[], toggle = false): void { + let next = list; + if (toggle && selected?.kind === "segment") { + const has = (s: PlanSegment): boolean => selected!.segments.some((t) => sameSegment(s, t)); + next = [ + ...selected.segments.filter((s) => !list.some((t) => sameSegment(s, t))), + ...list.filter((s) => !has(s)), + ]; + } + if (!next.length) return clear(); + choose({ + ...empty, + kind: "segment", + segments: next, + line: next[0].line, + seed: { segment: next[0] }, + }); + } + + function clear(): void { + selected = null; + onChange(); + } + + /** 씨 기준 한 단 — 노드 → 토막 → 구역 → 전체 → 씨. */ + function grow(): void { + if (!selected?.seed) return; + const seed = selected.seed; + const next = NEXT[selected.kind]; + const line = "node" in seed ? seed.node.line : seed.segment.line; + if (next === "segment") { + const seg = "node" in seed ? nodeSegment(network(), seed.node) : seed.segment; + if (seg) choose({ ...empty, kind: "segment", segments: [seg], line, seed }); + } else if (next === "line") choose({ ...empty, kind: "line", line, seed }); + else if (next === "all") choose({ ...empty, kind: "all", line, seed }); + else if ("node" in seed) node(seed.node); + else choose({ ...empty, kind: "segment", segments: [seed.segment], line, seed }); + } + + function holds(hit: { node?: PlanNode; segment?: PlanSegment }): boolean { + const s = selected; + if (!s || s.kind === "pieces" || s.kind === "mark") return false; + if (s.kind === "all") return true; + const line = hit.node?.line ?? hit.segment?.line; + if (s.kind === "line") return line === s.line; + if (s.kind === "node") + return !!hit.node && hit.node.line === s.node!.line && hit.node.vertex === s.node!.vertex; + if (hit.segment) return s.segments.some((t) => sameSegment(t, hit.segment!)); + return s.segments.some( + (t) => + t.line === hit.node!.line && + (t.index === hit.node!.vertex || t.index + 1 === hit.node!.vertex), + ); + } + + function paths(): Vertex[][] { + const s = selected; + const net = network(); + if (!s || s.kind === "node") return []; + if (s.kind === "segment") + return s.segments + .map((seg) => segmentPath(net, seg)) + .filter((p): p is [Vertex, Vertex] => !!p); + if (s.kind === "line") return net.lines.filter((l) => l.id === s.line).map((l) => l.vertices); + if (s.kind === "all") return net.lines.map((l) => l.vertices); + return s.pieces.flatMap((id) => { + const piece = net.pieces.find((p) => p.id === id); + const v = net.lines.find((l) => l.id === piece?.line)?.vertices; + return piece && v ? [sliceLine(v, piece.from_m, piece.to_m)] : []; + }); + } + + /** 사라진 것 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onChange 를 부르면 돌고 돎). */ + function sync(): void { + const s = selected; + if (!s) return; + const net = network(); + const vertexCount = (line: string | null): number => + net.lines.find((l) => l.id === line)?.vertices.length ?? 0; + const okNode = (n: PlanNode): boolean => n.vertex < vertexCount(n.line); + const okSeg = (g: PlanSegment): boolean => g.index + 1 < vertexCount(g.line); + if (s.kind === "pieces" || s.kind === "mark") { + const ids = new Set(net.pieces.map((p) => p.id)); + const pieces = s.pieces.filter((id) => ids.has(id)); + selected = pieces.length + ? { ...s, pieces, mark: s.mark && ids.has(s.mark) ? s.mark : null } + : null; + if (selected?.kind === "mark" && !selected.mark) selected = { ...selected, kind: "pieces" }; + return; + } + const seed = + s.seed && ("node" in s.seed ? okNode(s.seed.node) : okSeg(s.seed.segment)) ? s.seed : null; + const segments = s.segments.filter(okSeg); + const alive = { + all: net.lines.length > 0, + line: vertexCount(s.line) > 0, + node: !!s.node && okNode(s.node), + segment: segments.length > 0, + }[s.kind]; + if (alive) choose({ ...s, segments, seed, pieces: undefined }, true); + else selected = null; + } + + return { + get: () => selected, + clear, + node, + segments, + region: (line) => choose({ ...empty, kind: "line", line, seed: lineSeed(line) }), + all: () => choose({ ...empty, kind: "all", seed: null }), + pieces(ids, mark = null, scope = "piece") { + const list = [...new Set(ids)]; + if (!list.length) return clear(); + const net = network(); + choose({ + ...empty, + kind: mark ? "mark" : "pieces", + line: net.pieces.find((p) => p.id === list[0])?.line ?? null, + pieces: list, + mark, + scope, + }); + }, + grow, + holds, + paths, + sync, + }; + + /** 구역 씨 = 첫 토막(구역에서 넓힘 = 전체 → 첫 토막). */ + function lineSeed(line: string): { segment: PlanSegment } | null { + const first = lineSegments(network(), line)[0]; + return first ? { segment: first } : null; + } +} + +/** 고름 길이(m) — 토막 · 구역 · 전체 · 구간. */ +export function selectionLength(paths: Vertex[][]): number { + return paths.reduce((sum, path) => sum + lineLength(path), 0); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts new file mode 100644 index 000000000..4b44d001a --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts @@ -0,0 +1,287 @@ +/* ============================================================================= + * D03_PlanAnalysis_Select.ts + * D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체. + * 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음. + * 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감). + * 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기. + * 노드 판(598-4) 방식 · 값 바꿈 = 앞 노드 기준 같은 방향으로 다시 놓음 · 뒤 노드 그대로(어긋남 ⚠ = 599-2 `nodeOff`). + * DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`). + * ========================================================================== */ + +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, + type Vertex, + type ViewBox, +} from "./D03_PlanAnalysis_Plan"; + +/** 선(토막) — 구역 id · 토막 차례(꼭짓점 `index` → `index + 1`). */ +export interface PlanSegment { + line: string; + index: number; +} + +/** 노드 — 구역 id · 꼭짓점 차례. */ +export interface PlanNode { + line: string; + vertex: number; +} + +const EPS = 1e-6; +const round3 = (value: number): number => Math.round(value * 1000) / 1000; + +/** 꺾임점마다 선 위 거리. */ +export function chainages(vertices: ReadonlyArray): number[] { + const out = [0]; + for (let i = 1; i < vertices.length; i += 1) + out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i])); + return out; +} + +export const sameSegment = (a: PlanSegment, b: PlanSegment): boolean => + a.line === b.line && a.index === b.index; + +/** 구역 토막 모두. */ +export function lineSegments(network: PlanNetwork, line: string): PlanSegment[] { + const entry = network.lines.find((l) => l.id === line); + const count = Math.max(0, (entry?.vertices.length ?? 0) - 1); + return Array.from({ length: count }, (_, index) => ({ line, index })); +} + +/** 노선망 토막 모두. */ +export const allSegments = (network: PlanNetwork): PlanSegment[] => + network.lines.flatMap((line) => lineSegments(network, line.id)); + +/** 토막 꼭짓점 둘(없으면 null). */ +export function segmentPath(network: PlanNetwork, seg: PlanSegment): [Vertex, Vertex] | null { + const v = network.lines.find((l) => l.id === seg.line)?.vertices; + return v && seg.index >= 0 && seg.index + 1 < v.length ? [v[seg.index], v[seg.index + 1]] : null; +} + +/** 토막 선 위 거리 [시작, 끝](m). */ +export function segmentRange(network: PlanNetwork, seg: PlanSegment): [number, number] | null { + const v = network.lines.find((l) => l.id === seg.line)?.vertices; + if (!v || seg.index < 0 || seg.index + 1 >= v.length) return null; + const from = lineLength(v.slice(0, seg.index + 1)); + return [from, from + segmentLength(v[seg.index], v[seg.index + 1])]; +} + +/** 선 위 거리 `atM` 이 든 토막 차례(꺾임점 위 = 뒤 토막 · 끝 = 마지막). */ +export function segmentAt(vertices: ReadonlyArray, atM: number): number { + const c = chainages(vertices); + for (let i = 0; i + 1 < c.length; i += 1) if (atM < c[i + 1] - EPS) return i; + return Math.max(0, c.length - 2); +} + +/** 노드에서 넓힐 토막 — 뒤 토막(끝 노드 = 앞 토막). */ +export function nodeSegment(network: PlanNetwork, node: PlanNode): PlanSegment | null { + const n = network.lines.find((l) => l.id === node.line)?.vertices.length ?? 0; + if (n < 2) return null; + return { line: node.line, index: Math.min(node.vertex, n - 2) }; +} + +/** 토막에 걸친 조각 id(연도 구간). */ +export function segmentPieces(network: PlanNetwork, segs: ReadonlyArray): string[] { + const out = new Set(); + for (const seg of segs) { + const range = segmentRange(network, seg); + if (!range) continue; + for (const piece of network.pieces) + if (piece.line === seg.line && piece.from_m < range[1] - EPS && piece.to_m > range[0] + EPS) + out.add(piece.id); + } + return [...out]; +} + +/** 끌어 고르기 — 토막마다 판정(창 = 두 끝 모두 안 · 걸침 = 상자와 만남 · `piecesInBox` 와 같은 셈). */ +export function segmentsInBox( + network: PlanNetwork, + toView: (point: Vertex) => Vertex, + box: ViewBox, + crossing: boolean, +): PlanSegment[] { + const segs = allSegments(network); + const probe: PlanNetwork = { + lines: segs.map((seg, i) => ({ + id: `s${i}`, + vertices: segmentPath(network, seg)!, + branch: null, + })), + pieces: segs.map((seg, i) => { + const [a, b] = segmentPath(network, seg)!; + return { id: String(i), line: `s${i}`, from_m: 0, to_m: segmentLength(a, b), year: 0 }; + }), + }; + return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]); +} + +/** 새 구역 id — `line-`(있는 것과 안 겹침). */ +function nextLineId(lines: ReadonlyArray): string { + const used = new Set(lines.map((line) => line.id)); + let n = lines.length; + while (used.has(`line-${n}`)) n += 1; + return `line-${n}`; +} + +/** 새 조각 id — `p`(있는 것 중 가장 큰 수 + 1). */ +function nextPieceId(pieces: ReadonlyArray): string { + const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0)); + return `p${Math.max(0, ...used) + 1}`; +} + +/** 꼭짓점 [a, b) 만 남긴 선 — 노드 기록(594-1)도 같이 자름 · 뒤쪽 첫 기록 = 없음(앞 노드가 사라짐). */ +function cutVertices(entry: PlanLine, a: number, b: number): PlanLine { + const vertices = entry.vertices.slice(a, b); + if (!entry.nodes) return { ...entry, vertices }; + const nodes = entry.nodes.slice(a, b); + if (a > 0 && nodes.length) nodes[0] = null; + return { ...entry, vertices, nodes }; +} + +/** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) · + * 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 id 는 앞쪽이 지님(걸친 조각은 뒤쪽 새 id). */ +export function removeSegment(network: PlanNetwork, seg: PlanSegment): PlanNetwork | string { + const li = network.lines.findIndex((l) => l.id === seg.line); + const entry = network.lines[li]; + const n = (entry?.vertices.length ?? 0) - 1; + if (!entry || seg.index < 0 || seg.index >= n) return "고른 선이 없습니다."; + if (n === 1) return removeLine(network, li); + const i = seg.index; + const keepFront = i > 0; + const keepBack = i < n - 1; + const children = network.lines.filter((l) => l.branch?.line === entry.id); + if ( + children.some( + (l) => (!keepFront && l.branch!.vertex === 0) || (!keepBack && l.branch!.vertex === n), + ) + ) + return "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요."; + const backId = keepFront ? nextLineId(network.lines) : entry.id; + const c = chainages(entry.vertices); + const [cut0, cut1] = [c[i], c[i + 1]]; + + const front: PlanPiece[] = []; + const back: PlanPiece[] = []; + for (const piece of network.pieces.filter((p) => p.line === entry.id)) { + if (keepFront && piece.from_m < cut0 - EPS) + front.push({ ...piece, to_m: round3(Math.min(piece.to_m, cut0)) }); + if (keepBack && piece.to_m > cut1 + EPS) + back.push({ + ...piece, + line: backId, + from_m: round3(Math.max(0, piece.from_m - cut1)), + to_m: round3(piece.to_m - cut1), + }); + } + const others = network.pieces.filter((p) => p.line !== entry.id); + for (const piece of back) + if (front.some((p) => p.id === piece.id)) + piece.id = nextPieceId([...others, ...front, ...back]); + + const lines: PlanLine[] = []; + for (const line of network.lines) { + if (line === entry) { + if (keepFront) lines.push(cutVertices(entry, 0, i + 1)); + if (keepBack) lines.push({ ...cutVertices(entry, i + 1, n + 1), id: backId, branch: null }); + continue; + } + const b = line.branch; + if (b?.line === entry.id && b.vertex > i) + lines.push({ ...line, branch: { line: backId, vertex: b.vertex - (i + 1) } }); + else lines.push(line); + } + return { ...network, lines: orderLines(lines), pieces: [...others, ...front, ...back] }; +} + +/** 토막 여럿 지움 — 구역마다 뒤 토막부터(앞 토막 차례가 안 바뀜) · 하나라도 못 지우면 까닭 글. */ +export function removeSegments( + network: PlanNetwork, + segs: ReadonlyArray, +): PlanNetwork | string { + let next = network; + const order = [...segs].sort((a, b) => + a.line === b.line ? b.index - a.index : a.line < b.line ? -1 : 1, + ); + for (const seg of order) { + const out = removeSegment(next, seg); + if (typeof out === "string") return out; + next = out; + } + return next; +} + +/** 전체 지움 — 선 · 조각 모두(지점은 부르는 쪽이 남김). */ +export const removeAll = (network: PlanNetwork): PlanNetwork => ({ + ...network, + lines: [], + pieces: [], +}); + +/** `point` 자리 노드 모두(갈래 첫 점 = 붙은 선 점이 대신 · 빼고). */ +export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] { + const out: PlanNode[] = []; + for (const line of network.lines) + line.vertices.forEach((v, vertex) => { + if (!(vertex === 0 && line.branch) && samePoint(v, point)) + out.push({ line: line.id, vertex }); + }); + return out; +} + +/** `from` 자리 지점을 모두 `to` 로(지점 ↔ 노드 따라감). */ +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_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 785667b65..7090fc893 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -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 @@ -94,7 +95,7 @@ def test_d03_parts(): # 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌 assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools 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" not in three diff --git a/resources/tester/test_585_2_plan_select.py b/resources/tester/test_585_2_plan_select.py index fc4e4c4f9..4e9ec9e1c 100644 --- a/resources/tester/test_585_2_plan_select.py +++ b/resources/tester/test_585_2_plan_select.py @@ -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("다른 선에 닿은 끝이 없습니다") diff --git a/resources/tester/test_598_1_plan_select_layers.py b/resources/tester/test_598_1_plan_select_layers.py new file mode 100644 index 000000000..153a37311 --- /dev/null +++ b/resources/tester/test_598_1_plan_select_layers.py @@ -0,0 +1,150 @@ +"""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]), + 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] })) }; + const out = sel.removeSegment(withNodes, seg("main", 1)); + return out.lines.filter((l) => l.id !== "branch-1").map((l) => [l.id, l.nodes]); + })(), +})); +""" + + +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]] + 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]], + ["line-2", [None, rec]], + ] # 노드 기록 같이 · 뒤쪽 첫 = 없음