/* ============================================================================= * 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; /** 마지막 서버 세트 셈(왼쪽 세트 줄 덩이 수 · 고친 뒤 = null). */ sets?: () => ReadonlyArray<{ key: string; parts: number }> | null; 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, year: () => params.yearStart?.() ?? new Date().getFullYear(), 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, setOptions: props.setOptions, assign: props.assign, commit, sets: params.sets, }) : 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 = ( 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(); }); }