feat(D03): 평면 꺾임점 고침 겹 — 마디 끌기 · 두 번 누름 넣기 · 오른쪽 누름 지우기 · 곡선 손잡이 R · 캐시 d03-plan edits · 되돌리기(Ctrl+Z) · 원래 노선 · 카드 연장 즉시 (PLAN 543-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Api.ts
|
||||
* D03 분석 화면 서버 요청(PLAN 530-3) — 지점 정본(D02) · 지형 받기 · 분석 · 진행 · 저장본 · [저장] ·
|
||||
* 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄.
|
||||
* 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄 · 꺾임점 고침은
|
||||
* `edits`(마디 · 켬끔 · R — 선은 안 보냄 · 서버가 다시 그음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
|
||||
import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
|
||||
import type { ChainRow, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import type { PlanEdit } from "./logic/D03_PlanAnalysis_Edit";
|
||||
import type { ChainRow, RouteMetrics, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
/** 저장된 꺾임점 고침 — 입력 + 그때 서버가 잰 값. */
|
||||
export type SavedEdit = PlanEdit & { metrics?: RouteMetrics | null };
|
||||
|
||||
export interface PlanPointsView {
|
||||
year_start: number;
|
||||
@@ -29,7 +34,14 @@ export interface PlanJob {
|
||||
}
|
||||
|
||||
export interface PlanAnalysisView {
|
||||
years: Array<{ year: number; start: Vertex; end: Vertex; analysis: YearAnalysis | null }>;
|
||||
years: Array<{
|
||||
year: number;
|
||||
start: Vertex;
|
||||
end: Vertex;
|
||||
analysis: YearAnalysis | null;
|
||||
/** 저장된 꺾임점 고침(분석 지문이 같을 때만). */
|
||||
edit?: SavedEdit | null;
|
||||
}>;
|
||||
chain: ChainRow[] | null;
|
||||
/** 등록 노선 시작 누가(m). */
|
||||
start_chainage_m: number;
|
||||
@@ -83,10 +95,11 @@ export const savePlanAnalysis = (
|
||||
projectId: string,
|
||||
choices: Record<number, string>,
|
||||
points: PlanPoints | null,
|
||||
edits: Record<number, PlanEdit> = {},
|
||||
): Promise<{ chain: ChainRow[] }> =>
|
||||
request(`${base(projectId)}/analysis`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(points ? { choices, points } : { choices }),
|
||||
body: JSON.stringify(points ? { choices, points, edits } : { choices, edits }),
|
||||
});
|
||||
|
||||
export interface YearSurface {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` ·
|
||||
* 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선
|
||||
* `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시).
|
||||
* 꺾임점 고침 겹(543 · `overlay`) = 지점 다음 차례로 집음 · 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기.
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
@@ -60,10 +61,27 @@ export interface PlanMapScene {
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
/** 화면 px ↔ 작업 좌표계 m. */
|
||||
export interface ScreenMap {
|
||||
toScreen: (point: Vertex) => [number, number];
|
||||
toMetric: (px: number, py: number) => Vertex;
|
||||
}
|
||||
|
||||
/** 평면 위 고침 겹 — 그리기 · 집기(참 = 잡음) · 끌기 · 놓기 · 넣기 · 지우기. */
|
||||
export interface PlanMapOverlay {
|
||||
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
|
||||
grab: (px: number, py: number, map: ScreenMap) => boolean;
|
||||
move: (point: Vertex) => void;
|
||||
release: () => void;
|
||||
insert: (px: number, py: number, map: ScreenMap) => void;
|
||||
remove: (px: number, py: number, map: ScreenMap) => void;
|
||||
}
|
||||
|
||||
export interface PlanMapEvents {
|
||||
onDrag: (index: number, point: Vertex) => void;
|
||||
onDragEnd: (index: number) => void;
|
||||
onStation: (chainage: number) => void;
|
||||
overlay?: PlanMapOverlay;
|
||||
}
|
||||
|
||||
export interface PlanMap {
|
||||
@@ -73,6 +91,8 @@ export interface PlanMap {
|
||||
fit: () => void;
|
||||
/** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */
|
||||
stationScreen: (chainage: number) => [number, number] | null;
|
||||
/** 검증 창구 — 작업 좌표계 점 → 화면 px(그림 좌표 · 그리기 전이면 null). */
|
||||
screenOf: (point: Vertex) => [number, number] | null;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -133,6 +153,10 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
|
||||
const toScreen = (point: Vertex): [number, number] =>
|
||||
createMetricProjector(meta!, view).toScreen(point[0], point[1]);
|
||||
const screenMap: ScreenMap = {
|
||||
toScreen,
|
||||
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
|
||||
};
|
||||
|
||||
function refit(): void {
|
||||
const all = [...scene.nodes, ...scene.line];
|
||||
@@ -228,6 +252,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
if (scene.editable) events.overlay?.draw(context, screenMap);
|
||||
scene.nodes.forEach((node, i) => {
|
||||
const [sx, sy] = toScreen(node);
|
||||
const last = i === scene.nodes.length - 1;
|
||||
@@ -295,11 +320,12 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
press = [event.clientX, event.clientY];
|
||||
if (!meta || !scene.editable || event.button !== 0) return;
|
||||
const index = nodeAt(...at(event));
|
||||
if (index < 0) return;
|
||||
event.stopImmediatePropagation();
|
||||
const [px, py] = at(event);
|
||||
const [sx, sy] = toScreen(scene.nodes[index]);
|
||||
// 지점 먼저 · 다음 고침 겹(index -1)
|
||||
const index = nodeAt(px, py);
|
||||
if (index < 0 && !events.overlay?.grab(px, py, screenMap)) return;
|
||||
event.stopImmediatePropagation();
|
||||
const [sx, sy] = index < 0 ? [px, py] : toScreen(scene.nodes[index]);
|
||||
dragging = { index, id: event.pointerId, dx: sx - px, dy: sy - py, moved: false };
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
canvas.style.cursor = "grabbing";
|
||||
@@ -315,14 +341,17 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (!dragging.moved && !far) return;
|
||||
dragging.moved = true;
|
||||
const [px, py] = at(event);
|
||||
events.onDrag(dragging.index, screenToMetric(meta, view, px + dragging.dx, py + dragging.dy));
|
||||
const point = screenToMetric(meta, view, px + dragging.dx, py + dragging.dy);
|
||||
if (dragging.index < 0) events.overlay?.move(point);
|
||||
else events.onDrag(dragging.index, point);
|
||||
});
|
||||
const endDrag = (event: PointerEvent): void => {
|
||||
if (!dragging || event.pointerId !== dragging.id) return;
|
||||
const { index, moved } = dragging;
|
||||
dragging = null;
|
||||
canvas.style.cursor = "";
|
||||
if (moved) events.onDragEnd(index);
|
||||
if (index < 0) events.overlay?.release();
|
||||
else if (moved) events.onDragEnd(index);
|
||||
};
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
@@ -353,6 +382,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (hit) events.onStation(hit.chainage);
|
||||
});
|
||||
|
||||
canvas.addEventListener("dblclick", (event) => {
|
||||
if (meta && scene.editable) events.overlay?.insert(...at(event), screenMap);
|
||||
});
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
if (!meta || !scene.editable) return;
|
||||
event.preventDefault();
|
||||
events.overlay?.remove(...at(event), screenMap);
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => resize());
|
||||
observer.observe(root);
|
||||
|
||||
@@ -369,6 +407,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
draw();
|
||||
},
|
||||
stationScreen,
|
||||
screenOf: (point) => (meta ? toScreen(point) : null),
|
||||
dispose: () => observer.disconnect(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
* 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D].
|
||||
* 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」.
|
||||
*
|
||||
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 = 캐시(`d03-plan`) · [저장] = PUT plan/analysis
|
||||
* (끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 다시 셈). 화면 전체 잠금 = [저장]만.
|
||||
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] =
|
||||
* PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은
|
||||
* 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 ·
|
||||
* 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만.
|
||||
* 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -45,11 +47,13 @@ import {
|
||||
savePlanAnalysis,
|
||||
startPlanJob,
|
||||
type PlanAnalysisView,
|
||||
type SavedEdit,
|
||||
type PlanJob,
|
||||
type YearSurface,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
||||
import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross";
|
||||
import { createPlanEditLayer, type EditTarget } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
||||
import {
|
||||
chainRows,
|
||||
@@ -61,10 +65,19 @@ import {
|
||||
rowAt,
|
||||
sameNodes,
|
||||
stationChainages,
|
||||
withEdits,
|
||||
type ChainRow,
|
||||
type RouteEdit,
|
||||
type Vertex,
|
||||
type YearAnalysis,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
buildEdit,
|
||||
editFromRoute,
|
||||
moveNode,
|
||||
sameEdit,
|
||||
type PlanEdit,
|
||||
} from "./logic/D03_PlanAnalysis_Edit";
|
||||
|
||||
const MODE_LABEL: Record<string, string> = {
|
||||
gradeline: "영선",
|
||||
@@ -76,18 +89,30 @@ const POLL_MS = 1000;
|
||||
/** 등고선을 받아 올 지점 둘레(m). */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
|
||||
/** 되돌리기 쌓는 수. */
|
||||
const UNDO_MAX = 50;
|
||||
|
||||
interface Draft {
|
||||
nodes: Vertex[] | null;
|
||||
choices: Record<number, string>;
|
||||
/** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */
|
||||
edits?: Record<number, PlanEdit>;
|
||||
}
|
||||
|
||||
type Snapshot = { edits: Record<number, PlanEdit>; choices: Record<number, string> };
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
|
||||
const errorText = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
const map = createPlanMap({ onDrag, onDragEnd, onStation });
|
||||
const editLayer = createPlanEditLayer({
|
||||
begin: remember,
|
||||
change: onEdit,
|
||||
blocked: (message) => showToast(message, "info"),
|
||||
});
|
||||
const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer });
|
||||
const cross = createPlanCross();
|
||||
const main = document.createElement("div");
|
||||
main.className = "d03-plan";
|
||||
@@ -114,9 +139,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
|
||||
const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() });
|
||||
const undoButton = createButton({ label: "되돌리기", variant: "ghost", onClick: () => undo() });
|
||||
undoButton.title = "꺾임점 고침 · 갈래 고름 한 걸음 되돌림(Ctrl+Z)";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(analyzeButton, saveButton, viewButton);
|
||||
actions.append(analyzeButton, saveButton, viewButton, undoButton);
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
terrainCard.append(terrainText, bar, more);
|
||||
const routeCard = createPanelCard("연도별 노선", false);
|
||||
@@ -142,6 +169,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let editable = false;
|
||||
let busy: "terrain" | "analyze" | null = null;
|
||||
let lastCross: StationGround | null = null;
|
||||
// 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기
|
||||
let edits: Record<number, PlanEdit> = {};
|
||||
let serverEdits: Record<number, SavedEdit> = {};
|
||||
let draftEdits: Record<number, PlanEdit> | undefined;
|
||||
let history: Snapshot[] = [];
|
||||
let lastSaveMs: number | null = null;
|
||||
|
||||
const moved = (): boolean => !sameNodes(nodes, savedNodes);
|
||||
const sentPoints = () => (moved() ? pointsOf(years, nodes) : null);
|
||||
@@ -161,11 +194,54 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}),
|
||||
);
|
||||
const keepDraft = (): void =>
|
||||
writeState("d03-plan", { nodes: moved() ? nodes : null, choices } satisfies Draft, projectId);
|
||||
writeState(
|
||||
"d03-plan",
|
||||
{ nodes: moved() ? nodes : null, choices, edits } satisfies Draft,
|
||||
projectId,
|
||||
);
|
||||
|
||||
/** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */
|
||||
function remember(): void {
|
||||
history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })];
|
||||
}
|
||||
|
||||
function undo(): void {
|
||||
const last = history.pop();
|
||||
if (!last) return;
|
||||
({ edits, choices } = last);
|
||||
keepDraft();
|
||||
refresh();
|
||||
repick();
|
||||
}
|
||||
|
||||
/** 누른 측점 횡단을 지금 노선으로 다시. */
|
||||
function repick(): void {
|
||||
if (selected !== null && rowAt(rows, selected)) onStation(selected);
|
||||
}
|
||||
|
||||
function onEdit(year: number, edit: PlanEdit, final: boolean): void {
|
||||
edits = { ...edits, [year]: edit };
|
||||
refresh();
|
||||
if (!final) return;
|
||||
keepDraft();
|
||||
repick();
|
||||
}
|
||||
|
||||
/** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */
|
||||
const editOf = (result: YearAnalysis, mode: string | null): PlanEdit | null => {
|
||||
const own = edits[result.year];
|
||||
return own && own.base_mode === mode && own.base_fingerprint === result.fingerprint
|
||||
? own
|
||||
: null;
|
||||
};
|
||||
|
||||
/** 연도 목록에서 뺌. */
|
||||
const without = (from: Record<number, PlanEdit>, drop: number[]): Record<number, PlanEdit> =>
|
||||
Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year))));
|
||||
|
||||
/** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */
|
||||
function refresh(): void {
|
||||
results = years.map(
|
||||
const raw: YearAnalysis[] = years.map(
|
||||
(year, i) =>
|
||||
analysis?.years.find((entry) => entry.year === year)?.analysis ?? {
|
||||
year,
|
||||
@@ -176,7 +252,33 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
// 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름
|
||||
const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode]));
|
||||
rows = chainRows(results, { ...saved, ...choices }, startM);
|
||||
const picks = { ...saved, ...choices };
|
||||
// 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움
|
||||
const targets: EditTarget[] = [];
|
||||
const routed: Record<number, RouteEdit> = {};
|
||||
chainRows(raw, picks, startM).forEach((row, i) => {
|
||||
const result = raw[i];
|
||||
const route = row.mode ? result.routes[row.mode] : undefined;
|
||||
if (!row.mode || !route?.nodes || !result.fingerprint) return;
|
||||
const minRadiusM = result.criteria?.min_radius_m ?? 0;
|
||||
const own = editOf(result, row.mode);
|
||||
const built = buildEdit(
|
||||
own ?? editFromRoute(route, row.mode, result.fingerprint),
|
||||
minRadiusM,
|
||||
);
|
||||
targets.push({ year: result.year, ...built, minRadiusM });
|
||||
if (own)
|
||||
routed[result.year] = {
|
||||
mode: row.mode,
|
||||
planned: built.line.vertices,
|
||||
metrics: sameEdit(own, serverEdits[result.year])
|
||||
? serverEdits[result.year].metrics
|
||||
: null,
|
||||
};
|
||||
});
|
||||
editLayer.setTargets(editable ? targets : []);
|
||||
results = withEdits(raw, routed);
|
||||
rows = chainRows(results, picks, startM);
|
||||
const line = joinedLine(results, rows);
|
||||
const others: Vertex[][] = results.flatMap((result, i) => {
|
||||
const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode)
|
||||
@@ -210,6 +312,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요."
|
||||
: "";
|
||||
viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year));
|
||||
undoButton.hidden = !editable;
|
||||
undoButton.disabled = !history.length;
|
||||
}
|
||||
|
||||
function renderYears(): void {
|
||||
@@ -226,6 +330,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
`${stationLabel(row.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} (${row.length_m.toFixed(1)} m)`
|
||||
: `${result.year}년 · 분석 전 — [분석]을 누르세요`;
|
||||
box.append(head);
|
||||
if (row.mode && editOf(result, row.mode)) box.append(editLine(result.year));
|
||||
for (const mode of PLAN_MODES) {
|
||||
const route = result.routes[mode];
|
||||
if (!route) continue;
|
||||
@@ -239,11 +344,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = MODE_LABEL[mode];
|
||||
const values = document.createElement("span");
|
||||
values.textContent = metrics
|
||||
? `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${(metrics.max_grade_pct ?? 0).toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`
|
||||
: "노선 없음";
|
||||
values.textContent = !metrics
|
||||
? "노선 없음"
|
||||
: metrics.max_grade_pct === undefined
|
||||
? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈`
|
||||
: `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`;
|
||||
card.append(name, values);
|
||||
card.addEventListener("click", () => {
|
||||
if (row.mode === mode) return;
|
||||
remember();
|
||||
choices = { ...choices, [result.year]: mode };
|
||||
keepDraft();
|
||||
refresh();
|
||||
@@ -256,6 +365,30 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */
|
||||
function editLine(year: number): HTMLElement {
|
||||
const line = document.createElement("div");
|
||||
line.className = "d03-plan__year-head";
|
||||
line.textContent = sameEdit(edits[year], serverEdits[year])
|
||||
? "꺾임점 고침(저장됨) "
|
||||
: "꺾임점 고침 — [저장] 전 ";
|
||||
const reset = createButton({
|
||||
label: "원래 노선",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
remember();
|
||||
edits = without(edits, [year]);
|
||||
keepDraft();
|
||||
refresh();
|
||||
repick();
|
||||
},
|
||||
});
|
||||
reset.hidden = !editable;
|
||||
line.append(reset);
|
||||
return line;
|
||||
}
|
||||
|
||||
/** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */
|
||||
function targetLine(): HTMLElement[] {
|
||||
const last = rows[rows.length - 1];
|
||||
@@ -276,7 +409,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
function onDragEnd(index: number): void {
|
||||
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석]
|
||||
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] · 그 연도 꺾임점 고침도 버림
|
||||
const lost = [years[index - 1], years[index]].filter(
|
||||
(year) => year !== undefined && edits[year],
|
||||
);
|
||||
if (lost.length) {
|
||||
edits = without(edits, lost);
|
||||
history = [];
|
||||
showToast(`지점을 옮겨 ${lost.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
|
||||
}
|
||||
if (analysis)
|
||||
analysis = {
|
||||
...analysis,
|
||||
@@ -357,6 +498,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
analysis = await fetchPlanAnalysis(projectId, sentPoints());
|
||||
startM = analysis.start_chainage_m;
|
||||
targetM = analysis.end_chainage_target_m ?? null;
|
||||
serverEdits = Object.fromEntries(
|
||||
analysis.years.flatMap((entry) => (entry.edit ? [[entry.year, entry.edit]] : [])),
|
||||
);
|
||||
edits =
|
||||
draftEdits ??
|
||||
Object.fromEntries(
|
||||
Object.entries(serverEdits).map(([year, { metrics: _m, ...edit }]) => [year, edit]),
|
||||
);
|
||||
// 분석이 바뀐 연도(지문 다름)의 고침은 버림
|
||||
const prints = new Map(analysis.years.map((e) => [e.year, e.analysis?.fingerprint]));
|
||||
const stale = Object.keys(edits)
|
||||
.map(Number)
|
||||
.filter((year) => prints.get(year) !== edits[year].base_fingerprint);
|
||||
if (stale.length) {
|
||||
edits = without(edits, stale);
|
||||
if (draftEdits)
|
||||
showToast(`분석이 바뀌어 ${stale.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
|
||||
}
|
||||
draftEdits = edits;
|
||||
} catch (error) {
|
||||
analysis = null;
|
||||
showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
@@ -422,10 +582,20 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const picked = Object.fromEntries(
|
||||
rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]),
|
||||
);
|
||||
const sent = Object.fromEntries(
|
||||
results.flatMap((result, i) => {
|
||||
const edit = editOf(result, rows[i].mode);
|
||||
return edit ? [[result.year, edit]] : [];
|
||||
}),
|
||||
);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await savePlanAnalysis(projectId, picked, sentPoints());
|
||||
const began = performance.now();
|
||||
await savePlanAnalysis(projectId, picked, sentPoints(), sent);
|
||||
lastSaveMs = Math.round(performance.now() - began);
|
||||
clearState("d03-plan", projectId);
|
||||
draftEdits = undefined;
|
||||
history = [];
|
||||
await loadPoints();
|
||||
await loadAnalysis();
|
||||
refresh();
|
||||
@@ -469,17 +639,42 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const draft = readState<Draft>("d03-plan", projectId);
|
||||
nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes;
|
||||
choices = draft?.choices ?? {};
|
||||
draftEdits = draft?.edits;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디(ORCA 판정)
|
||||
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디 · 꺾임점 고침 · 저장 시간(ORCA 판정)
|
||||
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
|
||||
stationScreen: map.stationScreen,
|
||||
screenOf: map.screenOf,
|
||||
rows: () => rows,
|
||||
nodes: () => nodes,
|
||||
pick: onStation,
|
||||
cross: () => lastCross,
|
||||
edits: () => edits,
|
||||
targets: () => editLayer.targets(),
|
||||
results: () => results,
|
||||
saveMs: () => lastSaveMs,
|
||||
// 가이드 그림 · 검증 — 첫 고침 연도 마디 하나를 (dx, dy) m 옮김(끌기와 같은 길)
|
||||
nudge: (index: number, dx: number, dy: number) => {
|
||||
const target = editLayer.targets()[0];
|
||||
const node = target?.edit.nodes[index];
|
||||
if (!node) return;
|
||||
const step = moveNode(target.edit, index, [node[0] + dx, node[1] + dy], target.minRadiusM);
|
||||
if ("blocked" in step) return showToast(step.blocked, "info");
|
||||
remember();
|
||||
onEdit(target.year, step.edit, true);
|
||||
},
|
||||
};
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("keydown", onKey);
|
||||
const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select");
|
||||
if (editable && !typing && (event.ctrlKey || event.metaKey) && event.key === "z") {
|
||||
event.preventDefault();
|
||||
undo();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
try {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
|
||||
Reference in New Issue
Block a user