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:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 # 예상 코리도 걷음
|
||||
|
||||
@@ -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]]
|
||||
Reference in New Issue
Block a user