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:
@@ -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 },
|
||||
],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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분대)
|
||||
* 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
Reference in New Issue
Block a user