- 점 찍을 때 그때 방식 · 값을 nodes 칸에(594-1 꼴 · 붙은 점 = 자유 · 처음 기록 = 앞 노드 기록 없음) - 끼움 · 더함 · 뺌 · 지움 · 뒤집기(토막 끝 노드 몫 한 칸 밀림) · 잇기 · 다시 긋기(뒤 노드 = 자유)가 기록 길이를 꼭짓점과 맞춤 — [저장] 400 막음 - nodeRecord · nodeOff(길이 1 cm · 기울기 0.1 % 넘게 어긋남) — Span.ts(Plan 700 줄 넘지 않게) · 598 노드 판이 씀 - 시험 599_3 에 기록 따라감 · 어긋남 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
561 lines
22 KiB
TypeScript
561 lines
22 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Draw.ts
|
|
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
|
|
*
|
|
* 우클릭 메뉴(591-2 · `D03_PlanAnalysis_UI_Points`) = 이 자리에서 시작 — 빈 땅도 새 본선(지점 없이 긋기).
|
|
* [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점) ·
|
|
* 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때
|
|
* 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
|
|
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래 · 본선 꼬리가 다른 구역에
|
|
* 닿아 끝나면 저절로 그 점의 갈래(No.0 = 닿은 점 · 599 · 요구서 463 3장 10).
|
|
* 다시 긋기(599-3 · `respan`) = 고른 구간 앞 노드부터 긋고 원래 뒤 노드에 붙으면 끝(속 구간 흐리게 ·
|
|
* Enter 로 끝내면 취소) — 끝나면 `replaceSpan` 한 번 = 되돌리기 한 걸음.
|
|
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
|
|
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중
|
|
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
|
|
* 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 ·
|
|
* 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
|
|
* 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
|
|
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
|
|
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
|
|
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { keyIs } from "../A00_Common/key_registry";
|
|
import {
|
|
compassCandidates,
|
|
compassPoint,
|
|
fixedLengthPoint,
|
|
fixedSlopePoint,
|
|
groundSlopePct,
|
|
segmentLength,
|
|
} from "./logic/D03_PlanAnalysis_Draw";
|
|
import {
|
|
addBranch,
|
|
appendVertex,
|
|
attachHead,
|
|
dropLastVertex,
|
|
insertVertex,
|
|
newLine,
|
|
removeLine,
|
|
reverseLine,
|
|
samePoint,
|
|
type NodeRecord,
|
|
type PlanNetwork,
|
|
type Vertex,
|
|
} from "./logic/D03_PlanAnalysis_Plan";
|
|
import { nodeRecord, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
|
|
import {
|
|
fmt,
|
|
lineAt,
|
|
paintNetwork,
|
|
paintPreview,
|
|
screenGap,
|
|
SNAP_PX,
|
|
} from "./D03_PlanAnalysis_UI_DrawPaint";
|
|
import {
|
|
createDrawKindFields,
|
|
createDrawPanel,
|
|
DRAW_DEFAULT_LENGTH_M,
|
|
DRAW_DEFAULT_SLOPE_PCT,
|
|
type DrawConstraint,
|
|
} from "./D03_PlanAnalysis_UI_DrawPanel";
|
|
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
|
|
|
const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」.";
|
|
|
|
export type DrawMode = "idle" | "draw";
|
|
|
|
export interface DrawPreview {
|
|
point: Vertex;
|
|
lengthM: number;
|
|
slopePct: number | null;
|
|
/** 붙음 · 자리 없음 안내(있으면 점을 못 찍음 = `blocked`). */
|
|
note: string | null;
|
|
blocked: boolean;
|
|
snapped: boolean;
|
|
/** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */
|
|
onLine: { line: number; segment: number } | null;
|
|
}
|
|
|
|
export interface PlanDrawParams {
|
|
/** 지점(긋기에 붙는 점 · 591-2). */
|
|
base: () => Vertex[];
|
|
network: () => PlanNetwork;
|
|
heightAt: () => (x: number, y: number) => number;
|
|
commit: (next: PlanNetwork) => void;
|
|
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
|
|
onMode: () => void;
|
|
}
|
|
|
|
export interface PlanDraw {
|
|
layer: PlanMapLayer;
|
|
/** 좌측 「그리기」 칸 몸(`D03_PlanAnalysis_UI_DrawPanel` — 긋기 · 연도 구역 마커 · 방식 칸). */
|
|
panel: HTMLElement;
|
|
mode: () => DrawMode;
|
|
setMode: (mode: DrawMode) => void;
|
|
/** 우클릭 메뉴(591-2) — 이 자리에서 긋기를 시작하면 무엇이 되나 · 시작(빈 땅도 새 본선). */
|
|
aim: (px: number, py: number, map: ScreenMap) => "tip" | "point" | "line" | "empty";
|
|
startAt: (px: number, py: number, map: ScreenMap) => boolean;
|
|
setEditable: (editable: boolean) => void;
|
|
/** 키 — 받았으면 참(Enter · Esc · Backspace). */
|
|
key: (event: KeyboardEvent) => boolean;
|
|
/** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */
|
|
sync: () => void;
|
|
/** 검증 창구 — 지금 미리보기 · 제약 · 그리는 선(차례 · 끝에서 긋는 중). */
|
|
preview: () => DrawPreview | null;
|
|
stroke: () => { line: number; flip: boolean } | null;
|
|
constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number };
|
|
setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void;
|
|
/** 다시 긋기 시작(599-3 · 598 판 [다시 긋기]) — `lineId` 선 `fromVertex`~`toVertex` · 참 = 시작. */
|
|
respan: (lineId: string, fromVertex: number, toVertex: number) => boolean;
|
|
}
|
|
|
|
export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
|
let mode: DrawMode = "idle";
|
|
let editable = false;
|
|
let active: number | null = null; // 그리는 선 차례(첫 누름 전 = null)
|
|
let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음
|
|
let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴
|
|
let preview: DrawPreview | null = null;
|
|
let candidates: Vertex[] = [];
|
|
let kind: DrawConstraint = "free";
|
|
let lengthM = DRAW_DEFAULT_LENGTH_M;
|
|
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
|
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록
|
|
let span: {
|
|
line: string;
|
|
from: number;
|
|
to: number;
|
|
through: Vertex[];
|
|
records: NodeRecord[];
|
|
} | null = null;
|
|
const spanLine = (): number =>
|
|
span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1;
|
|
|
|
// ── 좌측 칸 ─────────────────────────────────────────────
|
|
const kindFields = createDrawKindFields({
|
|
value: { kind, lengthM, slopePct },
|
|
onChange: (value) => ({ kind, lengthM, slopePct } = value),
|
|
});
|
|
const drawPanel = createDrawPanel({
|
|
kind: kindFields,
|
|
onDraw: () => setMode(mode === "draw" ? "idle" : "draw"),
|
|
});
|
|
const panel = drawPanel.root;
|
|
|
|
function panelState(): void {
|
|
drawPanel.state({
|
|
drawing: mode === "draw",
|
|
editable,
|
|
canMark: params.network().lines.some((line) => line.vertices.length > 1),
|
|
});
|
|
}
|
|
|
|
// ── 셈 ──────────────────────────────────────────────────
|
|
const lastOf = (line: number): Vertex | null => {
|
|
const vertices = params.network().lines[line]?.vertices ?? [];
|
|
return vertices[vertices.length - 1] ?? null;
|
|
};
|
|
|
|
/** 붙을 자리 — 그리는 선 첫 점 아닌 지점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */
|
|
function snapAt(
|
|
px: number,
|
|
py: number,
|
|
map: ScreenMap,
|
|
): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null {
|
|
const network = params.network();
|
|
const head = active === null ? undefined : network.lines[active]?.vertices[0];
|
|
const offers = params
|
|
.base()
|
|
.filter((point) => !head || !samePoint(point, head))
|
|
.map((point) => ({ point, note: "지점에 붙음" }));
|
|
network.lines.forEach((line, i) => {
|
|
if (i !== active)
|
|
for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" });
|
|
});
|
|
const best = offers
|
|
.map((offer) => ({ ...offer, gap: screenGap(map, offer.point, px, py) }))
|
|
.filter((offer) => offer.gap <= SNAP_PX)
|
|
.sort((a, b) => a.gap - b.gap)[0];
|
|
if (best) return { point: best.point, note: best.note, onLine: null };
|
|
const hit = lineAt(network, px, py, map, active);
|
|
return hit
|
|
? { point: hit.point, note: "선 위에 붙음", onLine: { line: hit.line, segment: hit.segment } }
|
|
: null;
|
|
}
|
|
|
|
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */
|
|
function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null {
|
|
const spanVertices = span ? params.network().lines[spanLine()]?.vertices : undefined;
|
|
if (active === null && !spanVertices) return null;
|
|
const from = spanVertices
|
|
? (span!.through[span!.through.length - 1] ?? spanVertices[span!.from])
|
|
: lastOf(active!);
|
|
if (!from) return null;
|
|
const z = params.heightAt();
|
|
const cursor = map.toMetric(px, py);
|
|
candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : [];
|
|
// 다시 긋기 = 원래 뒤 노드에만 붙음
|
|
const end = spanVertices?.[span!.to];
|
|
const snap = end
|
|
? screenGap(map, end, px, py) <= SNAP_PX
|
|
? { point: end, note: "뒤 노드에 붙음", onLine: null }
|
|
: null
|
|
: snapAt(px, py, map);
|
|
let point: Vertex | null = cursor;
|
|
let note: string | null = null;
|
|
if (snap) {
|
|
[point, note] = [snap.point, snap.note];
|
|
} else if (kind === "length") {
|
|
point = fixedLengthPoint(from, cursor, lengthM);
|
|
} else if (kind === "slope") {
|
|
point = fixedSlopePoint(from, cursor, slopePct, z);
|
|
if (!point) note = `이 방향엔 ±${fmt(Math.abs(slopePct))} % 자리 없음`;
|
|
} else if (kind === "both") {
|
|
point = compassPoint(from, cursor, lengthM, slopePct, z);
|
|
if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ±${fmt(Math.abs(slopePct))} % 자리 없음`;
|
|
}
|
|
const at = point ?? cursor;
|
|
const length = segmentLength(from, at);
|
|
return {
|
|
point: at,
|
|
lengthM: length,
|
|
slopePct: groundSlopePct(from, at, z),
|
|
note,
|
|
blocked: !point || length <= 0,
|
|
snapped: !!snap,
|
|
onLine: snap?.onLine ?? null,
|
|
};
|
|
}
|
|
|
|
// ── 모드 ────────────────────────────────────────────────
|
|
/** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */
|
|
function stop(): void {
|
|
let network = params.network();
|
|
const line = active;
|
|
const entry = line === null ? null : network.lines[line];
|
|
if (line !== null && entry) {
|
|
if (entry.vertices.length < 2) {
|
|
const dropped = removeLine(network, line);
|
|
if (typeof dropped !== "string") network = dropped;
|
|
} else if (flip) {
|
|
const turned = reverseLine(network, line);
|
|
if (typeof turned !== "string") network = attachHead(turned, line);
|
|
} else {
|
|
const tail = entry.vertices[entry.vertices.length - 1];
|
|
const next = network.lines.findIndex(
|
|
(other, i) =>
|
|
i !== line &&
|
|
!other.branch &&
|
|
other.vertices.length > 1 &&
|
|
samePoint(other.vertices[0], tail),
|
|
);
|
|
if (next >= 0) network = attachHead(network, next);
|
|
else if (!entry.branch) {
|
|
// 꼬리가 다른 구역 꺾임점에 닿음 = 그 점의 갈래(No.0 = 닿은 점)
|
|
const turned = reverseLine(network, line);
|
|
const joined = typeof turned === "string" ? turned : attachHead(turned, line);
|
|
if (typeof joined !== "string" && joined !== turned) network = joined;
|
|
}
|
|
}
|
|
if (network !== params.network()) params.commit(network);
|
|
}
|
|
active = null;
|
|
flip = false;
|
|
strokeStart = null;
|
|
preview = null;
|
|
candidates = [];
|
|
span = null;
|
|
}
|
|
|
|
function respan(lineId: string, from: number, to: number): boolean {
|
|
if (!editable) return false;
|
|
const index = params.network().lines.findIndex((line) => line.id === lineId);
|
|
const blocked = respanBlocked(params.network(), index, from, to);
|
|
if (blocked) {
|
|
showToast(blocked, "info");
|
|
return false;
|
|
}
|
|
setMode("draw");
|
|
span = { line: lineId, from, to, through: [], records: [] };
|
|
panelState();
|
|
params.onMode();
|
|
return true;
|
|
}
|
|
|
|
function setMode(next: DrawMode): void {
|
|
if (mode === "draw") stop();
|
|
mode = editable ? next : "idle";
|
|
panelState();
|
|
params.onMode();
|
|
}
|
|
|
|
/** 누른 자리에서 긋기를 시작하면 — 선 끝(이어 긋기) · 지점 · 선 위(갈래) · 빈 땅. */
|
|
function aim(
|
|
px: number,
|
|
py: number,
|
|
map: ScreenMap,
|
|
):
|
|
| { kind: "tip"; line: number; head: boolean }
|
|
| { kind: "point"; point: Vertex }
|
|
| { kind: "line"; hit: NonNullable<ReturnType<typeof lineAt>> }
|
|
| { kind: "empty"; point: Vertex } {
|
|
const network = params.network();
|
|
const points = params.base();
|
|
const gap = (point: Vertex): number => screenGap(map, point, px, py);
|
|
const nearest = <T extends { gap: number }>(items: T[]): T | undefined =>
|
|
items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0];
|
|
const onBase = (list: Vertex[], point: Vertex): boolean =>
|
|
list.some((p) => samePoint(p, point));
|
|
// 1) 선 끝 — 꼬리 · 갈래 없는 선 머리(지점에 놓인 끝 빼고 — 지점 누름 = 새 선 · 갈래)
|
|
const tips: Array<{ line: number; head: boolean; gap: number }> = [];
|
|
network.lines.forEach((line, i) => {
|
|
const tail = line.vertices[line.vertices.length - 1];
|
|
const head = line.vertices[0];
|
|
if (tail && line.vertices.length > 1 && !onBase(points, tail))
|
|
tips.push({ line: i, head: false, gap: gap(tail) });
|
|
if (line.vertices.length > 1 && !line.branch && !onBase(points, head))
|
|
tips.push({ line: i, head: true, gap: gap(head) });
|
|
});
|
|
const tip = nearest(tips);
|
|
if (tip) return { kind: "tip", line: tip.line, head: tip.head };
|
|
// 2) 지점 3) 선 위 · 꺾임점
|
|
const base = nearest(points.map((point) => ({ point, gap: gap(point) })));
|
|
if (base) return { kind: "point", point: base.point };
|
|
const hit = lineAt(network, px, py, map);
|
|
return hit ? { kind: "line", hit } : { kind: "empty", point: map.toMetric(px, py) };
|
|
}
|
|
|
|
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 지점 · 선 위 갈래 · `free` = 빈 땅도 새 본선(우클릭 메뉴 591-2).
|
|
* 참 = 시작함. */
|
|
function begin(px: number, py: number, map: ScreenMap, free = false): boolean {
|
|
const network = params.network();
|
|
const target = aim(px, py, map);
|
|
strokeStart = network;
|
|
if (target.kind === "tip") {
|
|
if (target.head) {
|
|
const turned = reverseLine(network, target.line);
|
|
if (typeof turned === "string") {
|
|
showToast(turned, "info");
|
|
return false;
|
|
}
|
|
params.commit(turned);
|
|
}
|
|
[active, flip] = [target.line, target.head];
|
|
return true;
|
|
}
|
|
// 지점 — 이미 선이 놓인 지점 = 그 점의 갈래 · 아니면 새 본선(No.0 = 이 지점)
|
|
if (target.kind === "point") {
|
|
const { point } = target;
|
|
const owner = network.lines.findIndex(
|
|
(line) => line.vertices.length > 1 && line.vertices.some((v) => samePoint(v, point)),
|
|
);
|
|
if (owner >= 0) {
|
|
const vertex = network.lines[owner].vertices.findIndex((v) => samePoint(v, point));
|
|
params.commit(addBranch(network, owner, vertex));
|
|
} else params.commit(newLine(network, point));
|
|
[active, flip] = [params.network().lines.length - 1, false];
|
|
return true;
|
|
}
|
|
// 선 위 · 꺾임점 = 갈래
|
|
if (target.kind === "line") {
|
|
const { hit } = target;
|
|
let next = network;
|
|
let vertex = hit.vertex;
|
|
if (vertex === null) {
|
|
next = insertVertex(next, hit.line, hit.segment, hit.point);
|
|
vertex = hit.segment + 1;
|
|
}
|
|
params.commit(addBranch(next, hit.line, vertex));
|
|
[active, flip] = [params.network().lines.length - 1, false];
|
|
return true;
|
|
}
|
|
if (free) {
|
|
params.commit(newLine(network, target.point));
|
|
[active, flip] = [params.network().lines.length - 1, false];
|
|
return true;
|
|
}
|
|
strokeStart = null;
|
|
showToast(START_HINT, "info");
|
|
return false;
|
|
}
|
|
|
|
function addPoint(): boolean {
|
|
if ((active === null && !span) || !preview) return false;
|
|
if (preview.blocked) {
|
|
showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
|
|
return false;
|
|
}
|
|
// 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2)
|
|
const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct);
|
|
if (span) {
|
|
if (!preview.snapped) {
|
|
span.through.push(preview.point);
|
|
span.records.push(record);
|
|
return true;
|
|
}
|
|
const next = replaceSpan(
|
|
params.network(),
|
|
spanLine(),
|
|
span.from,
|
|
span.to,
|
|
span.through,
|
|
span.records,
|
|
);
|
|
if (typeof next === "string") showToast(next, "info");
|
|
else params.commit(next);
|
|
span = null;
|
|
setMode("idle");
|
|
return true;
|
|
}
|
|
const line = active;
|
|
if (line === null) return false;
|
|
const ended = preview.snapped;
|
|
let next = params.network();
|
|
if (preview.onLine)
|
|
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
|
|
params.commit(appendVertex(next, line, preview.point, record));
|
|
if (ended) {
|
|
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
|
|
setMode("idle");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const layer: PlanMapLayer = {
|
|
draw(context, map) {
|
|
let network = params.network();
|
|
let stroke = active;
|
|
const index = spanLine();
|
|
const dim = span && index >= 0 ? { line: index, from: span.from, to: span.to } : null;
|
|
if (span && index >= 0) {
|
|
// 다시 긋는 선 = 앞 노드 + 찍은 점(그리는 선 꼴)
|
|
const head = network.lines[index].vertices[span.from];
|
|
const temp = { id: "", vertices: [head, ...span.through], branch: null };
|
|
network = { ...network, lines: [...network.lines, temp] };
|
|
stroke = network.lines.length - 1;
|
|
}
|
|
paintNetwork(
|
|
context,
|
|
map,
|
|
network,
|
|
stroke,
|
|
params.heightAt(),
|
|
() => {
|
|
const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1);
|
|
if (from && preview)
|
|
paintPreview(
|
|
context,
|
|
map,
|
|
from,
|
|
preview,
|
|
kind === "both" ? { lengthM, candidates } : null,
|
|
);
|
|
},
|
|
dim,
|
|
);
|
|
},
|
|
press(px, py, map, button) {
|
|
if (!editable) return false;
|
|
if (mode === "draw" && button === 0) {
|
|
if (active === null && !span) {
|
|
const started = begin(px, py, map);
|
|
panelState();
|
|
params.onMode();
|
|
return started;
|
|
}
|
|
preview = previewAt(px, py, map);
|
|
return addPoint();
|
|
}
|
|
return false;
|
|
},
|
|
hover(px, py, map) {
|
|
preview = mode === "draw" ? previewAt(px, py, map) : null;
|
|
},
|
|
leave() {
|
|
preview = null;
|
|
},
|
|
};
|
|
|
|
panelState();
|
|
|
|
return {
|
|
layer,
|
|
panel,
|
|
mode: () => mode,
|
|
setMode,
|
|
aim: (px, py, map) => aim(px, py, map).kind,
|
|
startAt(px, py, map) {
|
|
if (!editable) return false;
|
|
setMode("draw");
|
|
const started = begin(px, py, map, true);
|
|
panelState();
|
|
params.onMode();
|
|
return started;
|
|
},
|
|
setEditable(next) {
|
|
editable = next;
|
|
if (!editable) setMode("idle");
|
|
panelState();
|
|
},
|
|
key(event) {
|
|
if (mode === "draw" && keyIs(event, "plan.drawFinish")) {
|
|
if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info");
|
|
setMode("idle");
|
|
return true;
|
|
}
|
|
if (mode === "draw" && event.key === "Escape") {
|
|
if (mode === "draw" && strokeStart && strokeStart !== params.network())
|
|
params.commit(strokeStart);
|
|
active = null; // 되돌린 꼴이라 stop 의 버림 · 뒤집기는 안 함
|
|
setMode("idle");
|
|
return true;
|
|
}
|
|
if (mode === "draw" && span && keyIs(event, "plan.drawBack")) {
|
|
span.records.pop();
|
|
if (!span.through.pop()) setMode("idle");
|
|
return true;
|
|
}
|
|
if (mode === "draw" && active !== null && keyIs(event, "plan.drawBack")) {
|
|
const network = params.network();
|
|
const step =
|
|
network.lines[active].vertices.length < 2
|
|
? removeLine(network, active)
|
|
: dropLastVertex(network, active);
|
|
if (typeof step === "string") showToast(step, "info");
|
|
else if (step !== network) params.commit(step);
|
|
if (active >= params.network().lines.length) {
|
|
active = null;
|
|
setMode("idle");
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
sync() {
|
|
if (
|
|
(active !== null && active >= params.network().lines.length) ||
|
|
(span && spanLine() < 0)
|
|
) {
|
|
active = null;
|
|
flip = false;
|
|
span = null;
|
|
mode = "idle";
|
|
}
|
|
panelState();
|
|
},
|
|
preview: () => preview,
|
|
stroke: () => (active === null ? null : { line: active, flip }),
|
|
constraint: () => ({ kind, lengthM, slopePct }),
|
|
setConstraint(next, length, slope) {
|
|
kind = next;
|
|
if (length !== undefined) lengthM = length;
|
|
if (slope !== undefined) slopePct = slope;
|
|
kindFields.set({ kind, lengthM, slopePct });
|
|
},
|
|
respan,
|
|
};
|
|
}
|