feat(D 예정노선): 고름 층 노드 → 선 → 구역 → 전체 · 판 하나 재활용 · 우클릭 메뉴 · 지점 ↔ 노드 붙음 (PLAN 598-1~3 · 요구 463)

- 새 logic/D03_PlanAnalysis_Select.ts — 토막 판정 · 끌어 고르기 토막 · 토막 지움(가운데 = 구역 둘 · 뒤쪽 새 구역) · 전체 지움 · 지점 ↔ 노드 같은 자리
- 새 UI_Select.ts — 고름 상태(다시 누름 = 한 단 넓힘 · 같은 해 = 왼쪽 해 줄로만)
- UI_Edit.ts — 우클릭 즉시 지움 걷음 · 노드 끌면 지점 같이 · 지점 12 px 안 붙음 · 지움 창구(dropNode · dropRegion · dropAll · splitAt)
- UI_Props.ts — 판 = 노드 · 선 · 구역 · 전체 · 구간 · 마커 몸 · [방향 바꾸기] · [갈래 시점 지정] 걷음
- 새 UI_Menu.ts — 우클릭 메뉴(Points 에서 옮김 · 노드 지움 · 지점으로 · 연도 구역 나눔)
- UI_Points.ts — 핀 놓을 때 노드 12 px 안 붙음(nodeNear)
- key_registry plan 줄 · 시험 585_2 갈래 시점 지정 줄 지움(요구 463-15 걷음) · 553_4 메뉴 자리 · 새 시험 598_1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:47:39 +09:00
co-authored by Claude Opus 5.5
parent ad8506b186
commit ff49f2c8a2
11 changed files with 1252 additions and 427 deletions
+6 -5
View File
@@ -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),
+268 -258
View File
@@ -1,57 +1,60 @@
/* =============================================================================
* 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 {
movePointsAt,
nodeSegment,
removeAll,
removeSegments,
segmentsInBox,
type PlanNode,
type PlanSegment,
} from "./logic/D03_PlanAnalysis_Select";
import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } 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<PlanScope, PlanScope> = { piece: "line", line: "year", year: "piece" };
export interface PlanEditParams {
network: () => PlanNetwork;
/** 지점(긋기에 붙는 점 · 591-2). */
@@ -59,6 +62,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 +73,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<string> =>
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 +223,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,46 +259,56 @@ 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<string> =>
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<string> {
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";
context.strokeStyle = colors.active;
context.globalAlpha = 0.8;
context.lineWidth = 8;
for (const points of sel.paths()) {
context.beginPath();
points.forEach((point, i) => {
const [sx, sy] = map.toScreen(point);
@@ -307,19 +316,9 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
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));
}
context.globalAlpha = 1;
// 꺾임점 손잡이(고른 구간이 든 선)
// 꺾임점 손잡이(고른 것이 든 선)
const lines = pickedLines(network);
context.lineWidth = 2;
context.fillStyle = "#ffffff";
@@ -331,11 +330,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 +382,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 +416,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 +463,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 +472,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),
};
}
@@ -0,0 +1,107 @@
/* =============================================================================
* 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 · 없으면 항목 없음) — 구역 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)]);
if (params.respan)
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,
);
},
};
}
+21 -3
View File
@@ -55,6 +55,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";
@@ -139,6 +140,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network = normalizePieces(next, yearStart);
map.redraw();
},
commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }),
showPoints: (nextPoints, next) => {
[points, network] = [nextPoints, next];
map.redraw();
},
enabled: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
onSelect: () => refresh(),
@@ -149,6 +155,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
editable: () => editable,
yearStart: () => yearStart,
commit,
heightAt: () => z,
epsg: () => epsg,
points: () => points,
addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }),
});
const pieces = createPlanPieces({ network: () => network, edit });
const measure = createPlanMeasure(() => z);
@@ -157,7 +167,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network: () => network,
names: () => pointNames,
epsg: () => epsg,
draw,
commit: (nextPoints, nextNames, next) =>
step({ network: next, points: nextPoints, pointNames: nextNames }),
center: () => viewMid,
@@ -168,6 +177,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
idle: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
});
const menu = createPlanMenu({
network: () => network,
points: () => points,
marks,
edit,
draw,
editable: () => editable,
});
// 지도 보기 가운데 상자 — 그릴 때마다 보이는 화면 가운데 반을 작업 좌표로(바뀌면 잠시 뒤 범위 봄)
const viewWatch: PlanMapLayer = {
draw(context, screen) {
@@ -307,7 +324,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh();
}
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */
const layerList = (): PlanMapLayer[] => [
...backdrop.layers,
draw.layer,
@@ -316,6 +333,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
...(measuring ? [measure.layer] : []),
viewWatch,
marks.pins,
menu.layer,
];
/** 점들이 받은 범위 안인가. */
@@ -553,7 +571,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
};
window.addEventListener("keydown", onKey);
map.setLayers(layerList());
marks.mount(map.root);
menu.mount(map.root);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
+32 -56
View File
@@ -2,21 +2,23 @@
* D03_PlanAnalysis_UI_Points.ts
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 ·
* 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) ·
* 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림).
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위).
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
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 { createText } from "@ui/ui_template_text";
import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
moveNodesAt,
samePoint,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
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";
@@ -31,7 +33,6 @@ export interface PlanPointsParams {
names: () => string[];
network: () => PlanNetwork;
epsg: () => number;
draw: PlanDraw;
/** 한 걸음 — 지점 · 이름 · 노선 함께. */
commit: (points: Vertex[], names: string[], network: PlanNetwork) => void;
/** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */
@@ -49,8 +50,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 {
@@ -60,8 +65,6 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
hint.classList.add("d03-plan__hint");
const panel = document.createElement("div");
panel.append(list, hint);
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
let drag: {
index: number;
points: Vertex[];
@@ -95,47 +98,26 @@ 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);
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
const input: PlanMapLayer = {
draw: () => undefined,
@@ -148,8 +130,8 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
},
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);
@@ -199,16 +181,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,
};
}
+204 -85
View File
@@ -1,30 +1,43 @@
/* =============================================================================
* 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 { 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 { segmentPath, segmentPieces, segmentRange } from "./logic/D03_PlanAnalysis_Select";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
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 +45,13 @@ export interface PlanPropsParams {
editable: () => boolean;
yearStart: () => number;
commit: (next: PlanNetwork) => void;
heightAt: () => HeightAt;
epsg: () => number;
/** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */
points: () => Vertex[];
addPoint: (point: Vertex) => void;
/** [다시 긋기](599 · 없으면 단추 없음) — 구역 id · 앞 노드 · 뒤 노드. */
respan?: (line: string, from: number, to: number) => boolean;
}
export interface PlanProps {
@@ -46,7 +66,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 +81,203 @@ 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);
/** 고름 꼴마다 — 머리 글 · 몸 · 단추. */
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 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)],
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 && params.respan)
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<number, number>();
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 {
@@ -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<Record<PlanSelectKind, PlanSelectKind>> = {
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<PlanSelection, "pieces" | "scope">): 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<PlanSelection, "pieces" | "scope"> & Partial<PlanSelection>,
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);
}
@@ -0,0 +1,241 @@
/* =============================================================================
* D03_PlanAnalysis_Select.ts
* D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체.
* 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음.
* 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감).
* 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기.
* DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`).
* ========================================================================== */
import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw";
import {
piecesInBox,
removeLine,
samePoint,
orderLines,
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<Vertex>): 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<Vertex>, 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<PlanSegment>): string[] {
const out = new Set<string>();
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-<n>`(있는 것과 안 겹침). */
function nextLineId(lines: ReadonlyArray<PlanLine>): 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<n>`(있는 것 중 가장 큰 수 + 1). */
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): 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 out: PlanLine & { nodes?: unknown[] } = { ...entry, vertices: entry.vertices.slice(a, b) };
const nodes = (entry as { nodes?: unknown }).nodes;
if (Array.isArray(nodes) && nodes.length === entry.vertices.length) {
out.nodes = nodes.slice(a, b);
if (a > 0 && out.nodes.length) out.nodes[0] = null;
}
return out;
}
/** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) ·
* 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 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<PlanSegment>,
): 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<Vertex>, from: Vertex, to: Vertex): Vertex[] {
return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p));
}
+4 -3
View File
@@ -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
@@ -78,7 +79,7 @@ def test_d03_parts():
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
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" in three
assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음
+4 -17
View File
@@ -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("다른 선에 닿은 끝이 없습니다")
@@ -0,0 +1,129 @@
"""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]),
}));
"""
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]]