Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts
T

305 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;
/** 마지막 서버 세트 셈(왼쪽 세트 줄 덩이 수 · 고친 뒤 = 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 = <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();
});
}