Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-11-01 11:44:11 +09:00
11 changed files with 591 additions and 339 deletions
+15 -80
View File
@@ -5,8 +5,8 @@
* 긋기 칸 `createPlanDraw`(방식 칸 · 부채꼴 · +/− 색 · Shift · Tab · Enter · Esc · Backspace · 선 끝 이어 긋기) ·
* 고르기 겹 `createPlanEdit`(노드 → 선 → 구역 → 전체 · 끌어 고르기 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete) ·
* 우클릭 메뉴 `createPlanMenu`(여기서 긋기 시작 · 이어 긋기 · 노드 지움 · 다시 긋기) · 속성 판 `createPlanProps`([다시 긋기] ·
* [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 한 선(`oneLine`) = 갈래 · 둘째 선 · 연도 · 지점 막음/숨김.
* 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 ·
* [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 숨김 = 기능 표 「b05」(`D03_PlanAnalysis_UI_Features` · 갈래 · 둘째 선 · 연도 · 지점).
* 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음(키트 `bindPlanInput` capture) = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 ·
* 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만).
* 한 번 고침(긋기 끝 · 놓음 · 지움) → (켠 뒤 처음만) 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) →
* `POST /route/follow`(저장 0) → `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] = 손 편집과
@@ -27,9 +27,9 @@ import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sam
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 { bindPlanInput } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit";
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 { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
@@ -47,19 +47,6 @@ 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;
@@ -120,12 +107,11 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
// ── D03 긋기 · 고르기 · 메뉴 · 속성 판(같은 함수) ─────────
const draw = createPlanDraw({
base: () => [],
page: "b05",
network: () => network,
heightAt: () => z,
commit,
onMode,
oneLine: true,
});
const show = (next: PlanNetwork): void => {
network = next;
@@ -146,28 +132,22 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
};
const editor = createPlanEdit(editParams);
const props = createPlanProps({
page: "b05",
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({
page: "b05",
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");
@@ -314,65 +294,20 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
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);
};
const claim = (event: Event): void => {
event.stopImmediatePropagation();
event.preventDefault();
};
canvas.addEventListener(
"pointerdown",
(event) => {
if (!open || event.button !== 0) return;
claim(event);
const [px, py] = at(event);
if (!busy && layers.some((layer) => layer.press?.(px, py, map(), 0, event.shiftKey)))
canvas.setPointerCapture(event.pointerId); // 끌기가 밖으로 나가도 놓음을 받음
params.redraw();
bindPlanInput({
canvas,
at: (event) => {
const rect = canvas.getBoundingClientRect();
return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top);
},
true,
);
canvas.addEventListener(
"pointermove",
(event) => {
if (!open || event.buttons & 6) return; // 오른쪽 · 가운데 끌기 = 지도 이동 그대로
event.stopImmediatePropagation();
const [px, py] = at(event);
const screen = map();
for (const layer of layers) layer.hover?.(px, py, screen, event.shiftKey);
params.redraw();
},
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", () => {
for (const layer of layers) layer.leave?.();
if (open) params.redraw();
screen: map,
layers: () => layers,
redraw: params.redraw,
capture: { active: () => open, busy: () => busy },
});
const onKey = (event: KeyboardEvent): void => {
+12 -11
View File
@@ -62,14 +62,15 @@ import {
DRAW_DEFAULT_SLOPE_PCT,
type DrawConstraint,
} from "./D03_PlanAnalysis_UI_DrawPanel";
import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」.";
export type DrawMode = "idle" | "draw";
/** 한 선(626-3) 막힘 까닭 — 선 위 · 지점 = 갈래 · 선이 있는데 빈 땅 = 둘째 선 · 됨 = null. */
export function oneLineBlock(
/** 갈래 숨긴 페이지(기능 표 「branch」 · B05 = 한 선) 막힘 까닭 — 선 위 · 지점 = 갈래 · 선이 있는데 빈 땅 = 둘째 선 · 됨 = null. */
export function lineLimitBlock(
kind: "tip" | "point" | "line" | "empty",
network: PlanNetwork,
): string | null {
@@ -95,15 +96,15 @@ export interface DrawPreview {
}
export interface PlanDrawParams {
/** 지점(긋기에 붙는 점 · 591-2). */
base: () => Vertex[];
/** 기능 표 페이지(`D03_PlanAnalysis_UI_Features` — 갈래 · 연도 마커 숨김). */
page: PlanPage;
/** 지점(긋기에 붙는 점 · 591-2) — 없음 = 지점 없는 페이지. */
base?: () => Vertex[];
network: () => PlanNetwork;
heightAt: () => (x: number, y: number) => number;
commit: (next: PlanNetwork) => void;
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
onMode: () => void;
/** 한 선만(B05 평면 예정노선 · 626-3) — 갈래 · 둘째 선 막음 · 연도 구역 마커 숨김. 없음 = D03 그대로. */
oneLine?: boolean;
}
export interface PlanDraw {
@@ -149,6 +150,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
through: Vertex[];
records: NodeRecord[];
} | null = null;
const basePoints = (): Vertex[] => params.base?.() ?? [];
const spanLine = (): number =>
span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1;
@@ -159,7 +161,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
});
const drawPanel = createDrawPanel({
kind: kindFields,
marks: !params.oneLine,
page: params.page,
onDraw: () => setMode(mode === "draw" ? "idle" : "draw"),
});
const panel = drawPanel.root;
@@ -186,8 +188,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null {
const network = params.network();
const head = active === null ? undefined : network.lines[active]?.vertices[0];
const offers = params
.base()
const offers = basePoints()
.filter((point) => !head || !samePoint(point, head))
.map((point) => ({ point, note: "지점에 붙음" }));
network.lines.forEach((line, i) => {
@@ -351,7 +352,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
| { kind: "line"; hit: NonNullable<ReturnType<typeof lineAt>> }
| { kind: "empty"; point: Vertex } {
const network = params.network();
const points = params.base();
const points = basePoints();
const gap = (point: Vertex): number => screenGap(map, point, px, py);
const nearest = <T extends { gap: number }>(items: T[]): T | undefined =>
items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0];
@@ -381,7 +382,7 @@ 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;
const single = featureOn(params.page, "branch") ? null : lineLimitBlock(target.kind, network);
if (single) {
showToast(single, "info");
return false;
@@ -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 { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features";
import "./D03_PlanAnalysis_UI_DrawParts.css";
// 방식 칸 = 공용(`A00_Common/map/map_draw_kind` · 626-3 · B05 평면 예정노선 그리기도 씀)
@@ -34,8 +35,8 @@ export interface DrawPanel {
export function createDrawPanel(opts: {
kind: DrawKindFields;
onDraw: () => void;
/** 연도 구역 마커 단추(없음 = 보임 · B05 평면 예정노선 = 숨김 626-3). */
marks?: boolean;
/** 기능 표 페이지 — 연도 구역 마커 단추 = 「years」 사용일 때만. */
page: PlanPage;
}): DrawPanel {
const drawButton = createButton({
label: "긋기",
@@ -51,7 +52,7 @@ export function createDrawPanel(opts: {
});
markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다";
markButton.dataset.role = "draw-year-mark";
markButton.hidden = opts.marks === false;
markButton.hidden = !featureOn(opts.page, "years");
markButton.draggable = true;
markButton.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
@@ -0,0 +1,109 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Features.ts
* 노선 그리기 기능 표(PLAN 635-1 · 요구서 546 4장) — 예정노선(d03) · B05 평면이 같은 그리기 부품을 쓰고 이 표로만 가름.
* 한 줄 = 기능 열쇠 · 이름 · 페이지마다 쓰임(use 사용 · view 보기만 · hide 숨김) · 까닭.
* 숨김을 바꿀 때 = 이 표만 고침(부품 · 키트가 `featureUse` 로 읽음) · 꼴 = 지도 도구줄 표(`map_toolbar`).
* ========================================================================== */
import type { MapPage } from "../A00_Common/map/map_layer_list";
export type PlanPage = MapPage;
export type FeatureUse = "use" | "view" | "hide";
export type PlanFeature =
| "site"
| "draw"
| "extend"
| "select"
| "props"
| "history"
| "terrain"
| "road-upload"
| "branch"
| "years"
| "points"
| "plan-work";
export interface PlanFeatureRow {
key: PlanFeature;
name: string;
pages: Record<PlanPage, FeatureUse>;
why?: string;
}
export const PLAN_FEATURES: readonly PlanFeatureRow[] = [
{ key: "site", name: "주소 · 장소 찾기(B05 = 지도 이동만)", pages: { d03: "use", b05: "use" } },
{
key: "draw",
name: "긋기 네 방식 · 길이 · 기울기 · Tab · Shift · 부채꼴 · ± 색",
pages: { d03: "use", b05: "use" },
},
{
key: "extend",
name: "이어 긋기 · 다시 긋기 · 빈 땅 새 선(선 없을 때)",
pages: { d03: "use", b05: "use" },
},
{
key: "select",
name: "고름 노드 → 선 → 전체 · 끌어 고르기 · 노드 끌기 · 꺾임점 · 지움",
pages: { d03: "use", b05: "use" },
},
{
key: "props",
name: "속성 판 지반고 · 측점 · 길이 · 기울기 · 경위도(B05 = 서버 변환)",
pages: { d03: "use", b05: "use" },
},
{
key: "history",
name: "되돌리기 · 다시하기 · 길이측정 · 3D(B05 = B05 것)",
pages: { d03: "use", b05: "use" },
},
{
key: "terrain",
name: "기준점 · 지형 받기 · 다시 받기 · 10 km 알림",
pages: { d03: "use", b05: "hide" },
why: "B 지형 = 전처리 확정",
},
{
key: "road-upload",
name: "임도(업로드) 올리기 · 묶음 목록",
pages: { d03: "use", b05: "hide" },
why: "서버 B 400 · 받은 자료로 보임",
},
{
key: "branch",
name: "갈래 · 둘째 선 · 다른 선과 잇기 · 가운데 토막 지움(끝 토막 지움은 사용)",
pages: { d03: "use", b05: "hide" },
why: "B 계획선 한 줄",
},
{
key: "years",
name: "연도 구역 마커 · 구역 · 연도 고침 · 선 이름",
pages: { d03: "use", b05: "view" },
why: "받은 몫 고정",
},
{
key: "points",
name: "지점 칸 · 핀 · 지점에서 긋기",
pages: { d03: "use", b05: "hide" },
why: "B 저장 자리 없음",
},
{
key: "plan-work",
name: "등록 기준점 · 확정 · 업체 지정 · 배포 · 산림청 [저장]",
pages: { d03: "use", b05: "hide" },
why: "예정노선 업무",
},
];
/** 그 페이지 쓰임 — 표에 없는 열쇠 = 숨김. */
export function featureUse(page: PlanPage, key: PlanFeature): FeatureUse {
return PLAN_FEATURES.find((row) => row.key === key)?.pages[page] ?? "hide";
}
/** 고칠 수 있게 씀(보기만 · 숨김 = 거짓). */
export const featureOn = (page: PlanPage, key: PlanFeature): boolean =>
featureUse(page, key) === "use";
/** 보임(사용 · 보기만). */
export const featureShown = (page: PlanPage, key: PlanFeature): boolean =>
featureUse(page, key) !== "hide";
+293
View File
@@ -0,0 +1,293 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Kit.ts
* 노선 그리기 키트(PLAN 635-1 · 요구서 546 3장 「노선 그리기」) — 예정노선(d03) · B05 평면이 같은 한 벌을 엮음.
*
* 부품 = 긋기 `createPlanDraw` · 고르기 `createPlanEdit` · 속성 판 `createPlanProps` · 구역·연도 `createPlanPieces` ·
* 지점 `createPlanPoints` · 우클릭 메뉴 `createPlanMenu` — 숨김 = 기능 표(`D03_PlanAnalysis_UI_Features`)만 읽음.
* 내놓음 = 왼쪽 칸(위치 · 지점 · 그리기 · 구역·연도 중 표가 켠 것) · 겹 차례(고정) · 키 · `sync()`.
* 저장 · 초기화 · 되돌리기 = 페이지 몫(`commit` 한 번 = 한 걸음 · 캐시 = 페이지).
* 지도 입력 나눠 주기 `bindPlanInput` 하나 — 예정노선 지도(겹 몫 그대로) · B05 평면(`capture` = 켠 동안 먼저 받음).
* ========================================================================== */
import { perfTime } from "@ui/ui_template_gl_map";
import { createPanelCard } from "@ui/ui_template_collapsible";
import type { HeightAt } from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { createPlanDraw, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { createPlanEdit, type PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { featureOn, featureShown, type PlanPage } from "./D03_PlanAnalysis_UI_Features";
import { YEAR_MARK_DRAG, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import { createPlanMenu, type PlanMenu } from "./D03_PlanAnalysis_UI_Menu";
import { createPlanPieces, type PlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanPoints, type PlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanProps, type PlanProps } from "./D03_PlanAnalysis_UI_Props";
/** 지점 통로(표 「points」 켠 페이지) — 지점 · 이름 · 좌표계 · 보기 가운데 · 한 걸음 · 보여 주기만. */
export interface PlanKitPoints {
list: () => Vertex[];
names: () => string[];
epsg: () => number;
center: () => Vertex | null;
commit: (points: Vertex[], next: PlanNetwork, names?: string[]) => void;
show: (points: Vertex[], next: PlanNetwork) => void;
}
export interface PlanKitParams {
page: PlanPage;
network: () => PlanNetwork;
/** 한 걸음(되돌리기 · 캐시 = 페이지). */
commit: (next: PlanNetwork) => void;
/** 끄는 중 보여 주기만(걸음 아님). */
show: (next: PlanNetwork) => void;
heightAt: () => HeightAt;
/** 작업 좌표 → 경위도(없음 = 속성 판 경위도 칸 숨김). */
lonLat?: (x: number, y: number) => [number, number] | null;
editable: () => boolean;
/** 고르기 · 핀이 누름을 받을 때(긋기 쉼 말고 더 — 예정노선 = 재기 끔). */
idle?: () => boolean;
onMode: () => void;
onSelect: () => void;
/** 사업연도 시작(연도 고름). */
yearStart?: () => number;
points?: PlanKitPoints;
/** 페이지 키(되돌리기 · 다시하기 · 재기 끔) — 긋기 · 고르기보다 먼저. */
onKey?: (event: KeyboardEvent) => boolean;
cards: {
/** 위치 칸 몸 · 제목 곁(진행 서클) — 찾기 → 이동은 페이지 몫. */
site?: { parts: HTMLElement[]; title?: HTMLElement };
};
}
export interface PlanKit {
/** 왼쪽 칸 — 표가 켠 차례(위치 · 지점 · 그리기 · 구역·연도). */
cards: HTMLElement[];
/** 겹 차례 고정 — 페이지 아래 겹 · 긋기 · 핀 누름 · 고르기 · 페이지 위 겹 · 핀 그림 · 우클릭 메뉴(맨 위). */
layers: (under: PlanMapLayer[], over: PlanMapLayer[]) => PlanMapLayer[];
draw: PlanDraw;
edit: PlanEdit;
props: PlanProps;
menu: PlanMenu;
marks: PlanPoints | null;
pieces: PlanPieces | null;
/** 키 — 받았으면 참(글 칸 · 보기만 = 안 받음). */
key: (event: KeyboardEvent) => boolean;
/** 꼴 · 고름이 바뀜 — 긋기 · 고르기 · 판 · 칸 다시. */
sync: () => void;
}
export function createPlanKit(params: PlanKitParams): PlanKit {
const { page, network, commit, heightAt, editable } = params;
const points = featureOn(page, "points") ? params.points : undefined;
const idle = (): boolean => draw.mode() === "idle" && (params.idle?.() ?? true);
const commitPoints = (list: Vertex[], next: PlanNetwork): void =>
points ? points.commit(list, next) : commit(next);
const draw = createPlanDraw({
page,
base: points?.list,
network,
heightAt,
commit,
onMode: params.onMode,
});
const edit = createPlanEdit({
network,
base: () => points?.list() ?? [],
commit,
heightAt,
show: params.show,
commitPoints,
showPoints: (list, next) => (points ? points.show(list, next) : params.show(next)),
enabled: idle,
editable,
onSelect: params.onSelect,
});
const props = createPlanProps({
page,
network,
edit,
editable,
yearStart: params.yearStart,
commit,
heightAt,
lonLat: params.lonLat,
points: points?.list,
addPoint: points
? (at) => points.commit([...points.list(), at], network(), [...points.names(), ""])
: undefined,
commitPoints,
respan: draw.respan,
});
const pieces = featureShown(page, "years")
? createPlanPieces({ network, edit, editable, yearOptions: props.yearOptions, commit })
: null;
const marks = points
? createPlanPoints({
points: points.list,
network,
names: points.names,
epsg: points.epsg,
commit: (list, names, next) => points.commit(list, next, names),
center: points.center,
show: points.show,
idle,
editable,
})
: null;
const menu = createPlanMenu({
page,
network,
points: points?.list,
marks: marks ?? undefined,
edit,
draw,
editable,
respan: draw.respan,
});
const card = (title: string, ...body: HTMLElement[]): HTMLElement => {
const box = createPanelCard(title, false);
box.append(...body);
return box;
};
const cards: HTMLElement[] = [];
const site = params.cards.site;
if (site && featureShown(page, "site")) {
const siteCard = card("위치", ...site.parts);
if (site.title) siteCard.firstElementChild!.append(site.title);
cards.push(siteCard);
}
if (marks) cards.push(card("지점", marks.panel));
cards.push(card("그리기", draw.panel));
if (pieces) cards.push(card("구역 · 연도", pieces.list));
return {
cards,
layers: (under, over) => [
...under,
draw.layer,
...(marks ? [marks.input] : []),
edit.layer,
...over,
...(marks ? [marks.pins] : []),
menu.layer,
],
draw,
edit,
props,
menu,
marks,
pieces,
key(event) {
const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select");
if (typing || !editable()) return false;
return !!params.onKey?.(event) || draw.key(event) || edit.key(event);
},
sync() {
draw.sync();
edit.sync();
props.render();
pieces?.render();
marks?.render();
},
};
}
/** 지도 입력 — 그림 px · 겹 · 그릴 틀(없음 = 아직 못 그림) · 다시 그림. */
export interface PlanInput {
canvas: HTMLCanvasElement;
/** 누른 자리 → 그림 px(돌리기 전). */
at: (event: MouseEvent) => [number, number];
screen: () => ScreenMap | null;
layers: () => PlanMapLayer[];
redraw: () => void;
/** 남의 지도 위에 얹음(B05 평면) — 켠 동안 잡기 단계에서 먼저 받고 막음 · 오른쪽 · 가운데 끌기 · 휠은 그대로. */
capture?: { active: () => boolean; busy: () => boolean };
}
/** 누름 · 맴돎 · 놓음 · 우클릭 · 연도 마커 끌어 놓기를 겹에 나눠 줌(한 함수 · 두 페이지). */
export function bindPlanInput(input: PlanInput): void {
const { canvas, at, layers, redraw, capture } = input;
const on = <K extends keyof HTMLElementEventMap>(
type: K,
run: (event: HTMLElementEventMap[K]) => void,
): void => canvas.addEventListener(type, run, !!capture);
const claim = (event: Event): void => {
event.stopImmediatePropagation();
event.preventDefault();
};
/** 지금 받을 틀 — 얹은 지도는 켠 동안만. */
const live = (): ScreenMap | null => (capture && !capture.active() ? null : input.screen());
const free = (): boolean => !capture?.busy();
on("pointerdown", (event) => {
if (event.button !== 0) return;
const map = live();
if (!map) return;
if (capture) claim(event);
const [px, py] = at(event);
if (free() && layers().some((layer) => layer.press?.(px, py, map, 0, event.shiftKey))) {
canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음
redraw();
} else if (capture) redraw();
});
on("pointerup", (event) => {
if (event.button !== 0) return;
const map = live();
if (!map) return;
if (capture) claim(event);
for (const layer of layers()) layer.release?.(map);
redraw();
});
on("pointermove", (event) => {
const map = live();
if (!map || event.buttons & (capture ? 6 : 4)) return; // 가운데(얹음 = 오른쪽도) 끌기 = 지도 이동
if (capture) event.stopImmediatePropagation();
perfTime("plan:pointermove", () => {
const [px, py] = at(event);
for (const layer of layers()) layer.hover?.(px, py, map, event.shiftKey);
redraw();
});
});
canvas.addEventListener("pointerleave", () => {
for (const layer of layers()) layer.leave?.();
if (!capture || capture.active()) redraw();
});
on("contextmenu", (event) => {
if (capture ? capture.active() : true) event.preventDefault();
const map = live();
if (!map) return;
if (capture) claim(event);
const [px, py] = at(event);
const took = free() && layers().some((layer) => layer.press?.(px, py, map, 2, event.shiftKey));
if (took || capture) redraw();
});
if (capture)
for (const type of ["click", "dblclick"] as const)
on(type, (event) => capture.active() && claim(event));
const markDrag = (event: DragEvent): ScreenMap | null =>
event.dataTransfer?.types.includes(YEAR_MARK_DRAG) ? live() : null;
on("dragover", (event) => {
const map = markDrag(event);
if (!map) return;
const [px, py] = at(event);
if (layers().some((layer) => layer.dragOver?.(px, py, map))) {
event.preventDefault();
event.dataTransfer!.dropEffect = "copy";
}
redraw();
});
on("drop", (event) => {
const map = markDrag(event);
if (!map) return;
event.preventDefault();
const [px, py] = at(event);
layers().some((layer) => layer.drop?.(px, py, map));
redraw();
});
canvas.addEventListener("dragleave", () => {
if (capture && !capture.active()) return;
for (const layer of layers()) layer.leave?.();
redraw();
});
}
+18 -63
View File
@@ -38,7 +38,7 @@ import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm";
/** 「연도 구역 마커」 끌어 놓기 자료 꼴(585-3 · 단추 = 그리기 칸 599-4). */
/** 「연도 구역 마커」 끌어 놓기 자료 꼴(585-3 · 단추 = 그리기 칸 599-4 · 받음 = 키트 `bindPlanInput`). */
export const YEAR_MARK_DRAG = "application/x-aislo-year-mark";
/** 측점 간격(m) — B 규칙 측점과 같음. */
@@ -157,6 +157,14 @@ export interface PlanMap {
screenOfLonLat: (lon: number, lat: number) => [number, number] | null;
/** 검증 창구 — 지금 돌린 각(도). */
rotationDeg: () => number;
/** 지도 입력 — 누름 · 맴돎 · 놓음 · 우클릭을 겹에 나눠 줄 재료(`bindPlanInput`). */
input: {
canvas: HTMLCanvasElement;
at: (event: MouseEvent) => [number, number];
screen: () => ScreenMap | null;
layers: () => PlanMapLayer[];
redraw: () => void;
};
dispose: () => void;
}
@@ -444,71 +452,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
flush();
}
/** 누른 화면 자리 → 그림 px(돌리기 전). */
const at = (event: PointerEvent | MouseEvent): [number, number] => {
/** 누른 화면 자리 → 그림 px(돌리기 전) · 나눠 주기 = `bindPlanInput`(키트 · 페이지가 묶음). */
const at = (event: MouseEvent): [number, number] => {
const rect = canvas.getBoundingClientRect();
return rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
};
canvas.addEventListener("pointerdown", (event) => {
if (!meta || event.button !== 0) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.press?.(px, py, map, 0, event.shiftKey))) {
canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음
drawTop();
}
});
canvas.addEventListener("pointerup", (event) => {
if (event.button !== 0 || !meta) return;
const map = screenMap();
for (const layer of layers) layer.release?.(map);
drawTop();
});
canvas.addEventListener("pointermove", (event) => {
if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중
perfTime("plan:pointermove", () => {
const [px, py] = at(event);
const map = screenMap();
for (const layer of layers) layer.hover?.(px, py, map, event.shiftKey);
drawTop();
});
});
canvas.addEventListener("pointerleave", () => {
for (const layer of layers) layer.leave?.();
drawTop();
});
const markDrag = (event: DragEvent): boolean =>
!!meta && !!event.dataTransfer?.types.includes(YEAR_MARK_DRAG);
canvas.addEventListener("dragover", (event) => {
if (!markDrag(event)) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.dragOver?.(px, py, map))) {
event.preventDefault();
event.dataTransfer!.dropEffect = "copy";
}
drawTop();
});
canvas.addEventListener("drop", (event) => {
if (!markDrag(event)) return;
event.preventDefault();
const [px, py] = at(event);
const map = screenMap();
layers.some((layer) => layer.drop?.(px, py, map));
drawTop();
});
canvas.addEventListener("dragleave", () => {
for (const layer of layers) layer.leave?.();
drawTop();
});
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
if (!meta) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.press?.(px, py, map, 2, event.shiftKey))) drawTop();
});
bindRouteEditNavigation({
canvas,
getView: () => view,
@@ -572,6 +520,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
screenOfLonLat: (lon, lat) =>
scene.meta ? rotation.rerotate(...lonLat(scene.meta).project(lon, lat)) : null,
rotationDeg: () => (rotation.radians() * 180) / Math.PI,
input: {
canvas,
at,
screen: () => (meta ? screenMap() : null),
layers: () => layers,
redraw: drawTop,
},
dispose: () => {
gl?.enable(null);
observer.disconnect();
+18 -13
View File
@@ -10,22 +10,24 @@
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { oneLineBlock, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { lineLimitBlock, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features";
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points";
export interface PlanMenuParams {
/** 기능 표 페이지 — 갈래 · 연도 구역 나눔 · 지점 항목. */
page: PlanPage;
network: () => PlanNetwork;
points: () => Vertex[];
marks: PlanPoints;
/** 지점 · 핀(표 「points」 켠 페이지만). */
points?: () => Vertex[];
marks?: PlanPoints;
edit: PlanEdit;
draw: PlanDraw;
editable: () => boolean;
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
respan: (line: string, from: number, to: number) => boolean;
/** 한 선만(B05 평면 예정노선 · 626-3) — 갈래 시작 · 둘째 선 · 연도 구역 나눔 항목 뺌. 없음 = D03 그대로. */
oneLine?: boolean;
}
export interface PlanMenu {
@@ -35,7 +37,9 @@ export interface PlanMenu {
}
export function createPlanMenu(params: PlanMenuParams): PlanMenu {
const { marks, edit, draw } = params;
const { edit, draw, page } = params;
const marks = featureOn(page, "points") ? params.marks : undefined;
const points = (): Vertex[] => params.points?.() ?? [];
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
@@ -44,11 +48,11 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu {
press(px, py, map, button) {
if (button !== 2 || !params.editable()) return false;
const items: MapContextMenuItem[] = [];
const pin = marks.pinAt(px, py, map);
const pin = marks?.pinAt(px, py, map) ?? null;
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;
if (!featureOn(page, "branch") && lineLimitBlock(aim, params.network())) return;
const label = {
tip: "이어 긋기",
point: "지점에서 긋기 시작",
@@ -59,8 +63,8 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu {
};
if (draw.mode() === "draw" && draw.stroke())
items.push(["긋기 끝", () => draw.setMode("idle")]);
else if (pin !== null) {
const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서
else if (marks && pin !== null) {
const [sx, sy] = map.toScreen(points()[pin]); // 핀 머리를 눌러도 지점 자리에서
items.push([`${marks.name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]);
items.push([`${marks.name(pin)} 지우기`, () => marks.remove(pin)]);
} else if (at?.mark) {
@@ -78,15 +82,16 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu {
const [sx, sy] = map.toScreen(point);
start(sx, sy);
items.push("-", ["노드 지움", () => edit.dropNode(node)]);
if (!marks.full() && !params.points().some((p) => samePoint(p, point)))
if (marks && !marks.full() && !points().some((p) => samePoint(p, point)))
items.push(["지점으로", () => marks.place(sx, sy, map)]);
} else if (at?.segment) {
const seg = at.segment;
start(px, py);
if (!params.oneLine) items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]);
if (featureOn(page, "years"))
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())
if (marks && pin === null && !at?.node && !at?.mark && !marks.full())
items.push("-", ["지점 놓기", () => marks.place(px, py, map)]);
menu.open(menuAt[0], menuAt[1], items);
return true;
+68 -128
View File
@@ -21,7 +21,7 @@
import "./D03_PlanAnalysis_UI_Style.css";
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { askConfirm } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
import {
@@ -50,8 +50,7 @@ import {
type TerrainStart,
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { bindPlanInput, createPlanKit } from "./D03_PlanAnalysis_UI_Kit";
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
import { createPlanTerrain } from "./D03_PlanAnalysis_UI_LidarUpload";
import {
@@ -60,10 +59,6 @@ import {
type PlanMapLayer,
} from "./D03_PlanAnalysis_UI_Map";
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu";
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
import {
aroundSite,
@@ -84,6 +79,7 @@ import {
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
/** 되돌리기 쌓는 수. */
const UNDO_MAX = 100;
@@ -130,83 +126,75 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const heights = terrain.surface(surface); // 지형 칩 원천(624-4)
return heights ? gridHeightAt(heights.grid, x, y) : NaN;
};
const draw = createPlanDraw({
base: () => points,
network: () => network,
heightAt: () => z,
commit,
onMode: () => {
if (draw.mode() !== "idle") setMeasure(false);
refresh();
},
});
const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void =>
step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로
const edit = createPlanEdit({
const showPoints = (nextPoints: Vertex[], next: PlanNetwork): void => {
[points, network] = [nextPoints, next];
map.redraw();
};
// 긋기 · 고르기 · 속성 판 · 지점 · 구역 · 메뉴 · 왼쪽 칸 · 키 = 노선 그리기 키트(635-1 · 숨김 = 기능 표 「d03」)
const circle = createProgressCircle({ size: 16, inline: true }); // 받기 진행 = 위치 칸 제목 오른쪽 서클(604-3)
const siteBox = createPlanSite({
onPick: (hit) => void pickSite(hit),
onRetry: () => {
[failed, lastTried, ready] = [null, "", true];
void ensureCovered(true);
},
onUpload: (files) => uploadRoads(files),
roads: {
projectId,
onPick: (pick, bounds) => {
backdrop.pickRoad(pick);
if (pick) fitTo(boxOfLonLat(epsg, bounds));
},
onDelete: (bundle) => backdrop.deleteRoad(bundle),
},
});
const kit = createPlanKit({
page: "d03",
network: () => network,
base: () => points,
commit,
heightAt: () => z,
show: (next) => {
network = normalizePieces(next, yearStart);
map.redraw();
},
commitPoints: stepPoints,
showPoints: (nextPoints, next) => {
[points, network] = [nextPoints, next];
map.redraw();
},
enabled: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
onSelect: () => refresh(),
});
const props = createPlanProps({
network: () => network,
edit,
editable: () => editable,
yearStart: () => yearStart,
commit,
heightAt: () => z,
epsg: () => epsg,
points: () => points,
addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]),
commitPoints: stepPoints,
respan: draw.respan,
});
const pieces = createPlanPieces({
network: () => network,
edit,
lonLat: (x, y) => {
const tm = tmParams(epsg);
return tm ? tmToLonLat(tm, x, y) : null;
},
editable: () => editable,
yearOptions: props.yearOptions,
commit,
idle: () => !measuring,
onMode: () => {
if (draw.mode() !== "idle") setMeasure(false);
refresh();
},
onSelect: () => refresh(),
yearStart: () => yearStart,
points: {
list: () => points,
names: () => pointNames,
epsg: () => epsg,
center: () => viewMid,
commit: stepPoints,
show: showPoints,
},
onKey: (event) =>
keyIs(event, "plan.undo")
? (undo(), true)
: keyIs(event, "plan.redo")
? (redo(), true)
: measuring && event.key === "Escape"
? (setMeasure(false), true)
: false,
cards: { site: { parts: siteBox.parts, title: circle.root } },
});
const { draw, edit, props } = kit;
const marks = kit.marks!;
const measure = createPlanMeasure(
() => z,
() => network,
);
const marks = createPlanPoints({
points: () => points,
network: () => network,
names: () => pointNames,
epsg: () => epsg,
commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames),
center: () => viewMid,
show: (nextPoints, next) => {
[points, network] = [nextPoints, next];
map.redraw();
},
idle: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
});
const menu = createPlanMenu({
network: () => network,
points: () => points,
marks,
edit,
draw,
editable: () => editable,
respan: draw.respan,
});
// 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4)
const viewWatch: PlanMapLayer = {
draw(context, screen) {
@@ -216,8 +204,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
if (!had && viewMid) marks.render(); // 첫 그림 뒤 가운데가 생김 = 지점 칸 [추가] 켬
},
};
// 받기 진행 = 위치 칸 제목 오른쪽 서클 하나(받는 일 수 · 0 = 숨김 · 지도 조작 안 막음 604-3)
const circle = createProgressCircle({ size: 16, inline: true });
const work = createWorkCircle(circle);
const terrain = createPlanTerrain({
projectId,
@@ -237,31 +223,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
main.className = "d03-plan";
main.append(map.root);
const siteBox = createPlanSite({
onPick: (hit) => void pickSite(hit),
onRetry: () => {
[failed, lastTried, ready] = [null, "", true];
void ensureCovered(true);
},
onUpload: (files) => uploadRoads(files),
roads: {
projectId,
onPick: (pick, bounds) => {
backdrop.pickRoad(pick);
if (pick) fitTo(boxOfLonLat(epsg, bounds));
},
onDelete: (bundle) => backdrop.deleteRoad(bundle),
},
});
const siteCard = createPanelCard("위치", false);
siteCard.firstElementChild!.append(circle.root);
siteCard.append(...siteBox.parts);
const pointCard = createPanelCard("지점", false);
pointCard.append(marks.panel);
const drawCard = createPanelCard("그리기", false);
drawCard.append(draw.panel);
const pieceCard = createPanelCard("구역 · 연도", false);
pieceCard.append(pieces.list);
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
const resetButton = createButton({
label: "초기화",
@@ -273,7 +234,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
actions.append(saveButton, resetButton);
const panel = document.createElement("div");
panel.className = "ui-sidebar-panel d03-plan__panel";
panel.append(siteCard, pointCard, drawCard, pieceCard, actions);
panel.append(...kit.cards, actions);
attachCollapsible(panel);
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
@@ -351,17 +312,9 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh();
}
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */
const layerList = (): PlanMapLayer[] => [
...backdrop.layers,
draw.layer,
marks.input,
edit.layer,
...(measuring ? [measure.layer] : []),
viewWatch,
marks.pins,
menu.layer,
];
/** 겹 차례 = 키트(바탕 겹 아래 · 재기 · 보기 가운데 = 고르기 위 · 핀 그림 · 우클릭 메뉴 맨 위). */
const layerList = (): PlanMapLayer[] =>
kit.layers(backdrop.layers, [...(measuring ? [measure.layer] : []), viewWatch]);
/** 점들이 받은 범위 안인가. */
const inside = (list: Vertex[]): boolean =>
@@ -381,8 +334,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
function refresh(): void {
draw.sync();
edit.sync();
kit.sync();
map.setTools({
undo: editable && undoStack.length > 0,
redo: editable && redoStack.length > 0,
@@ -390,9 +342,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
measure: measuring,
});
map.redraw();
props.render();
pieces.render();
marks.render();
const far = epsg ? farFromSite(epsg, site, [...points, ...allVertices()]) : 0;
const farNote = far ? `기준점 10 km 밖 점 ${far}곳 — 지형 안 받음 · 위치를 옮기세요` : "";
siteBox.render({
@@ -620,26 +569,17 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
layers: backdrop.state,
terrain: terrain.state,
};
// 키 = 등록부(588-3 · 한글 자판도 자판 자리로) · 되돌리기 · 다시하기 · 재기 끔 = 키트 `onKey` 먼저
const onKey = (event: KeyboardEvent): void => {
if (!root.isConnected) return window.removeEventListener("keydown", onKey);
const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select");
if (typing || !editable) return;
// 키 = 등록부(588-3 · 한글 자판도 자판 자리로)
const handled = keyIs(event, "plan.undo")
? (undo(), true)
: keyIs(event, "plan.redo")
? (redo(), true)
: measuring && event.key === "Escape"
? (setMeasure(false), true)
: draw.key(event) || edit.key(event);
if (handled) {
event.preventDefault();
map.redraw();
}
if (!kit.key(event)) return;
event.preventDefault();
map.redraw();
};
window.addEventListener("keydown", onKey);
map.setLayers(layerList());
menu.mount(map.root);
kit.menu.mount(map.root);
bindPlanInput(map.input);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
+29 -22
View File
@@ -33,7 +33,6 @@ import {
segmentRange,
} from "./logic/D03_PlanAnalysis_Select";
import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
import {
createDrawKindFields,
DRAW_DEFAULT_LENGTH_M,
@@ -41,6 +40,7 @@ import {
type DrawKindValue,
} from "./D03_PlanAnalysis_UI_DrawPanel";
import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit";
import { featureOn, featureShown, type PlanPage } from "./D03_PlanAnalysis_UI_Features";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import { selectionLength } from "./D03_PlanAnalysis_UI_Select";
import "./D03_PlanAnalysis_UI_DrawParts.css";
@@ -53,22 +53,24 @@ const meters = (m: number): string => `${m.toFixed(1)} m`;
const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`);
export interface PlanPropsParams {
/** 기능 표 페이지 — 연도 · 구간 줄 · [지점으로]. */
page: PlanPage;
network: () => PlanNetwork;
edit: PlanEdit;
editable: () => boolean;
yearStart: () => number;
/** 사업연도 시작(연도 고름 · 표 「years」 사용 페이지). */
yearStart?: () => number;
commit: (next: PlanNetwork) => void;
heightAt: () => HeightAt;
epsg: () => number;
/** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */
points: () => Vertex[];
addPoint: (point: Vertex) => void;
/** 작업 좌표 → 경위도(없음 = 경위도 칸 숨김). */
lonLat?: (x: number, y: number) => [number, number] | null;
/** 지점(노드 판 [지점으로] — 이미 있으면 숨김 · 표 「points」 켠 페이지만). */
points?: () => Vertex[];
addPoint?: (point: Vertex) => void;
/** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */
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 {
@@ -81,7 +83,11 @@ export interface PlanProps {
}
export function createPlanProps(params: PlanPropsParams): PlanProps {
const { edit } = params;
const { edit, page } = params;
const points = (): Vertex[] => params.points?.() ?? [];
/** 연도 줄 보임 — 표 「years」 사용 · 보기만 + 조각 있음. */
const yearsShown = (network: PlanNetwork): boolean =>
featureShown(page, "years") && network.pieces.length > 0;
const float = createFloatPanel({
title: "고름",
draggable: true,
@@ -92,7 +98,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
float.root.hidden = true;
function yearOptions(): Array<{ value: string; text: string }> {
const start = params.yearStart();
const start = params.yearStart?.() ?? new Date().getFullYear();
const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => start + i));
params.network().pieces.forEach((piece) => years.add(piece.year));
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
@@ -115,7 +121,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""),
size: 24,
ariaLabel: "고른 구간 연도",
disabled: !params.editable(),
disabled: !params.editable() || !featureOn(page, "years"),
onChange: (value) =>
value && params.commit(setPiecesYear(params.network(), ids, Number(value))),
});
@@ -153,7 +159,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
const apply = (): void => {
const network = params.network();
const next = nodeRecord(value.kind, value.lengthM, value.slopePct);
const out = placeNode(network, params.points(), line, vertex, next, params.heightAt());
const out = placeNode(network, points(), line, vertex, next, params.heightAt());
if (typeof out === "string") return void showToast(out, "info");
if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines))
params.commitPoints(out.points, out.network);
@@ -188,17 +194,17 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
const { line, vertex } = picked.node!;
const vertices = network.lines.find((l) => l.id === line)!.vertices;
const point = vertices[vertex];
const tm = tmParams(params.epsg());
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
const lonLat = params.lonLat?.(point[0], point[1]) ?? null;
const height = z(point[0], point[1]);
const rows: KvRow[] = [
{
label: "경위도",
value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null,
},
{ label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null },
{ label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) },
];
if (params.lonLat)
rows.unshift({
label: "경위도",
value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null,
});
if (vertex > 0) {
const [a, b] = [vertices[vertex - 1], vertices[vertex]];
rows.push(
@@ -211,9 +217,10 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
const parts: HTMLElement[] = [createKv(rows)];
if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex));
const toPoint = button("지점으로", "props-to-point", () =>
params.addPoint([point[0], point[1]]),
params.addPoint?.([point[0], point[1]]),
);
toPoint.hidden = !!params.oneLine || params.points().some((p) => samePoint(p, point));
toPoint.hidden =
!featureOn(page, "points") || !params.addPoint || points().some((p) => samePoint(p, point));
return {
title: `${nameOf(line)} · 노드 ${vertex + 1}`,
parts,
@@ -240,7 +247,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` });
rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) });
}
if (!params.oneLine)
if (yearsShown(network))
rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") });
const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)];
if (single)
@@ -267,7 +274,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
{ label: "길이", value: meters(length) },
{ label: "노드", value: `${vertices.length}개` },
]),
...(params.oneLine ? [] : [yearField(pieces), pieceRows(network, pieces)]),
...(yearsShown(network) ? [yearField(pieces), pieceRows(network, pieces)] : []),
],
actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))],
};
@@ -107,7 +107,9 @@ def test_layers_turn_drops_old_answers() -> None:
def test_one_circle_in_site_title() -> None:
page = _src("D03_PlanAnalysis_UI_Page.ts")
assert "createProgressCircle({ size: 16, inline: true })" in page
assert "siteCard.firstElementChild!.append(circle.root);" in page
assert "cards: { site: { parts: siteBox.parts, title: circle.root } }" in page # 635-1 키트
kit = _src("D03_PlanAnalysis_UI_Kit.ts")
assert "if (site.title) siteCard.firstElementChild!.append(site.title);" in kit
assert (
"corner: true" not in page
and "circle.root);\n" not in page.split("map.root.append(")[1][:80]
+22 -18
View File
@@ -1,7 +1,7 @@
"""626-3 B05 평면 예정노선 변경 → 다시 추적 → 편집기 한 걸음 → [저장](요구 530 · 531 · 535-1 · 536 · 결정 D3 · D4 · D9).
- 예정노선 페이지(D03) 긋기 · 고르기 · 메뉴 · 속성 판 함수를 그대로 부름(`oneLine` = 한 선 · 갈래 · 연도 · 지점 막음/숨김) ·
D03 덧 인자는 기본값 = 지금 그대로.
- 예정노선 페이지(D03) 긋기 · 고르기 · 메뉴 · 속성 판 함수를 그대로 부름(숨김 = 기능 표 「b05」 — 한 선 · 갈래 · 연도 ·
지점 · 635-1) · 지도 입력 = 키트 `bindPlanInput` capture.
- 지킴(글 대조) — 다시 추적 = `/route/follow` 만(저장 0) · `/route/replan` · `as_initial` · `expected_route.csv` 안 부름 ·
결과 = 편집기 `restoreSnapshot(…, true)` 한 걸음 · [저장] = 손 편집 같은 길(`bindRouteApply`) + 초안 `expected` ·
걸음 사진 · 노선 초안에 고친 예정노선 실림(되돌리기 · 세션 되살림) · 같은 선 판정에 예정노선 · 켠 동안 캔버스 누름을
@@ -71,32 +71,36 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선():
ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts")
for call in ("createPlanDraw({", "createPlanEdit(", "createPlanMenu({", "createPlanProps({"):
assert call in ui, call
assert ui.count("oneLine: true,") == 3 # 긋기 · 메뉴 · 속성 판
assert ui.count('page: "b05",') == 3 # 긋기 · 메뉴 · 속성 판 = 기능 표(635-1)
for fake in ("NO_POINTS", "epsg: () => 0", "addPoint: () => undefined", "oneLine"):
assert fake not in ui, fake
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 "event.buttons & 6" in ui # 오른쪽 · 가운데 끌기 = 지도 이동 그대로
# 지도 입력 = 키트 한 함수 · 잡기 단계(capture) — 재기 · 계획선 고르기보다 먼저
assert "capture: { active: () => open, busy: () => busy }," in ui
kit = _src(D03 / "D03_PlanAnalysis_UI_Kit.ts")
assert "canvas.addEventListener(type, run, !!capture)" in kit
assert "event.stopImmediatePropagation()" in kit
assert "event.buttons & (capture ? 6 : 4)" in kit # 오른쪽 · 가운데 끌기 = 지도 이동 그대로
assert 'createElement("button")' not in ui
# D03 덧 인자 — 기본값 = 지금 그대로
# 숨김 = 기능 표 읽기(635-1 · 플래그 없음)
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(
assert 'featureOn(params.page, "branch") ? null : lineLimitBlock(target.kind, network)' in draw
assert 'markButton.hidden = !featureOn(opts.page, "years");' 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
assert '!featureOn(page, "branch") && lineLimitBlock(aim, params.network())' in menu
assert 'if (featureOn(page, "years"))' in menu
props = _src(D03 / "D03_PlanAnalysis_UI_Props.ts")
assert "!!params.oneLine ||" in props and "...(params.oneLine ? [] :" in props
assert '!featureOn(page, "points") ||' in props and "yearsShown(network)" in props
for src in (draw, menu, props):
assert "oneLine" not in src
table = _src(D03 / "D03_PlanAnalysis_UI_Features.ts")
for key, b05 in (("branch", "hide"), ("years", "view"), ("points", "hide"), ("draw", "use")):
assert re.search(rf'key: "{key}",[^}}]*b05: "{b05}"', table, re.S), key
tools = _src(ROOT / "A00_Common" / "map" / "map_toolbar.ts")
# 634-2 — 표 줄에 호버제목(title) · 걷음은 635-3
assert re.search(