- UI_Points: 우클릭 메뉴 = UI_Menu 그대로 · sub4 597-2 칸 몫([추가] · 펼침 이름 · 경위도 · nameError · 칸 설명 글 걷음) 살림 · 핀 이름 그림(pointLayer names) · dev 쪽 openMenu · mount 걷음(지점 놓기 노드 붙음 = UI_Menu place) - UI_Pieces: 구역 지움 = edit.dropRegion(line.id)(598 이 dropLine 을 dropRegion 으로 바꿈) - UI_Page: 지점 한 걸음 stepPoints — 위치(site) 같이 실음(596-2 Snap) · 700 줄 - UI_Edit: 고름 강조 선 = strokeCased 검은 테(600-1 · 밝은 바탕 대비) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
558 lines
21 KiB
TypeScript
558 lines
21 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Edit.ts
|
|
* D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 598 · 요구 463 9 · 12 · 13·16 · 15) — 쉴 때(긋기 · 재기 아님)만.
|
|
*
|
|
* 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`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, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw";
|
|
import {
|
|
insertVertex,
|
|
moveMark,
|
|
moveNodesAt,
|
|
moveVertex,
|
|
removeLine,
|
|
removeMark,
|
|
removeVertex,
|
|
samePoint,
|
|
splitPiece,
|
|
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,
|
|
strokeCased,
|
|
} from "./D03_PlanAnalysis_UI_DrawPaint";
|
|
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
|
import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select";
|
|
|
|
export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select";
|
|
|
|
/** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */
|
|
const DRAG_PX = 4;
|
|
/** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */
|
|
const MARK_OFF_PX = 3 * SNAP_PX;
|
|
|
|
export interface PlanEditParams {
|
|
network: () => PlanNetwork;
|
|
/** 지점(긋기에 붙는 점 · 591-2). */
|
|
base: () => Vertex[];
|
|
commit: (next: PlanNetwork) => void;
|
|
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
|
|
show: (next: PlanNetwork) => void;
|
|
/** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */
|
|
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
|
|
showPoints: (points: Vertex[], next: PlanNetwork) => void;
|
|
/** 고르기 받는 때(쉴 때 · 재기 아님). */
|
|
enabled: () => boolean;
|
|
/** 고치기 됨(산림청). */
|
|
editable: () => boolean;
|
|
/** 고름이 바뀜 — 페이지가 좌측 줄 밝힘 · 속성 판. */
|
|
onSelect: () => void;
|
|
}
|
|
|
|
/** 누른 자리 — 마크(구간 id) · 노드 · 토막(선 위 · 노드면 넓힐 토막). */
|
|
export interface PlanHit {
|
|
mark: string | null;
|
|
node: PlanNode | null;
|
|
segment: PlanSegment | null;
|
|
}
|
|
|
|
export interface PlanEdit {
|
|
layer: PlanMapLayer;
|
|
selected: () => PlanSelection | null;
|
|
/** 밝힐 꺾은선(고른 토막 · 구역 · 전체 · 구간). */
|
|
paths: () => Vertex[][];
|
|
/** 구간 id 묶음 고름(빈 묶음 = 풂) · 마크 고름. */
|
|
select: (pieces: string[], mark?: string | null) => void;
|
|
/** 구간 고름 넓이 바꿈(구간 · 선 · 같은 해 — 왼쪽 목록). */
|
|
setScope: (scope: PlanScope) => void;
|
|
selectNode: (node: PlanNode) => void;
|
|
selectRegion: (line: string) => void;
|
|
selectYear: (year: number) => void;
|
|
selectAll: () => void;
|
|
/** 고른 것 지움(Delete · 판 [지움]). */
|
|
dropSelected: () => void;
|
|
dropNode: (node: PlanNode) => void;
|
|
dropMark: () => void;
|
|
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;
|
|
/** 끌기 앞 자리 · 같이 가는 지점(같은 자리)이 있나. */
|
|
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;
|
|
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 {
|
|
const sel = createPlanSelect(params.network, params.onSelect);
|
|
let drag: Drag | null = null;
|
|
/** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */
|
|
let markDrop: { point: Vertex; ok: boolean; text: string } | null = null;
|
|
|
|
const lineIndexOf = (network: PlanNetwork, id: string): number =>
|
|
network.lines.findIndex((line) => line.id === id);
|
|
|
|
/** 꼴 바꿈 하나 — 까닭 글이면 알림. */
|
|
function apply(next: PlanNetwork | string): void {
|
|
if (!params.editable()) return;
|
|
if (typeof next === "string") return void showToast(next, "info");
|
|
params.commit(next);
|
|
sel.sync();
|
|
}
|
|
|
|
function dropMark(): void {
|
|
const network = params.network();
|
|
const piece = network.pieces.find((p) => p.id === sel.get()?.mark);
|
|
if (piece) apply(removeMark(network, piece.line, piece.from_m, 1e-3));
|
|
}
|
|
|
|
function dropNode(node: PlanNode): void {
|
|
const network = params.network();
|
|
const index = lineIndexOf(network, node.line);
|
|
if (index >= 0) apply(removeVertex(network, index, node.vertex));
|
|
}
|
|
|
|
function dropRegion(line: string): void {
|
|
const network = params.network();
|
|
const index = lineIndexOf(network, line);
|
|
if (index >= 0) apply(removeLine(network, index));
|
|
}
|
|
|
|
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 {
|
|
let best: number | null = null;
|
|
let bestPx = SNAP_PX;
|
|
network.pieces.forEach((piece, index) => {
|
|
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
|
|
if (!vertices || !(piece.from_m > 0)) return;
|
|
const gap = screenGap(map, pointAtDistance(vertices, piece.from_m), px, py);
|
|
if (gap <= bestPx) [best, bestPx] = [index, gap];
|
|
});
|
|
return best;
|
|
}
|
|
|
|
/** 화면 px 둘레 꺾임점 — 갈래 첫 점은 붙은 선 점이 대신 집힘. */
|
|
function vertexAt(
|
|
network: PlanNetwork,
|
|
px: number,
|
|
py: number,
|
|
map: ScreenMap,
|
|
): { line: number; vertex: number } | null {
|
|
let best: { line: number; vertex: number } | null = null;
|
|
let bestPx = SNAP_PX;
|
|
network.lines.forEach((line, i) =>
|
|
line.vertices.forEach((point, v) => {
|
|
if (v === 0 && line.branch) return;
|
|
const gap = screenGap(map, point, px, py);
|
|
if (gap <= bestPx) [best, bestPx] = [{ line: i, vertex: v }, gap];
|
|
}),
|
|
);
|
|
return best;
|
|
}
|
|
|
|
/** 지점 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). */
|
|
function markPlace(
|
|
px: number,
|
|
py: number,
|
|
map: ScreenMap,
|
|
): { point: Vertex; next: PlanNetwork | null; piece: string | null } {
|
|
const network = params.network();
|
|
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: on.point, next: added ? next : null, piece: added?.id ?? 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;
|
|
}
|
|
params.commit(place.next);
|
|
sel.pieces([place.piece], place.piece);
|
|
return true;
|
|
}
|
|
|
|
/** 노드 끌기 시작 — 지점 자리면 지점도 같이. */
|
|
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 picked = sel.get();
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
for (const points of sel.paths()) {
|
|
context.beginPath();
|
|
points.forEach((point, i) => {
|
|
const [sx, sy] = map.toScreen(point);
|
|
if (i) context.lineTo(sx, sy);
|
|
else context.moveTo(sx, sy);
|
|
});
|
|
strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1)
|
|
}
|
|
// 꺾임점 손잡이(고른 것이 든 선)
|
|
const lines = pickedLines(network);
|
|
context.lineWidth = 2;
|
|
context.fillStyle = "#ffffff";
|
|
for (const line of network.lines) {
|
|
if (!lines.has(line.id)) continue;
|
|
for (const point of line.vertices) {
|
|
const [sx, sy] = map.toScreen(point);
|
|
context.fillRect(sx - 4, sy - 4, 8, 8);
|
|
context.strokeRect(sx - 4, sy - 4, 8, 8);
|
|
}
|
|
}
|
|
// 고른 노드 · 마크 테
|
|
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;
|
|
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;
|
|
context.strokeStyle = colors.active;
|
|
context.stroke();
|
|
}
|
|
if (markDrop) {
|
|
const at = map.toScreen(markDrop.point);
|
|
paintMark(context, at, markDrop.ok ? colors.active : colors.warn);
|
|
planLabel(context, map, at[0], at[1] - 20, markDrop.text, "bold 12px");
|
|
}
|
|
// 끌어 고르기 상자 — 보이는 화면 네모를 그림 px 로 되돌려 그림
|
|
if (drag?.kind === "box") {
|
|
const { box } = drag;
|
|
const crossing = box.x1 < box.x0;
|
|
const color = themeColor(crossing ? "--draw-success" : "--draw-primary", "#26114a");
|
|
context.beginPath();
|
|
(
|
|
[
|
|
[box.x0, box.y0],
|
|
[box.x1, box.y0],
|
|
[box.x1, box.y1],
|
|
[box.x0, box.y1],
|
|
] as const
|
|
).forEach(([x, y], i) => {
|
|
const [sx, sy] = map.unrotate(x, y);
|
|
if (i) context.lineTo(sx, sy);
|
|
else context.moveTo(sx, sy);
|
|
});
|
|
context.closePath();
|
|
context.globalAlpha = 0.12;
|
|
context.fillStyle = color;
|
|
context.fill();
|
|
context.globalAlpha = 1;
|
|
context.setLineDash(crossing ? [6, 4] : []);
|
|
context.lineWidth = 1.5;
|
|
context.strokeStyle = color;
|
|
context.stroke();
|
|
context.setLineDash([]);
|
|
}
|
|
},
|
|
press(px, py, map, button, shift) {
|
|
if (!params.enabled() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu)
|
|
const network = params.network();
|
|
const mark = markAt(network, px, py, map);
|
|
if (mark !== null) {
|
|
const piece = network.pieces[mark];
|
|
sel.pieces([piece.id], piece.id);
|
|
if (params.editable()) drag = { kind: "mark", index: mark, before: network, last: null };
|
|
return true;
|
|
}
|
|
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);
|
|
drag = { kind: "box", box: { x0: vx, y0: vy, x1: vx, y1: vy }, shift };
|
|
return true;
|
|
},
|
|
hover(px, py, map) {
|
|
if (!drag) return;
|
|
if (drag.kind === "box") {
|
|
[drag.box.x1, drag.box.y1] = map.rerotate(px, py);
|
|
return;
|
|
}
|
|
if (drag.kind === "press") {
|
|
if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) 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") {
|
|
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) {
|
|
if (!params.enabled() || !params.editable()) return false;
|
|
const place = markPlace(px, py, map);
|
|
markDrop = {
|
|
point: place.point,
|
|
ok: !!place.next,
|
|
text: place.next
|
|
? "여기서 구역 나눔"
|
|
: lineAt(params.network(), px, py, map)
|
|
? "선 끝 · 다른 마커 1 m 안 — 못 놓음"
|
|
: "선 위에 놓으세요",
|
|
};
|
|
return true;
|
|
},
|
|
drop(px, py, map) {
|
|
markDrop = null;
|
|
if (!params.enabled() || !params.editable()) return false;
|
|
splitAt(px, py, map);
|
|
return true;
|
|
},
|
|
leave() {
|
|
markDrop = null;
|
|
},
|
|
release(map) {
|
|
const done = drag;
|
|
drag = 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) sel.clear();
|
|
return;
|
|
}
|
|
const network = params.network();
|
|
const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point));
|
|
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;
|
|
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: sel.get,
|
|
paths: sel.paths,
|
|
select: (pieces, mark = null) => sel.pieces(pieces, mark),
|
|
setScope,
|
|
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,
|
|
dropRegion,
|
|
dropAll,
|
|
splitAt,
|
|
hit,
|
|
key(event) {
|
|
if (!sel.get() || !params.enabled()) return false;
|
|
if (keyIs(event, "plan.delete") && params.editable()) {
|
|
dropSelected();
|
|
return true;
|
|
}
|
|
if (event.key === "Escape") {
|
|
sel.clear();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
sync: sel.sync,
|
|
box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null),
|
|
};
|
|
}
|