Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts
T

187 lines
6.4 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Edit.ts
* D03 평면 꺾임점 고침 겹(요구 363-1 · PLAN 543-2) — 고른 갈래 노선의 마디(네모) · 곡선 손잡이(동그라미) ·
* R 글을 그리고, 끌기 · 두 번 누름(넣기) · 오른쪽 누름(지우기)을 순수 셈(`logic/D03_PlanAnalysis_Edit`)
* 으로 넘김. 처음 · 끝 마디(연도 경계)는 지점 끌기 몫이라 안 그림 · 안 집음.
*
* 값은 페이지가 쥠(캐시 `d03-plan` · 되돌리기) — 여기는 고친 값을 `change` 로 올리기만.
* 집기는 B05 편집기 순수 부품 그대로(`nodeAtScreen` · `handleAtScreen` · `segmentAtScreen`).
* ========================================================================== */
import {
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
import type { EditedPolyline } from "../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
import type { PlanMapOverlay, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import {
insertNode,
moveHandle,
moveNode,
removeNode,
type EditStep,
type PlanEdit,
} from "./logic/D03_PlanAnalysis_Edit";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
/** 마디 · 손잡이 집는 반경 · 선 위 두 번 누름 거리(px). */
const NODE_HIT_PX = 10;
const HANDLE_HIT_PX = 8;
const SEGMENT_HIT_PX = 10;
/** 연도 하나 — 지금 고침(없으면 엔진 노선을 편 것) · 그 선 · 법정 최소 R. */
export interface EditTarget {
year: number;
edit: PlanEdit;
line: EditedPolyline;
minRadiusM: number;
}
export interface PlanEditEvents {
/** 고치기 직전 한 번(되돌리기 쌓기). */
begin: () => void;
/** 고친 값 — `final` = 끌기 끝 · 넣기 · 지우기(캐시 · 횡단 다시). */
change: (year: number, edit: PlanEdit, final: boolean) => void;
/** 막힌 까닭(하한 · 끝 잠금). */
blocked: (message: string) => void;
}
export interface PlanEditLayer extends PlanMapOverlay {
setTargets: (targets: EditTarget[]) => void;
targets: () => EditTarget[];
}
type Grab = { year: number; index: number; which: "node" | "start" | "end" };
export function createPlanEditLayer(events: PlanEditEvents): PlanEditLayer {
let targets: EditTarget[] = [];
let grab: Grab | null = null;
let moved = false;
let stop = "";
const interior = (edit: PlanEdit): Vertex[] => edit.nodes.slice(1, -1);
function apply(target: EditTarget, step: EditStep, final: boolean): void {
if ("blocked" in step) {
stop = step.blocked;
if (final) events.blocked(stop);
return;
}
target.edit = step.edit;
target.line = step.line;
events.change(target.year, step.edit, final);
}
return {
setTargets(next) {
targets = next;
},
targets: () => targets,
draw(context, map) {
context.save();
context.font = "11px system-ui, sans-serif";
context.textAlign = "left";
context.textBaseline = "bottom";
for (const target of targets) {
for (const curve of target.line.curves) {
for (const end of [curve.start, curve.end]) {
const [x, y] = map.toScreen(end);
context.beginPath();
context.arc(x, y, 4, 0, Math.PI * 2);
context.fillStyle = "#f59e0b";
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = "#ffffff";
context.stroke();
}
const [ax, ay] = map.toScreen(curve.apex);
context.lineWidth = 3;
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
context.strokeText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5);
context.fillStyle = curve.violations.length ? "#fca5a5" : "#ffffff";
context.fillText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5);
}
interior(target.edit).forEach((node, i) => {
const [x, y] = map.toScreen(node);
context.fillStyle = "#ffffff";
context.fillRect(x - 4.5, y - 4.5, 9, 9);
context.lineWidth = 2;
const bad = target.line.nodes[i + 1]?.violations.length;
context.strokeStyle = bad ? "#dc2626" : "#2563eb";
context.strokeRect(x - 4.5, y - 4.5, 9, 9);
});
}
context.restore();
},
grab(px, py, map) {
grab = null;
moved = false;
stop = "";
for (const target of targets) {
const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX);
if (node >= 0) {
grab = { year: target.year, index: node + 1, which: "node" };
return true;
}
}
for (const target of targets) {
const hit = handleAtScreen(target.line.curves, map.toScreen, px, py, HANDLE_HIT_PX);
if (hit) {
grab = { year: target.year, index: hit.node, which: hit.end };
return true;
}
}
return false;
},
move(point) {
const target = grab && targets.find((entry) => entry.year === grab!.year);
if (!grab || !target) return;
if (!moved) events.begin();
moved = true;
const step =
grab.which === "node"
? moveNode(target.edit, grab.index, point, target.minRadiusM)
: moveHandle(target.edit, grab.index, grab.which, point, target.minRadiusM);
if (!("blocked" in step)) stop = "";
apply(target, step, false);
},
release() {
const target = grab && targets.find((entry) => entry.year === grab!.year);
grab = null;
if (!moved || !target) return;
moved = false;
events.change(target.year, target.edit, true);
if (stop) events.blocked(stop);
},
insert(px: number, py: number, map: ScreenMap) {
for (const target of targets) {
const segment = segmentAtScreen(target.edit.nodes, map.toScreen, px, py, SEGMENT_HIT_PX);
if (segment < 0) continue;
events.begin();
apply(
target,
insertNode(target.edit, segment, map.toMetric(px, py), target.minRadiusM),
true,
);
return;
}
},
remove(px: number, py: number, map: ScreenMap) {
for (const target of targets) {
const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX);
if (node < 0) continue;
events.begin();
apply(target, removeNode(target.edit, node + 1, target.minRadiusM), true);
return;
}
},
};
}