187 lines
6.4 KiB
TypeScript
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;
|
|
}
|
|
},
|
|
};
|
|
}
|