- refresh → edit.sync → onSelect → refresh 되풀이로 속성 판 그림이 끊김 · sync 는 고름만 맞추고 다시 그림은 부르는 쪽(refresh) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
544 lines
21 KiB
TypeScript
544 lines
21 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Edit.ts
|
|
* D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만.
|
|
*
|
|
* 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위
|
|
* (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂.
|
|
* 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) ·
|
|
* 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함.
|
|
* 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
|
|
* 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 ·
|
|
* Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움.
|
|
* 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 ·
|
|
* 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침).
|
|
* 잠금 = 지점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
|
|
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함).
|
|
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름).
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import {
|
|
distanceAlong,
|
|
lineLength,
|
|
pointAtDistance,
|
|
sliceLine,
|
|
} from "./logic/D03_PlanAnalysis_Draw";
|
|
import {
|
|
insertVertex,
|
|
moveMark,
|
|
moveVertex,
|
|
piecesInBox,
|
|
removeLine,
|
|
removeMark,
|
|
removeVertex,
|
|
reverseLine,
|
|
samePoint,
|
|
splitPiece,
|
|
branchFromEnd,
|
|
type PlanNetwork,
|
|
type Vertex,
|
|
type ViewBox,
|
|
} from "./logic/D03_PlanAnalysis_Plan";
|
|
import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
|
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
|
|
|
/** 끌기로 볼 마우스 움직임(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;
|
|
/** 기준 지점(D02 · 585-1). */
|
|
base: () => Vertex[];
|
|
commit: (next: PlanNetwork) => void;
|
|
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
|
|
show: (next: PlanNetwork) => void;
|
|
/** 고르기 받는 때(쉴 때 · 재기 아님). */
|
|
enabled: () => boolean;
|
|
/** 고치기 됨(산림청). */
|
|
editable: () => boolean;
|
|
/** 고름이 바뀜 — 페이지가 좌측 줄 밝힘 · 속성 판. */
|
|
onSelect: () => void;
|
|
}
|
|
|
|
/** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */
|
|
export interface PlanSelection {
|
|
pieces: string[];
|
|
scope: PlanScope;
|
|
seed: string | null;
|
|
mark: string | null;
|
|
}
|
|
|
|
export interface PlanEdit {
|
|
layer: PlanMapLayer;
|
|
selected: () => PlanSelection | null;
|
|
/** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */
|
|
select: (pieces: string[], mark?: string | null) => void;
|
|
/** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */
|
|
setScope: (scope: PlanScope) => void;
|
|
/** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */
|
|
flipLine: () => void;
|
|
dropLine: () => void;
|
|
dropMark: () => void;
|
|
/** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */
|
|
branchLine: () => void;
|
|
/** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */
|
|
lineIndex: () => number;
|
|
/** 키 — 받았으면 참(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;
|
|
x: number;
|
|
y: number;
|
|
hit: { line: number; segment: number; point: Vertex } | null;
|
|
}
|
|
/** 빈 곳 끌기 — 보이는 px 시작 · 지금. */
|
|
| { kind: "box"; box: ViewBox; shift: boolean };
|
|
|
|
export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
|
let selected: PlanSelection | null = null;
|
|
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));
|
|
|
|
/** 씨 구간 → 넓이만큼 구간 묶음. */
|
|
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();
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/** Delete — 마크 · 선 통째 고름 = 그 선. */
|
|
function dropSelected(): void {
|
|
if (selected?.mark) return dropMark();
|
|
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));
|
|
}
|
|
|
|
/** 사라진 구간 뺌 — 부르는 쪽(페이지 `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;
|
|
}
|
|
|
|
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;
|
|
|
|
/** 화면 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;
|
|
}
|
|
|
|
/** 잠금 까닭 — 지점에 놓인 점. */
|
|
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))
|
|
? "지점 자리 점은 1단계(기준 지정)에서 옮깁니다."
|
|
: null;
|
|
}
|
|
|
|
/** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */
|
|
function markPlace(
|
|
px: number,
|
|
py: number,
|
|
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 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 };
|
|
}
|
|
|
|
/** 구간 누름 — 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 });
|
|
}
|
|
if (selected?.pieces.includes(piece) && !selected.mark) {
|
|
drag = { kind: "grow", piece, x, y, hit };
|
|
return;
|
|
}
|
|
select([piece]);
|
|
}
|
|
|
|
/** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */
|
|
const pickedLines = (network: PlanNetwork): Set<string> =>
|
|
new Set(
|
|
(selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""),
|
|
);
|
|
|
|
const layer: PlanMapLayer = {
|
|
draw(context, map) {
|
|
const network = params.network();
|
|
const colors = planColors();
|
|
const path = (points: Vertex[]): void => {
|
|
context.beginPath();
|
|
points.forEach((point, i) => {
|
|
const [sx, sy] = map.toScreen(point);
|
|
if (i) context.lineTo(sx, sy);
|
|
else context.moveTo(sx, sy);
|
|
});
|
|
context.stroke();
|
|
};
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
context.strokeStyle = colors.active;
|
|
context.globalAlpha = 0.8;
|
|
context.lineWidth = 8;
|
|
for (const id of selected?.pieces ?? []) {
|
|
const piece = network.pieces.find((p) => p.id === id);
|
|
const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices;
|
|
if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m));
|
|
}
|
|
context.globalAlpha = 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 mark = network.pieces.find((p) => p.id === selected?.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));
|
|
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()) return false;
|
|
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) 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 };
|
|
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);
|
|
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 === "grow") {
|
|
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 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);
|
|
}
|
|
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;
|
|
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);
|
|
return true;
|
|
},
|
|
leave() {
|
|
markDrop = null;
|
|
},
|
|
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 });
|
|
}
|
|
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);
|
|
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);
|
|
return;
|
|
}
|
|
if (!done.last) return;
|
|
params.show(done.before);
|
|
params.commit(done.last);
|
|
sync();
|
|
},
|
|
};
|
|
|
|
return {
|
|
layer,
|
|
selected: () => selected,
|
|
select,
|
|
setScope,
|
|
flipLine: () => apply((network, index) => reverseLine(network, index)),
|
|
dropLine: () => apply((network, index) => removeLine(network, index)),
|
|
dropMark,
|
|
branchLine: () => apply((network, index) => branchFromEnd(network, index)),
|
|
lineIndex,
|
|
key(event) {
|
|
if (!selected || !params.enabled()) return false;
|
|
if (event.key === "Delete" && params.editable()) {
|
|
dropSelected();
|
|
return true;
|
|
}
|
|
if (event.key === "Escape") {
|
|
choose(null);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
sync,
|
|
box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null),
|
|
};
|
|
}
|