Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts

498 lines
19 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Draw.ts
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
*
* [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점 · 반대는 [방향 바꾸기]) ·
* 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때
* 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래.
* 미리보기 = 마지막 점 → 마우스 길이(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 { createButton, showToast } from "@ui/ui_template_elements";
import { keyIs } from "../A00_Common/key_registry";
import { createInputField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import {
compassCandidates,
compassPoint,
fixedLengthPoint,
fixedSlopePoint,
groundSlopePct,
segmentLength,
} from "./logic/D03_PlanAnalysis_Draw";
import {
addBranch,
appendVertex,
attachHead,
dropLastVertex,
insertVertex,
newLine,
removeLine,
reverseLine,
samePoint,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import {
fmt,
lineAt,
paintNetwork,
paintPreview,
screenGap,
slopeDeg,
SNAP_PX,
} from "./D03_PlanAnalysis_UI_DrawPaint";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
export const DRAW_DEFAULT_LENGTH_M = 20;
export const DRAW_DEFAULT_SLOPE_PCT = 9;
export type DrawConstraint = "free" | "length" | "slope" | "both";
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 {
/** 기준 지점(D02 지점 목록 · 585-1). */
base: () => Vertex[];
network: () => PlanNetwork;
heightAt: () => (x: number, y: number) => number;
commit: (next: PlanNetwork) => void;
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
onMode: () => void;
}
export interface PlanDraw {
layer: PlanMapLayer;
/** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */
panel: HTMLElement;
mode: () => DrawMode;
setMode: (mode: DrawMode) => void;
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;
}
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;
// ── 좌측 칸 ─────────────────────────────────────────────
const drawButton = createButton({
label: "긋기",
variant: "filled",
size: 28,
onClick: () => setMode(mode === "draw" ? "idle" : "draw"),
});
const modeRow = document.createElement("div");
modeRow.className = "d03-plan__row";
modeRow.append(drawButton);
const kindSegment = createSegment({
options: [
{ value: "free", label: "자유" },
{ value: "length", label: "길이 고정" },
{ value: "slope", label: "기울기 고정" },
{ value: "both", label: "둘 다" },
],
value: kind,
size: 24,
ariaLabel: "긋기 제약",
onChange: (value) => {
kind = value as DrawConstraint;
fieldsState();
},
});
kindSegment.root.dataset.role = "draw-constraint";
const lengthField = createInputField({
label: "길이",
type: "number",
value: String(lengthM),
min: 1,
unit: "m",
size: 24,
onInput: (value) => {
const next = Number(value);
if (next > 0) lengthM = next;
lengthField.setError(next > 0 ? undefined : "0 보다 큰 수");
},
});
lengthField.input.dataset.role = "draw-length";
const slopeField = createInputField({
label: "기울기(± 같음)",
type: "number",
value: String(slopePct),
unit: "%",
size: 24,
onInput: (value) => {
const next = Number(value);
const ok = value.trim() !== "" && Number.isFinite(next);
if (ok) slopePct = next;
slopeField.setError(ok ? undefined : "숫자");
degText();
},
});
slopeField.input.dataset.role = "draw-slope";
const slopeDegText = createText("", "muted");
slopeDegText.dataset.role = "draw-slope-deg";
const degText = (): void => {
slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`;
};
degText();
const fieldRow = document.createElement("div");
fieldRow.className = "d03-plan__row";
fieldRow.append(lengthField.root, slopeField.root, slopeDegText);
const hint = createText("", "muted");
hint.classList.add("d03-plan__hint");
const panel = document.createElement("div");
panel.className = "d03-plan__draw";
panel.append(modeRow, kindSegment.root, fieldRow, hint);
function fieldsState(): void {
lengthField.input.disabled = kind === "free" || kind === "slope";
slopeField.input.disabled = kind === "free" || kind === "length";
}
function panelState(): void {
drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기";
drawButton.disabled = !editable;
drawButton.setAttribute("aria-pressed", String(mode === "draw"));
panel.dataset.mode = mode;
hint.textContent = !editable
? "보기만 — 산림청 계정에서 긋습니다."
: mode === "draw"
? active === null
? "지점 · 선 끝 · 선 위를 눌러 시작합니다."
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
: "[긋기]로 지점 · 선에서 긋습니다 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다.";
}
fieldsState();
// ── 셈 ──────────────────────────────────────────────────
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 {
if (active === null) return null;
const 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 snap = 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);
}
if (network !== params.network()) params.commit(network);
}
active = null;
flip = false;
strokeStart = null;
preview = null;
candidates = [];
}
function setMode(next: DrawMode): void {
if (mode === "draw") stop();
mode = editable ? next : "idle";
panelState();
params.onMode();
}
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 지점 · 선 위 갈래. 참 = 시작함. */
function begin(px: number, py: number, map: ScreenMap): boolean {
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));
strokeStart = network;
// 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) {
if (tip.head) {
const turned = reverseLine(network, tip.line);
if (typeof turned === "string") {
showToast(turned, "info");
return false;
}
params.commit(turned);
}
[active, flip] = [tip.line, tip.head];
return true;
}
// 2) 지점 — 이미 선이 놓인 지점 = 그 점의 갈래 · 아니면 새 본선(No.0 = 이 지점)
const base = nearest(points.map((point) => ({ point, gap: gap(point) })));
if (base) {
const { point } = base;
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;
}
// 3) 선 위 · 꺾임점 = 갈래
const hit = lineAt(network, px, py, map);
if (hit) {
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;
}
strokeStart = null;
showToast("지점 · 선 끝 · 선 위를 눌러 시작합니다.", "info");
return false;
}
function addPoint(): boolean {
if (active === null || !preview) return false;
if (preview.blocked) {
showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
return false;
}
const line = active;
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));
if (ended) {
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
setMode("idle");
}
return true;
}
const layer: PlanMapLayer = {
draw(context, map) {
const network = params.network();
paintNetwork(context, map, network, active, params.heightAt(), () => {
const from = active === null ? null : lastOf(active);
if (from && preview)
paintPreview(
context,
map,
from,
preview,
kind === "both" ? { lengthM, candidates } : null,
);
});
},
press(px, py, map, button) {
if (!editable) return false;
if (mode === "draw" && button === 0) {
if (active === null) {
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,
setEditable(next) {
editable = next;
if (!editable) setMode("idle");
panelState();
},
key(event) {
if (mode === "draw" && keyIs(event, "plan.drawFinish")) {
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" && 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) {
active = null;
flip = false;
mode = "idle";
}
panelState();
},
preview: () => preview,
stroke: () => (active === null ? null : { line: active, flip }),
constraint: () => ({ kind, lengthM, slopePct }),
setConstraint(next, length, slope) {
kind = next;
kindSegment.set(next);
if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)];
if (slope !== undefined) {
[slopePct, slopeField.input.value] = [slope, String(slope)];
degText();
}
fieldsState();
},
};
}