- 새 D03_PlanAnalysis_UI_Features.ts — 노선 그리기 기능 표(요구서 546 4장 · pages d03 · b05 = use · view · hide · 까닭) · featureUse · featureOn · featureShown - 새 D03_PlanAnalysis_UI_Kit.ts — createPlanKit(부품 엮기 · 왼쪽 칸 위치 · 지점 · 그리기 · 구역·연도 · 겹 차례 · 키 · sync) · bindPlanInput(누름 · 맴돎 · 놓음 · 우클릭 · 연도 마커 끌어 놓기 · capture = B05 켠 동안 먼저 받음) - 예정노선 UI_Page 680 → 620 줄 · 키트로 엮음(화면 · 저장 · 되돌리기 그대로) · UI_Map 지도 입력 줄 → map.input - UI_Draw · UI_Menu · UI_Props · UI_DrawPanel — oneLine 플래그 → 기능 표 page 읽기 · 지점 · 경위도(lonLat) · 연도 시작 = 없으면 숨김(빈 지점 · epsg 0 가짜 값 걷음) · oneLineBlock → lineLimitBlock - B05 ExpectedDraw — page: "b05" 넘김 · NO_POINTS · 가짜 값 걷음 · 지도 입력 = bindPlanInput capture(동작 그대로) · 속성 판 경위도 빈칸 → 칸 숨김 - 시험 고침: test_626_3(oneLine 글 대조 → 기능 표 · 키트 capture 대조) · test_475(위치 칸 서클 = 키트 site.title) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* 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();
|
|
});
|
|
}
|