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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user