feat(B05 평면): 예정노선 변경 = 예정노선 페이지 긋기 · 고르기 · 메뉴 · 속성 판 그대로(한 선) · 원본 옅은 점선 켬끔 (PLAN 626-3 · 요구 535-1 · 536 · 535-3)

- B05 「예정노선 변경」 켬끔 = D03 createPlanDraw · createPlanEdit · createPlanMenu · createPlanProps 를 한 선 network 로 얹음(같은 함수 · 단추 · 단축키 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete · [다시 긋기]) — 갈래 · 둘째 선 · 연도 · 지점은 막음/숨김
- 한 번 고침(긋기 끝 · 놓음 · 지움) → 켠 뒤 첫 고침만 확인창 → `/route/follow` → 편집기 한 걸음 · 다 지우면 새로 그을 때까지 평면 그대로
- 도구줄 「예정노선(원본)」 켬끔 — 파일입력 예정노선(안 덮음)을 옅은 점선으로(고친 것과 다를 때만)
- D03 덧 인자 `oneLine`(UI_Draw · UI_DrawPanel 연도 마커 · UI_Menu · UI_Props) — 기본값 = 지금 그대로 · 긋기 칸 · 속성 판 꼴 CSS 를 `D03_PlanAnalysis_UI_DrawParts.css` 로 옮겨 그 TS 가 불러옴(D03 꼴 같음)
- 옛 자체 긋기 셈 `logic/B05_Profile_ExpectedDraw.ts` 지움(D03 셈으로 갈음) · 시험 test_626_3 다시 씀(5)
- 낡은 글 고침 — 설계 지형 바꿈 = 지형 칩(`/route/design-terrain`) · 「추천 [적용] 때만」 글 걷음(Router_Terrain · Router_Replan · Api_Replan · RouteEdit_Apply · 동작 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 22:42:36 +09:00
co-authored by Claude Opus 5.5
parent 8a7d972ce8
commit e250517adf
18 changed files with 451 additions and 580 deletions
+2 -1
View File
@@ -48,9 +48,10 @@ export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [
[
{
key: "expected-draw",
does: "예정노선 그리기 판 켬끔 — 고친 예정노선 따라 평면선 다시 추적(626-3)",
does: "예정노선 변경 켬끔 — 예정노선 페이지 긋기 그대로 · 고친 예정노선 따라 평면선 다시 추적(626-3)",
pages: B05,
},
{ key: "expected-original", does: "예정노선(원본) 옅은 점선 켬끔(626-3)", pages: B05 },
],
[
{
+2 -2
View File
@@ -6,8 +6,8 @@
* POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial)
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
*
* 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 지형 고르기는 추천 창 안 · [적용]이
* 그 지형을 설계 지형으로(509-5).
* 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 설계 지형 바꿈 = 평면 도구줄 지형 칩
* (`/route/design-terrain` · 626-1 · `_Terrain`).
*
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
* 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다.
+1 -1
View File
@@ -573,7 +573,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
if request.balanced_profile and as_initial
else None
),
request.terrain if as_initial else None, # 지형 바꿈 = 추천 [적용]만(509-5)
request.terrain if as_initial else None, # 손 [저장] = 지형 그대로 · 바꿈 = 지형 칩(626-1)
(old_line, await asyncio.to_thread(_vertices_of, working_path)),
as_initial,
)
+3 -1
View File
@@ -13,7 +13,9 @@
GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag)
분석 지형(`terrain`, PLAN 133-1) — las = 라이다 · sheet = 수치지형도 · None = 지금 설계 지형.
고르기는 추천 노선 창 안(509-5) — 설계 지형(확정)은 추천 [적용] 때만 바뀜(`/route/replan`).
고르기 = 평면 도구줄 지형 칩(626-1) — 설계 지형(확정)은 지형 칩
→ `POST /route/design-terrain` 때만 바뀜(손 [저장] = 지형 그대로 · 옛 추천 [적용] `as_initial` 길은
서버 · 시험만 남음).
"""
import asyncio
+290 -301
View File
@@ -1,76 +1,80 @@
/* =============================================================================
* B05_Profile_UI_ExpectedDraw.ts
* B05 평면 예정노선 그리기(PLAN 626-3 · 요구 530-11 · 12 · 결정 D3 · D4 · D9 · D10) — 도구줄 「예정노선 그리기」 칸.
* 켬 = 떠 있는 판(새로 긋기 · 구간 다시 긋기 · 지움 + 공용 방식 칸 `map_draw_kind`) · 고치는 예정노선이 맨 위 ·
* 캔버스 왼쪽 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기(Shift+누름) ·
* 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠은 그대로.
* 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab(자유지정 ↔ 길이·기울기) · Enter 끝 · Esc 취소 ·
* Backspace 마지막 점 빼기. 구간 다시 긋기 = 예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝(그 사이 갈음 ·
* Enter = 취소). 지움 = 고친 예정노선 버리고 파일입력 예정노선으로.
* 끝 → 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → `POST /route/follow`(저장 0) →
* `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] 이 손 편집과 같은 길 + 초안 `expected`.
* 예정노선 세 벌 = 파일입력(`expected` · 안 바뀜) · 저장본(`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림).
* 셈 = `logic/B05_Profile_ExpectedDraw` · 그림 = D03 `paintPreview` · `strokeCased` · `slopeColor` 그대로.
* B05 평면 예정노선 변경(PLAN 626-3 · 요구 530-11 · 12 · 535-1 · 536 · 결정 D3 · D4 · D9) — 도구줄 「예정노선 변경」 켬끔.
* 켬 = 예정노선이 맨 위 · 예정노선 페이지(D03) 긋기 · 고르기 · 고치기를 **그대로**(같은 함수 · 단추 · 단축키):
* 긋기 칸 `createPlanDraw`(방식 칸 · 부채꼴 · +/− 색 · Shift · Tab · Enter · Esc · Backspace · 선 끝 이어 긋기) ·
* 고르기 겹 `createPlanEdit`(노드 → 선 → 구역 → 전체 · 끌어 고르기 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete) ·
* 우클릭 메뉴 `createPlanMenu`(여기서 긋기 시작 · 이어 긋기 · 노드 지움 · 다시 긋기) · 속성 판 `createPlanProps`([다시 긋기] ·
* [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 한 선(`oneLine`) = 갈래 · 둘째 선 · 연도 · 지점 막음/숨김.
* 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 ·
* 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만).
* 한 번 고침(긋기 끝 · 놓음 · 지움) → (켠 뒤 처음만) 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) →
* `POST /route/follow`(저장 0) → `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] = 손 편집과
* 같은 길 + 초안 `expected`(`expected_route_edit.csv`). 선이 없으면(다 지움) 평면 그대로 · 새로 그을 때까지 기다림.
* 예정노선 세 벌 = 파일입력(`expected_route.csv` · 안 덮음 · 도구줄 「예정노선(원본)」 켬끔 = 옅은 점선) · 저장본
* (`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림).
* ========================================================================== */
import { keyIs } from "../A00_Common/key_registry";
import { listenWhileAlive } from "@ui/ui_template_window_watch";
import { createButton, showToast } from "@ui/ui_template_elements";
import { showToast } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { askConfirm } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { themeColor } from "@ui/ui_template_palette";
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
import {
createDrawKindFields,
DRAW_DEFAULT_LENGTH_M,
DRAW_DEFAULT_SLOPE_PCT,
type DrawConstraint,
} from "../A00_Common/map/map_draw_kind";
import { groundSlopePct, segmentLength } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw";
import type { Fan } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan";
import {
paintPreview,
slopeColor,
strokeCased,
} from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint";
import type { DrawPreview } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw";
import { planColors } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
import type { PlanNetwork } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan";
import { createPlanDraw } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw";
import { createPlanEdit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit";
import type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
import { createPlanMenu } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu";
import type { PlanPoints } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Points";
import { createPlanProps } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Props";
import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits";
import {
leadBefore,
nearestOnLine,
pickNext,
respliceLine,
} from "./logic/B05_Profile_ExpectedDraw";
type Vertex = [number, number];
/** 예정노선 위로 볼 반경(px) — D03 붙음 반경과 같음. */
const SNAP_PX = 12;
const IDLE_HINT = "새로 긋기 · 구간 다시 긋기 · 지움 중 하나를 고릅니다.";
const KEYS_HINT = "Enter 끝 · Esc 취소 · Backspace 점 빼기 · Tab 방식 · Shift 사이 기울기";
const HINT =
"[긋기] · 우클릭 「여기서 긋기 시작」 · 선 끝에서 이어 긋기 · 고르고 Delete · 노드 끌기 — 한 번 고칠 때마다 평면선을 다시 추적합니다.";
const EMPTY_HINT = "예정노선이 없습니다 — 빈 땅 우클릭 「여기서 긋기 시작」으로 새로 긋습니다.";
/** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */
type FollowResponse = Pick<RoutePlanResponse, "planned" | "nodes" | "curves" | "node_edits">;
/** 지점 없음(B05 예정노선 = 한 선 · 메뉴의 지점 항목 숨김). */
const NO_POINTS: PlanPoints = {
panel: document.createElement("div"),
input: { draw: () => undefined },
pins: { draw: () => undefined },
render: () => undefined,
pinAt: () => null,
place: () => undefined,
remove: () => undefined,
name: () => "",
full: () => true,
};
export interface ExpectedDrawParams {
projectId: string;
canvas: HTMLCanvasElement;
/** 도구줄 칸 · 판을 얹을 도구 줄 칸. */
button: HTMLButtonElement;
host: HTMLElement;
/** 평면 뼈대 — 도구줄 칸(변경 켬끔 · 원본 켬끔) · 긋기 판 자리(도구 줄) · 속성 판 · 메뉴 자리(지도 칸). */
chrome: Pick<RouteEditChrome, "overlay" | "expectedDrawButton" | "expectedOriginalButton">;
toScreen: (vertex: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
/** 지도 돌림 — 보이는 px ↔ 그리기 px · 글자 세울 각(만든 뒤에 서므로 함수로). */
rotation: () => Pick<MapRotation, "unrotate" | "rerotate" | "uprightRad">;
pxPerMeter: () => number;
view: () => ViewState;
redraw: () => void;
/** 판을 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */
/** 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */
onOpen: () => void;
/** 다시 추적한 평면 · 고친 예정노선([] = 지움) — 편집기 한 걸음. */
/** 다시 추적한 평면 · 고친 예정노선 — 편집기 한 걸음. */
onFollow: (plan: FlattenedPlan, expected: Vertex[]) => void;
}
@@ -83,209 +87,187 @@ export interface ExpectedDraw {
/** 지금 보일 예정노선(같은 배열을 돌려줌 — 재기 높이 표가 배열로 낡음을 봄). */
line: () => Vertex[];
paint: (context: CanvasRenderingContext2D) => void;
/** 검증 창구 — 판 켬 · 긋는 중 · 미리보기. */
state: () => {
open: boolean;
stroke: string | null;
points: number;
preview: DrawPreview | null;
};
}
type Stroke =
| { mode: "new"; points: Vertex[] }
| { mode: "respan"; start: { atM: number; point: Vertex } | null; through: Vertex[] };
const sameLine = (a: ReadonlyArray<Vertex>, b: ReadonlyArray<Vertex>): boolean =>
JSON.stringify(a) === JSON.stringify(b);
export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
const { canvas } = params;
const { expectedDrawButton: button, expectedOriginalButton: originalButton } = params.chrome;
const viewport = params.chrome.overlay.querySelector<HTMLElement>(".b05-routeedit__canvas-wrap")!;
let original: Vertex[] = [];
let saved: Vertex[] = [];
let edit: Vertex[] | null = null;
let open = false;
let showOriginal = true;
let busy = false;
let stroke: Stroke | null = null;
let preview: DrawPreview | null = null;
/** 확인창이 떠 있는 동안 보일 새 예정노선(묻는 동안 무엇이 바뀌는지 보이게). */
let asking: Vertex[] | null = null;
let fan: Fan | null = null;
let hoverAt: { px: number; py: number; shift: boolean } | null = null;
/** 켠 뒤 확인창을 물었나 — 다시 추적 뒤 평면은 손 고침이 없음(켠 동안 계획선 안 집힘). */
let asked = false;
let grid: DesignTerrainGrid | null = null;
let kind: DrawConstraint = "both"; // 기억 안 함(B9)
let lengthM = DRAW_DEFAULT_LENGTH_M;
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
/** 고치는 판(D03 꼴 · 한 선) · 긋기 시작 때 판(끝나면 바뀜을 봄). */
let network: PlanNetwork = { lines: [], pieces: [] };
let strokeBase: PlanNetwork | null = null;
const line = (): Vertex[] =>
edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original;
const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN);
const fromLine = (): PlanNetwork => {
const shown = line();
const vertices = shown.map(([x, y]): Vertex => [x, y]);
return { lines: shown.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] };
};
// ── 판 ─────────────────────────────────────────────────
const panel = createFloatPanel({ title: "예정노선 그리기", onClose: () => setOpen(false) });
// ── D03 긋기 · 고르기 · 메뉴 · 속성 판(같은 함수) ─────────
const draw = createPlanDraw({
base: () => [],
network: () => network,
heightAt: () => z,
commit,
onMode,
oneLine: true,
});
const show = (next: PlanNetwork): void => {
network = next;
params.redraw();
};
// `heightAt` = 625-3(놓인 노드 부호 색) 덧 칸 — 합치기 앞뒤 둘 다 맞게 칸 묶음을 따로 둠
const editParams = {
network: () => network,
base: (): Vertex[] => [],
commit,
show,
commitPoints: (_points: Vertex[], next: PlanNetwork) => commit(next),
showPoints: (_points: Vertex[], next: PlanNetwork) => show(next),
enabled: () => open && draw.mode() === "idle",
editable: () => open && !busy,
heightAt: () => z,
onSelect: (): void => (props.render(), params.redraw()),
};
const editor = createPlanEdit(editParams);
const props = createPlanProps({
network: () => network,
edit: editor,
editable: () => !busy,
yearStart: () => new Date().getFullYear(),
commit,
heightAt: () => z,
epsg: () => 0,
points: () => [],
addPoint: () => undefined,
commitPoints: (_points, next) => commit(next),
respan: draw.respan,
oneLine: true,
});
const menu = createPlanMenu({
network: () => network,
points: () => [],
marks: NO_POINTS,
edit: editor,
draw,
editable: () => open && !busy,
respan: draw.respan,
oneLine: true,
});
const layers: PlanMapLayer[] = [draw.layer, editor.layer, menu.layer];
props.panel.classList.add("b05-routeedit__expected-props");
viewport.append(props.panel);
menu.mount(viewport);
// ── 켬끔 판 ─────────────────────────────────────────────
const panel = createFloatPanel({ title: "예정노선 변경", onClose: () => setOpen(false) });
panel.root.classList.add("b05-routeedit__expected-draw");
panel.root.hidden = true;
const hint = createText(IDLE_HINT, "muted");
const kindFields = createDrawKindFields({
value: { kind, lengthM, slopePct },
onChange: (value) => {
({ kind, lengthM, slopePct } = value);
rehover();
},
const hint = createText(HINT, "muted");
panel.body.append(draw.panel, hint);
params.chrome.overlay.querySelector(".b05-routeedit__tools")!.append(panel.root);
button.addEventListener("click", () => setOpen(!open));
const paintOriginalButton = (): void => {
originalButton.setAttribute("aria-pressed", String(showOriginal));
originalButton.classList.toggle("is-active", showOriginal);
};
paintOriginalButton();
originalButton.addEventListener("click", () => {
showOriginal = !showOriginal;
paintOriginalButton();
params.redraw();
});
const row = document.createElement("div");
row.className = "b05-routeedit__expected-row";
row.append(
createButton({ label: "새로 긋기", variant: "filled", size: 28, onClick: () => begin("new") }),
createButton({
label: "구간 다시 긋기",
variant: "ghost",
size: 28,
onClick: () => begin("respan"),
}),
createButton({ label: "지움", variant: "ghost", size: 28, onClick: () => void clear() }),
);
panel.body.append(row, kindFields.root, hint);
params.host.append(panel.root);
params.button.addEventListener("click", () => setOpen(!open));
function setOpen(next: boolean): void {
if (draw.mode() !== "idle") draw.setMode("idle");
open = next;
stroke = null;
preview = fan = null;
params.button.setAttribute("aria-pressed", String(open));
params.button.classList.toggle("is-active", open);
asked = false;
network = fromLine();
editor.select([]);
draw.setEditable(open);
button.setAttribute("aria-pressed", String(open));
button.classList.toggle("is-active", open);
panel.root.hidden = !open;
hint.textContent = IDLE_HINT;
canvas.style.removeProperty("cursor");
hint.textContent = network.lines.length ? HINT : EMPTY_HINT;
if (open) {
params.onOpen();
void loadDesignTerrainGrid(params.projectId).then((loaded) => ((grid = loaded), rehover()));
void loadDesignTerrainGrid(params.projectId).then(
(loaded) => ((grid = loaded), params.redraw()),
);
}
props.render();
params.redraw();
}
function begin(mode: Stroke["mode"]): void {
if (busy) return;
if (mode === "respan" && line().length < 2)
return showToast("다시 그을 예정노선이 없습니다 — 새로 긋기를 쓰세요.", "info");
stroke = mode === "new" ? { mode, points: [] } : { mode, start: null, through: [] };
preview = fan = null;
hint.textContent =
(mode === "new"
? "첫 점을 누릅니다 · "
: "예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝 · ") + KEYS_HINT;
canvas.style.cursor = "crosshair";
/** 꼴 한 걸음(D03 `commit`) — 긋는 중이면 판만 · 쉴 때(놓음 · 지움) = 다시 추적. */
function commit(next: PlanNetwork): void {
network = next;
props.render();
params.redraw();
if (draw.mode() === "idle") void apply();
}
function stop(message = IDLE_HINT): void {
stroke = null;
preview = fan = null;
hint.textContent = message;
canvas.style.removeProperty("cursor");
params.redraw();
}
// ── 셈 ──────────────────────────────────────────────────
/** 화면 px 둘레 예정노선 위 자리 — 멀면 null. */
function onLine(px: number, py: number): { atM: number; point: Vertex } | null {
const shown = line();
if (shown.length < 2) return null;
const hit = nearestOnLine(shown, params.toMetric(px, py));
const [sx, sy] = params.toScreen(hit.point);
return Math.hypot(sx - px, sy - py) <= SNAP_PX ? hit : null;
}
/** 그은 점(구간 다시 긋기 = 시작 자리 + 속 점). */
function strokePoints(): Vertex[] {
if (!stroke) return [];
if (stroke.mode === "new") return stroke.points;
return stroke.start ? [stroke.start.point, ...stroke.through] : [];
}
function previewAt(px: number, py: number, shift: boolean): DrawPreview | null {
const points = strokePoints();
const from = points[points.length - 1];
if (!stroke || !from) return null;
const cursor = params.toMetric(px, py);
const prev =
points.length > 1
? points[points.length - 2]
: stroke.mode === "respan" && stroke.start
? leadBefore(line(), stroke.start.atM)
: null;
const end = stroke.mode === "respan" ? onLine(px, py) : null;
const pick = pickNext(kind, from, cursor, lengthM, slopePct, z, prev, shift);
fan = pick.fan;
const point = end ? end.point : pick.point;
const at = point ?? cursor;
const length = segmentLength(from, at);
return {
point: at,
lengthM: length,
slopePct: groundSlopePct(from, at, z),
note: end ? "예정노선에 붙음 — 끝" : pick.note,
blocked: !point || length <= 0,
snapped: !!end,
lengthOnly: pick.lengthOnly && !end,
onLine: null,
};
}
function rehover(): void {
if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.shift);
params.redraw();
}
function press(px: number, py: number, shift: boolean): void {
if (!stroke || busy) return void (open && !stroke && showToast(IDLE_HINT, "info"));
if (stroke.mode === "new" && !stroke.points.length) {
stroke.points.push(params.toMetric(px, py));
return rehover();
/** 긋기 들어감 = 판 기억 · 긋기 끝 = 바뀌었으면 다시 추적. */
function onMode(): void {
if (draw.mode() === "draw") {
strokeBase ??= network;
return params.redraw();
}
if (stroke.mode === "respan" && !stroke.start) {
stroke.start = onLine(px, py);
if (!stroke.start) showToast("예정노선 위를 눌러 시작합니다.", "info");
return rehover();
}
preview = previewAt(px, py, shift);
if (!preview || preview.blocked)
return showToast(preview?.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
if (stroke.mode === "respan" && preview.snapped) {
const end = onLine(px, py)!;
const next = respliceLine(line(), stroke.start!.atM, end.atM, stroke.through);
return void follow(next);
}
if (stroke.mode === "new") stroke.points.push(preview.point);
else stroke.through.push(preview.point);
rehover();
const base = strokeBase;
strokeBase = null;
if (base && JSON.stringify(base.lines) !== JSON.stringify(network.lines)) void apply();
params.redraw();
}
function finish(): void {
if (!stroke) return;
if (stroke.mode === "respan")
return stop("구간 다시 긋기는 예정노선 위를 눌러 끝 — 취소했습니다.");
if (stroke.points.length < 2) return void showToast("점이 2개 이상이어야 합니다.", "info");
void follow([...stroke.points]);
function revert(): void {
network = fromLine();
editor.sync();
draw.sync();
props.render();
params.redraw();
}
/** 다시 추적 — 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. `next` = [] 면 파일입력 예정노선. */
async function follow(next: Vertex[]): Promise<void> {
stop();
asking = next.length ? next : original;
params.redraw();
const ok = await askConfirm({
title: "평면선 다시 추적",
message:
(next.length ? "고친 예정노선" : "파일입력 예정노선") +
"을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " +
"[저장] 전까지는 되돌리기로 물릴 수 있습니다.",
okLabel: "다시 추적",
});
asking = null;
params.redraw();
if (!ok) return;
/** 다시 추적 — 한 선 · 점 2 이상일 때만 · 켠 뒤 처음만 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. */
async function apply(): Promise<void> {
if (busy || !open) return;
const drawn = network.lines.filter((entry) => entry.vertices.length > 1);
hint.textContent = drawn.length ? HINT : EMPTY_HINT;
if (!drawn.length) return; // 다 지움 — 새로 그을 때까지 평면 그대로
if (drawn.length > 1) {
showToast("예정노선은 한 선입니다.", "info");
return revert();
}
const points = drawn[0].vertices.map(([x, y]): Vertex => [x, y]);
if (sameLine(points, line())) return;
if (!asked) {
const ok = await askConfirm({
title: "평면선 다시 추적",
message:
"고친 예정노선을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " +
"예정노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.",
okLabel: "다시 추적",
});
if (!ok) return revert();
asked = true;
}
busy = true;
params.redraw();
try {
const points = next.length ? next : original;
const plan = await requestJson<FollowResponse>(
`/projects/${params.projectId}/route/follow`,
{ method: "POST", body: JSON.stringify({ points, crs: "tm" }) },
@@ -293,25 +275,45 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
);
params.onFollow(
flattenServerPlan(plan.nodes ?? [], plan.curves ?? [], plan.node_edits),
next,
points,
);
} catch (error) {
showToast(
`평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
"error",
);
revert();
} finally {
busy = false;
params.redraw();
}
}
async function clear(): Promise<void> {
if (busy) return;
if (line() === original) return showToast("고친 예정노선이 없습니다.", "info");
await follow([]);
}
// ── 누름 · 키 — 판이 켜진 동안 먼저 받음(계획선 · 구조물 · 재기로 안 샘) ──
// ── 누름 · 키 — 켠 동안 먼저 받아 D03 겹에 넘김(계획선 · 구조물 · 재기로 안 샘) ──
const map = (): ScreenMap => {
const rotation = params.rotation();
return {
toScreen: params.toScreen,
toMetric: params.toMetric,
pxPerMeter: params.pxPerMeter(),
view: params.view(),
stations: false, // 측점 눈금 = 계획선 몫
slopes: markShown("slopes"),
uprightRad: rotation.uprightRad(),
upright(context, x, y, paint) {
const radians = rotation.uprightRad();
if (!radians) return paint();
context.save();
context.translate(x, y);
context.rotate(radians);
context.translate(-x, -y);
paint();
context.restore();
},
rerotate: rotation.rerotate,
unrotate: rotation.unrotate,
};
};
const at = (event: MouseEvent): [number, number] => {
const rect = canvas.getBoundingClientRect();
return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top);
@@ -325,119 +327,102 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
(event) => {
if (!open || event.button !== 0) return;
claim(event);
press(...at(event), event.shiftKey);
const [px, py] = at(event);
if (!busy && layers.some((layer) => layer.press?.(px, py, map(), 0, event.shiftKey)))
canvas.setPointerCapture(event.pointerId); // 끌기가 밖으로 나가도 놓음을 받음
params.redraw();
},
true,
);
canvas.addEventListener(
"pointermove",
(event) => {
if (!open || event.buttons) return; // 가운데 끌기 = 지도 이동 그대로
if (!open || event.buttons & 6) return; // 오른쪽 · 가운데 끌기 = 지도 이동 그대로
event.stopImmediatePropagation();
const [px, py] = at(event);
hoverAt = { px, py, shift: event.shiftKey };
if (stroke) rehover();
const screen = map();
for (const layer of layers) layer.hover?.(px, py, screen, event.shiftKey);
params.redraw();
},
true,
);
for (const type of ["pointerup", "click", "dblclick", "contextmenu"])
canvas.addEventListener(
type,
(event) => {
if (open && (event as MouseEvent).button !== 1) claim(event);
},
true,
);
canvas.addEventListener(
"pointerup",
(event) => {
if (!open || event.button !== 0) return;
claim(event);
const screen = map();
for (const layer of layers) layer.release?.(screen);
params.redraw();
},
true,
);
canvas.addEventListener(
"contextmenu",
(event) => {
if (!open) return;
claim(event);
const [px, py] = at(event);
if (!busy) layers.some((layer) => layer.press?.(px, py, map(), 2, event.shiftKey));
params.redraw();
},
true,
);
for (const type of ["click", "dblclick"])
canvas.addEventListener(type, (event) => open && claim(event), true);
canvas.addEventListener("pointerleave", () => {
hoverAt = null;
if (preview) ((preview = null), params.redraw());
for (const layer of layers) layer.leave?.();
if (open) params.redraw();
});
const onKey = (event: KeyboardEvent): void => {
if (!open || (event.target as HTMLElement | null)?.closest?.("input, select, textarea")) return;
if (!stroke) return;
if (event.key === "Shift") {
if (hoverAt) ((hoverAt.shift = event.type === "keydown"), rehover());
return;
}
if (event.type !== "keydown") return;
if (keyIs(event, "plan.drawKind")) {
kind = kind === "both" ? "free" : "both"; // 단독 고정이면 길이·기울기로(625-1)
kindFields.set({ kind });
rehover();
} else if (keyIs(event, "plan.drawFinish")) finish();
else if (event.key === "Escape") stop();
else if (keyIs(event, "plan.drawBack")) {
const points = stroke.mode === "new" ? stroke.points : stroke.through;
if (!points.pop() && stroke.mode === "respan") stroke.start = null;
rehover();
} else return;
if (keyIs(event, "plan.undo") || keyIs(event, "plan.redo")) return; // 되돌리기 = 페이지 사진첩
if (!(draw.key(event) || editor.key(event))) return;
claim(event);
params.redraw();
};
listenWhileAlive(window, "keydown", canvas, onKey, true);
listenWhileAlive(window, "keyup", canvas, onKey, true);
// ── 그림 ────────────────────────────────────────────────
const map = {
toScreen: params.toScreen,
pxPerMeter: 1,
upright(context: CanvasRenderingContext2D, x: number, y: number, paint: () => void): void {
const radians = params.rotation().uprightRad();
if (!radians) return paint();
context.save();
context.translate(x, y);
context.rotate(radians);
context.translate(-x, -y);
paint();
context.restore();
},
};
const path = (context: CanvasRenderingContext2D, points: ReadonlyArray<Vertex>): void => {
context.beginPath();
points.forEach((point, i) => {
const [sx, sy] = params.toScreen(point);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
};
function paint(context: CanvasRenderingContext2D): void {
if (!open) return;
const colors = planColors();
map.pxPerMeter = params.pxPerMeter();
context.save();
context.lineCap = "round";
context.lineJoin = "round";
path(context, line()); // 고치는 예정노선 = 맨 위(D9)
strokeCased(context, colors.measure, 2.5, [8, 5]);
const points = strokePoints();
if ((asking ?? points).length > 1) {
path(context, asking ?? points); // 묻는 동안 = 새 예정노선 통째
strokeCased(context, colors.active, 3);
}
points.forEach((point, i) => {
const [sx, sy] = params.toScreen(point);
if (showOriginal && original.length > 1 && line() !== original) {
context.save();
context.beginPath();
context.arc(sx, sy, 3.5, 0, Math.PI * 2);
context.fillStyle = i ? slopeColor(groundSlopePct(points[i - 1], point, z)) : colors.active;
context.fill();
});
const from = points[points.length - 1];
if (from && preview) paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
context.restore();
original.forEach((point, i) => {
const [sx, sy] = params.toScreen(point);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.globalAlpha = 0.7;
context.setLineDash([2, 5]);
context.lineWidth = 2;
context.strokeStyle = themeColor("--draw-text-secondary", "#9ca3af"); // 원본 = 옅은 점선
context.stroke();
context.restore();
}
if (!open) return;
const screen = map();
for (const layer of layers) {
context.save();
layer.draw(context, screen); // 고치는 예정노선 = 맨 위(D9) · 고름 · 미리보기
context.restore();
}
}
const state = (): ReturnType<ExpectedDraw["state"]> => ({
open,
stroke: stroke?.mode ?? null,
points: strokePoints().length,
preview,
});
// 검증 창구 — 판 상태 · 보이는 예정노선 · 사업지 좌표 → 보이는 화면 px
// 검증 창구 — 켬 · 긋기 모드 · 고친 판 · 보이는 예정노선 · 사업지 좌표 → 보이는 화면 px
Object.assign(canvas, {
expectedProbe: {
state,
state: () => ({
open,
mode: draw.mode(),
lines: network.lines.map((entry) => entry.vertices.length),
preview: draw.preview(),
selected: editor.selected()?.kind ?? null,
busy,
}),
line,
original: () => original,
edit: () => edit,
screen: (vertex: Vertex) => params.rotation().rerotate(...params.toScreen(vertex)),
},
@@ -447,13 +432,17 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
load(nextOriginal, nextSaved) {
original = nextOriginal;
saved = nextSaved;
network = fromLine();
},
edit: () => edit,
setEdit(next) {
edit = next;
if (draw.mode() !== "idle") return; // 긋는 중 되돌림 — 긋기가 끝나면 그 판으로 다시 추적
network = fromLine();
editor.sync();
props.render();
},
line,
paint,
state,
};
}
+2 -2
View File
@@ -122,12 +122,12 @@ export async function mountRouteEditor(
const expectedDraw = createExpectedDraw({
projectId,
canvas,
button: chrome.expectedDrawButton,
host: overlay.querySelector<HTMLElement>(".b05-routeedit__tools")!,
chrome,
toScreen: (vertex) => toScreen(vertex),
toMetric: (px, py) => toMetric(px, py),
rotation: () => rotation,
pxPerMeter: () => pxPerMeter(),
view: () => view,
redraw: () => draw(),
onOpen: () => ((picked = -1), syncCurveBar()),
onFollow: (flat, line) =>
@@ -94,7 +94,7 @@ export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteD
/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. 돌려준 함수 = 편집기가 떠날 때 풂
* (안 풀면 노선 저장 뒤 다시 선 페이지에서 옛 편집기 「고친 것 있음」이 [저장] · [확정]마다 노선 저장을 다시 돌림 · 445-2). */
export function bindRouteApply(params: RouteApplyParams): () => void {
// 손으로 고친 노선 = 설계 지형 그대로 — 지형 바꿈은 추천 [적용]만(509-5)
// 손으로 고친 노선 = 설계 지형 그대로 — 지형 바꿈 = 도구줄 지형 칩(626-1 · `_Terrain`)
const settings = () => ({ ...params.snap(), terrain: null });
const source: RouteSource = {
pending: async () => {
@@ -21,7 +21,7 @@ import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoL
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
* 도구줄 차례 = 공용 표(624-3 · `A00_Common/map/map_toolbar` 「b05」) — [?] | ↶ ↷ | 3D ⟲ ⟳ | 1m 등고선 · 측점 · 기울기 ·
* 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 예정노선 그리기(626-3) | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` ·
* 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 예정노선 변경 · 원본(626-3) | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` ·
* 끝 = 그래픽 카드 단추) · 범례 = 상태 글판 위. 단추는 공용 지도 도구줄(568-2 · `ui_template_map_tools`) 아이콘 — 긴 설명은 마우스 글.
*
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
@@ -66,6 +66,8 @@ export interface RouteEditChrome {
stationSnapButton: HTMLButtonElement;
/** 예정노선 그리기 켬끔(626-3 · 배선 = `B05_Profile_UI_ExpectedDraw`). */
expectedDrawButton: HTMLButtonElement;
/** 예정노선(원본 · 파일입력) 옅은 점선 켬끔(626-3 · 535-1). */
expectedOriginalButton: HTMLButtonElement;
/** 설계 지형 칩(626-1) — 지형 읽은 뒤 `load`. */
terrain: RouteTerrain;
/** 추천 노선 미리보기(626-1) — 그리기 안에서 `draw`. */
@@ -291,6 +293,7 @@ export function createRouteEditChrome(
minorContourButton,
stationSnapButton,
expectedDrawButton: mapTools.button("expected-draw")!,
expectedOriginalButton: mapTools.button("expected-original")!,
terrain,
preview,
layers,
@@ -508,3 +508,9 @@ html.is-bm-route .b05-routeedit__tools {
flex-wrap: wrap;
gap: var(--spacing-4);
}
/* 626-3 예정노선 변경 속성 판(D03 고름 판 그대로) — 도구 줄 아래 오른쪽 · 횡단 판 비킴 */
.b05-routeedit .b05-routeedit__expected-props {
top: 132px;
right: calc(var(--spacing-12) + var(--b05-side-open, 0px));
}
@@ -1,116 +0,0 @@
/* =============================================================================
* B05_Profile_ExpectedDraw.ts
* B05 평면 예정노선 그리기 셈(PLAN 626-3 · 요구 530-11 · 결정 D10) — 다음 점 고르기 · 구간 다시 긋기 갈음 · 선 위 자리.
* 다음 점 = D03 긋기와 같은 방식 넷(자유지정 · 길이 고정 · 기울기 고정 · 길이·기울기 고정 부채꼴 · Shift 사이 기울기) —
* 셈은 D03 `logic/D03_PlanAnalysis_Draw` · `logic/D03_PlanAnalysis_Fan` 그대로(파이썬 짝 없음 · 화면 고르기 전용).
* 구간 다시 긋기 = 선 위 두 자리(m) 사이를 그은 점으로 갈음(거꾸로 그었으면 뒤집어 끼움).
* 서버는 고친 예정노선을 다시 추적(`POST /route/follow`) · [저장] 때 `expected_route_edit.csv`.
* DOM · fetch · sessionStorage 없음.
* ========================================================================== */
import {
distanceAlong,
fixedLengthPoint,
fixedSlopePoint,
lineLength,
pointAtDistance,
segmentLength,
sliceLine,
type HeightAt,
type Vertex,
} from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw";
import {
compassFan,
fanBetween,
fanPick,
type Fan,
} from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan";
/** 긋기 방식 — 공용 방식 칸(`A00_Common/map/map_draw_kind` `DrawConstraint`)과 같은 꼴(logic 은 화면 파일을 안 부름). */
export type DrawConstraint = "free" | "length" | "slope" | "both";
/** 같은 점으로 볼 거리(m). */
const SAME_M = 1e-6;
/** 구간 다시 긋기 첫 구간 정방향 — 시작 자리 앞 이만큼(m) 선 위 점을 앞 구간 시작으로 봄. */
const LEAD_M = 5;
export interface ExpectedPick {
/** 찍을 점 — 없으면 null(자리 없음). */
point: Vertex | null;
/** 길이·기울기 고정 부채꼴(그 방식일 때만). */
fan: Fan | null;
note: string | null;
/** 길이만 지킨 점(Shift 사이 고르기 · 완만한 땅). */
lengthOnly: boolean;
}
const one = (value: number): string => value.toFixed(1);
/** 다음 점 — `prev` = 앞 구간 시작(정방향 · 없으면 null) · `shift` = 부채꼴 사이 고르기. */
export function pickNext(
kind: DrawConstraint,
from: Vertex,
cursor: Vertex,
lengthM: number,
slopePct: number,
heightAt: HeightAt,
prev: Vertex | null,
shift = false,
): ExpectedPick {
if (kind === "length")
return {
point: fixedLengthPoint(from, cursor, lengthM),
fan: null,
note: null,
lengthOnly: false,
};
if (kind === "slope") {
const point = fixedSlopePoint(from, cursor, slopePct, heightAt);
const note = point ? null : `이 방향엔 ±${one(Math.abs(slopePct))} % 자리 없음`;
return { point, fan: null, note, lengthOnly: false };
}
if (kind === "both") {
const fan = compassFan(from, lengthM, slopePct, heightAt, prev, cursor);
const point = shift
? fanBetween(fan, from, cursor, lengthM)
: fanPick(fan, from, cursor, lengthM);
const note = point
? null
: `반지름 ${one(lengthM)} m 원 위에 ±${one(Math.abs(slopePct))} % 자리 없음`;
return { point, fan, note, lengthOnly: shift || fan.flat };
}
return { point: cursor, fan: null, note: null, lengthOnly: false };
}
/** 선 위 가장 가까운 자리(누가 m · 점). */
export function nearestOnLine(
line: ReadonlyArray<Vertex>,
point: Vertex,
): { atM: number; point: Vertex } {
const atM = distanceAlong(line, point);
return { atM, point: pointAtDistance(line, atM) };
}
/** 구간 다시 긋기 첫 구간의 앞 구간 시작 — 선 위 시작 자리 조금 앞(시점이면 null). */
export function leadBefore(line: ReadonlyArray<Vertex>, atM: number): Vertex | null {
return atM > SAME_M ? pointAtDistance(line, Math.max(0, atM - LEAD_M)) : null;
}
/** 이웃한 같은 점 뺌. */
function dedupe(points: Vertex[]): Vertex[] {
return points.filter((point, i) => !i || segmentLength(points[i - 1], point) > SAME_M);
}
/** 구간 다시 긋기 — 선 `fromM` ~ `toM` 사이를 `through`(그은 속 점 · 시작 · 끝 자리 뺌)로 갈음. */
export function respliceLine(
line: ReadonlyArray<Vertex>,
fromM: number,
toM: number,
through: ReadonlyArray<Vertex>,
): Vertex[] {
const total = lineLength(line);
const forward = fromM <= toM;
const [lo, hi] = forward ? [fromM, toM] : [toM, fromM];
const middle = forward ? [...through] : [...through].reverse();
return dedupe([...sliceLine(line, 0, lo), ...middle, ...sliceLine(line, hi, total)]);
}
@@ -68,6 +68,18 @@ const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 —
export type DrawMode = "idle" | "draw";
/** 한 선(626-3) 막힘 까닭 — 선 위 · 지점 = 갈래 · 선이 있는데 빈 땅 = 둘째 선 · 됨 = null. */
export function oneLineBlock(
kind: "tip" | "point" | "line" | "empty",
network: PlanNetwork,
): string | null {
if (kind === "line" || kind === "point")
return "예정노선은 한 선입니다 — 선 끝에서 이어 긋습니다.";
if (kind === "empty" && network.lines.some((line) => line.vertices.length > 1))
return "예정노선은 한 선입니다 — 지운 뒤 새로 긋습니다.";
return null;
}
export interface DrawPreview {
point: Vertex;
lengthM: number;
@@ -90,6 +102,8 @@ export interface PlanDrawParams {
commit: (next: PlanNetwork) => void;
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
onMode: () => void;
/** 한 선만(B05 평면 예정노선 · 626-3) — 갈래 · 둘째 선 막음 · 연도 구역 마커 숨김. 없음 = D03 그대로. */
oneLine?: boolean;
}
export interface PlanDraw {
@@ -145,6 +159,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
});
const drawPanel = createDrawPanel({
kind: kindFields,
marks: !params.oneLine,
onDraw: () => setMode(mode === "draw" ? "idle" : "draw"),
});
const panel = drawPanel.root;
@@ -366,6 +381,11 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
function begin(px: number, py: number, map: ScreenMap, free = false): boolean {
const network = params.network();
const target = aim(px, py, map);
const single = params.oneLine ? oneLineBlock(target.kind, network) : null;
if (single) {
showToast(single, "info");
return false;
}
strokeStart = network;
if (target.kind === "tip") {
if (target.head) {
@@ -11,6 +11,7 @@
import { createButton } from "@ui/ui_template_elements";
import { YEAR_MARK_DRAG } from "./D03_PlanAnalysis_UI_Map";
import type { DrawKindFields } from "../A00_Common/map/map_draw_kind";
import "./D03_PlanAnalysis_UI_DrawParts.css";
// 방식 칸 = 공용(`A00_Common/map/map_draw_kind` · 626-3 · B05 평면 예정노선 그리기도 씀)
export {
@@ -30,7 +31,12 @@ export interface DrawPanel {
}
/** 좌측 「그리기」 칸 — [긋기] · 연도 구역 마커(끌기) · 방식 칸. */
export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void }): DrawPanel {
export function createDrawPanel(opts: {
kind: DrawKindFields;
onDraw: () => void;
/** 연도 구역 마커 단추(없음 = 보임 · B05 평면 예정노선 = 숨김 626-3). */
marks?: boolean;
}): DrawPanel {
const drawButton = createButton({
label: "긋기",
variant: "filled",
@@ -45,6 +51,7 @@ export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void
});
markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다";
markButton.dataset.role = "draw-year-mark";
markButton.hidden = opts.marks === false;
markButton.draggable = true;
markButton.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
@@ -0,0 +1,50 @@
/* D03 긋기 칸 · 고름 속성 판 꼴(626-3) — 긋기 칸(`UI_DrawPanel`) · 속성 판(`UI_Props`)이 불러옴 ·
* 예정노선(D03) · B05 평면 예정노선 변경이 같이 씀(자리만 페이지 몫). */
.d03-plan__row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: flex-end;
margin-bottom: 6px;
}
.d03-plan__row > .ui-field {
flex: 1 1 6rem;
margin: 0;
}
.d03-plan__draw {
display: flex;
flex-direction: column;
gap: 6px;
}
.d03-plan__props {
top: calc(var(--spacing-8) * 6);
right: var(--spacing-8);
width: 300px;
max-height: calc(100% - var(--spacing-8) * 8);
}
.d03-plan__props[hidden] {
display: none;
}
.d03-plan__props .ui-float-panel__body {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--spacing-8);
overflow: auto;
}
.d03-plan__props-rows {
display: flex;
flex-direction: column;
align-items: stretch;
}
.d03-plan__props-rows > .ui-btn {
justify-content: flex-start;
}
+7 -3
View File
@@ -10,7 +10,7 @@
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { oneLineBlock, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points";
@@ -24,6 +24,8 @@ export interface PlanMenuParams {
editable: () => boolean;
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
respan: (line: string, from: number, to: number) => boolean;
/** 한 선만(B05 평면 예정노선 · 626-3) — 갈래 시작 · 둘째 선 · 연도 구역 나눔 항목 뺌. 없음 = D03 그대로. */
oneLine?: boolean;
}
export interface PlanMenu {
@@ -45,12 +47,14 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu {
const pin = marks.pinAt(px, py, map);
const at = pin === null ? edit.hit(px, py, map) : null;
const start = (x: number, y: number): void => {
const aim = draw.aim(x, y, map);
if (params.oneLine && oneLineBlock(aim, params.network())) return;
const label = {
tip: "이어 긋기",
point: "지점에서 긋기 시작",
line: "갈래 시작",
empty: "여기서 긋기 시작",
}[draw.aim(x, y, map)];
}[aim];
items.push([label, () => draw.startAt(x, y, map)]);
};
if (draw.mode() === "draw" && draw.stroke())
@@ -79,7 +83,7 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu {
} else if (at?.segment) {
const seg = at.segment;
start(px, py);
items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]);
if (!params.oneLine) items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]);
items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]);
} else start(px, py);
if (pin === null && !at?.node && !at?.mark && !marks.full())
@@ -43,6 +43,7 @@ import {
import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import { selectionLength } from "./D03_PlanAnalysis_UI_Select";
import "./D03_PlanAnalysis_UI_DrawParts.css";
/** 조각 연도 고를 수 — 사업연도 시작부터. */
const YEAR_SPAN = 10;
@@ -66,6 +67,8 @@ export interface PlanPropsParams {
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
respan: (line: string, from: number, to: number) => boolean;
/** 한 선만(B05 평면 예정노선 · 626-3) — 연도 · 구간 줄 · [지점으로] 뺌. 없음 = D03 그대로. */
oneLine?: boolean;
}
export interface PlanProps {
@@ -210,7 +213,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
const toPoint = button("지점으로", "props-to-point", () =>
params.addPoint([point[0], point[1]]),
);
toPoint.hidden = params.points().some((p) => samePoint(p, point));
toPoint.hidden = !!params.oneLine || params.points().some((p) => samePoint(p, point));
return {
title: `${nameOf(line)} · 노드 ${vertex + 1}`,
parts,
@@ -237,7 +240,8 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` });
rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) });
}
rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") });
if (!params.oneLine)
rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") });
const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)];
if (single)
actions.unshift(
@@ -263,8 +267,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
{ label: "길이", value: meters(length) },
{ label: "노드", value: `${vertices.length}개` },
]),
yearField(pieces),
pieceRows(network, pieces),
...(params.oneLine ? [] : [yearField(pieces), pieceRows(network, pieces)]),
],
actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))],
};
@@ -88,25 +88,6 @@
margin: 0 0 6px;
}
.d03-plan__row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: flex-end;
margin-bottom: 6px;
}
.d03-plan__row > .ui-field {
flex: 1 1 6rem;
margin: 0;
}
.d03-plan__draw {
display: flex;
flex-direction: column;
gap: 6px;
}
.d03-plan__pieces {
display: flex;
flex-direction: column;
@@ -138,35 +119,6 @@
}
/* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */
.d03-plan__props {
top: calc(var(--spacing-8) * 6);
right: var(--spacing-8);
width: 300px;
max-height: calc(100% - var(--spacing-8) * 8);
}
.d03-plan__props[hidden] {
display: none;
}
.d03-plan__props .ui-float-panel__body {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--spacing-8);
overflow: auto;
}
.d03-plan__props-rows {
display: flex;
flex-direction: column;
align-items: stretch;
}
.d03-plan__props-rows > .ui-btn {
justify-content: flex-start;
}
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
.d03-plan__points-head {
display: flex;
+41 -97
View File
@@ -1,116 +1,31 @@
"""626-3 B05 평면 예정노선 그리기 → 다시 추적 → 편집기 한 걸음 → [저장](요구 530 · 531 · 결정 D3 · D4 · D9 · D10).
"""626-3 B05 평면 예정노선 변경 → 다시 추적 → 편집기 한 걸음 → [저장](요구 530 · 531 · 535-1 · 536 · 결정 D3 · D4 · D9).
- 셈(TS `logic/B05_Profile_ExpectedDraw.ts` · tsc 로 옮겨 Node) — 구간 다시 긋기 갈음(앞뒤 그대로 · 거꾸로 그음 뒤집음 ·
시점 · 종점 자리) · 다음 점 방식 넷(D03 셈 그대로) · 선 위 자리 · 첫 구간 정방향 앞 점.
- 예정노선 페이지(D03) 긋기 · 고르기 · 메뉴 · 속성 판 함수를 그대로 부름(`oneLine` = 한 선 · 갈래 · 연도 · 지점 막음/숨김) ·
D03 덧 인자는 기본값 = 지금 그대로.
- 지킴(글 대조) — 다시 추적 = `/route/follow` 만(저장 0) · `/route/replan` · `as_initial` · `expected_route.csv` 안 부름 ·
결과 = 편집기 `restoreSnapshot(…, true)` 한 걸음 · [저장] = 손 편집 같은 길(`bindRouteApply`) + 초안 `expected` ·
걸음 사진 · 노선 초안에 고친 예정노선 실림(되돌리기 · 세션 되살림) · 같은 선 판정에 예정노선 · 긋기 방식 칸 공용 ·
판 켜면 캔버스 누름을 먼저 받음(재기 · 계획선 고르기 막음) · RouteEdit 700 줄 안.
걸음 사진 · 노선 초안에 고친 예정노선 실림(되돌리기 · 세션 되살림) · 같은 선 판정에 예정노선 · 켠 동안 캔버스 누름을
먼저 받음(재기 · 계획선 고르기 막음) · 원본 켬끔 · RouteEdit 700 줄 안.
./venv/Scripts/python.exe -m pytest resources/tester/test_626_3_expected_edit.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
TS_FILES = [
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts",
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Fan.ts",
B05 / "logic" / "B05_Profile_ExpectedDraw.ts",
]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as d from "./B05_Profile/logic/B05_Profile_ExpectedDraw.js";
const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]];
const plane = (x, y) => 0.65 * x;
const out = {};
out.forward = d.respliceLine(line, 12, 28, [[15, 5], [25, 5]]);
out.backward = d.respliceLine(line, 28, 12, [[25, 5], [15, 5]]);
out.head = d.respliceLine(line, 0, 20, [[10, 3]]);
out.tail = d.respliceLine(line, 30, 40, []);
out.near = d.nearestOnLine(line, [17, 4]);
out.leadStart = d.leadBefore(line, 0);
out.lead = d.leadBefore(line, 12);
out.free = d.pickNext("free", [0, 0], [3, 4], 20, 9, plane, null);
out.length = d.pickNext("length", [0, 0], [3, 4], 20, 9, plane, null);
const both = d.pickNext("both", [0, 0], [0, 50], 20, 9, plane, [0, -20]);
out.both = { point: both.point, nodes: both.fan.nodes.length, arcs: both.fan.arcs.length, lengthOnly: both.lengthOnly };
const between = d.pickNext("both", [0, 0], [-3, 50], 20, 9, plane, [0, -20], true);
out.between = { point: between.point, lengthOnly: between.lengthOnly };
out.none = d.pickNext("both", [0, 0], [0, 50], 20, 9, () => NaN, null);
out.slopeNone = d.pickNext("slope", [0, 0], [0, 50], 20, 9, () => 0, null);
writeFileSync(process.argv[2], JSON.stringify(out));
"""
@pytest.fixture(scope="module")
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp = tmp_path_factory.mktemp("expected_draw")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), *map(str, TS_FILES), "--outDir", str(out), "--rootDir", str(ROOT)]
+ ["--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler"]
+ ["--ignoreConfig"],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
D03 = ROOT / "D03_PlanAnalysis"
def _src(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_구간_다시_긋기_앞뒤_그대로_가운데만_갈음(r):
assert r["forward"] == [[0, 0], [10, 0], [12, 0], [15, 5], [25, 5], [28, 0], [30, 0], [40, 0]]
assert r["backward"] == r["forward"] # 거꾸로 그어도 같은 선
assert r["head"] == [[0, 0], [10, 3], [20, 0], [30, 0], [40, 0]] # 시점부터 · 같은 점 하나
assert r["tail"] == [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]] # 속 점 없음 = 곧은 갈음
def test_선_위_자리_첫_구간_앞_점(r):
assert r["near"] == {"atM": 17, "point": [17, 0]}
assert r["leadStart"] is None and r["lead"] == [7, 0]
def test_다음_점_방식_넷_D03_셈_그대로(r):
assert r["free"]["point"] == [3, 4] and r["free"]["fan"] is None
assert [round(v, 6) for v in r["length"]["point"]] == [12, 16]
both = r["both"]
assert both["nodes"] == 4 and both["arcs"] == 2 and not both["lengthOnly"]
assert abs((both["point"][0] ** 2 + both["point"][1] ** 2) ** 0.5 - 20) < 1e-6
between = r["between"]
assert between["lengthOnly"] and abs(sum(v * v for v in between["point"]) ** 0.5 - 20) < 1e-6
assert r["none"]["point"] is None and "자리 없음" in r["none"]["note"]
assert r["slopeNone"]["point"] is None and "자리 없음" in r["slopeNone"]["note"]
def test_다시_추적_follow_만_저장_0_초기값_추천_안_건드림():
ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts")
ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts").split("*/", 1)[1] # 머리 글 뺀 몸
assert "/route/follow" in ui and "askConfirm(" in ui and "flattenServerPlan(" in ui
for banned in (
"/route/replan",
@@ -152,18 +67,47 @@ def test_걸음_사진_초안_같은_선에_예정노선():
assert "expected: list[tuple[float, float]] | None = None" in server
def test_방식_칸_공용_판_켜면_캔버스_누름_먼저_받음():
def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선():
ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts")
assert 'from "../A00_Common/map/map_draw_kind"' in ui and "createDrawKindFields(" in ui
assert "paintPreview(" in ui and "pickNext(" in ui # D03 그림 · 셈 그대로
for call in ("createPlanDraw({", "createPlanEdit(", "createPlanMenu({", "createPlanProps({"):
assert call in ui, call
assert ui.count("oneLine: true,") == 3 # 긋기 · 메뉴 · 속성 판
assert "draw.key(event) || editor.key(event)" in ui # 같은 단축키
assert (
'keyIs(event, "plan.undo") || keyIs(event, "plan.redo")' in ui
) # 되돌리기 = 페이지 사진첩
assert '"pointerdown",' in ui and "event.stopImmediatePropagation()" in ui
assert re.search(
r"\n true,\n \);", ui
) # 잡기 단계(capture) — 재기 · 계획선 고르기보다 먼저
assert 'keyIs(event, "plan.drawKind")' in ui and 'keyIs(event, "plan.drawFinish")' in ui
assert "createFloatPanel(" in ui and 'createElement("button")' not in ui
assert "event.buttons & 6" in ui # 오른쪽 · 가운데 끌기 = 지도 이동 그대로
assert 'createElement("button")' not in ui
# D03 덧 인자 — 기본값 = 지금 그대로
draw = _src(D03 / "D03_PlanAnalysis_UI_Draw.ts")
assert (
"oneLine?: boolean;" in draw
and "params.oneLine ? oneLineBlock(target.kind, network) : null" in draw
)
assert "marks: !params.oneLine," in draw
assert "markButton.hidden = opts.marks === false;" in _src(
D03 / "D03_PlanAnalysis_UI_DrawPanel.ts"
)
menu = _src(D03 / "D03_PlanAnalysis_UI_Menu.ts")
assert "if (params.oneLine && oneLineBlock(aim, params.network())) return;" in menu
assert 'if (!params.oneLine) items.push(["여기서 연도 구역 나눔"' in menu
props = _src(D03 / "D03_PlanAnalysis_UI_Props.ts")
assert "!!params.oneLine ||" in props and "...(params.oneLine ? [] :" in props
tools = _src(ROOT / "A00_Common" / "map" / "map_toolbar.ts")
assert re.search(r'key: "expected-draw",\s+does: "[^"]+",\s+pages: B05', tools)
assert re.search(r'key: "expected-original", does: "[^"]+", pages: B05', tools)
render = _src(B05 / "B05_Profile_UI_RouteEdit_Render.ts")
# 맨 위 = 구조물 마커 뒤 · 회전 끝 앞
assert render.index("scene.overAll?.(context);") < render.index("context.restore(); // 회전 끝")
def test_원본_예정노선_안_덮음_옅은_점선_켬끔():
ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts")
assert "showOriginal && original.length > 1 && line() !== original" in ui
assert "expected_route.csv" in ui.split("*/")[0] # 머리 = 원본 안 덮음 적음
for banned in ("write_route_csv", 'expected_route.csv"', "/route/initial"):
assert banned not in ui.split("*/", 1)[1], banned
+7 -1
View File
@@ -34,7 +34,13 @@ export const MAP_TOOLS = {
"expected-draw": {
icon: "pencil",
title:
"예정노선 그리기 — 예정노선을 새로 긋기 · 구간 다시 긋기 · 지움 → 평면선을 예정노선 따라 통째 다시 추적 (긋는 동안 계획선은 안 집힘)",
"예정노선 변경 — 켜면 예정노선이 맨 위 · 예정노선 페이지와 같은 긋기 · 고르기 · 고치기 → 평면선을 예정노선 따라 통째 다시 추적 (켠 동안 계획선 · 구조물은 안 집힘)",
toggle: true,
},
"expected-original": {
icon: "lineDashed",
title:
"예정노선(원본) — 파일로 올린 예정노선을 옅은 점선으로 보이기 · 숨기기 (고친 예정노선과 다를 때만 보임)",
toggle: true,
},
} satisfies Record<string, { icon: IconName; title: string; toggle?: boolean }>;