/* ============================================================================= * B05_Profile_UI_RouteEdit_StructureMarks.ts * 평면 **구조물 측점 마커**(PLAN 399 · 요구 250-2). * * · 목록 = 종단 아래 알약과 같은 것(`laneStructures` · 관 · 구조물 · 물넘이 · 사토장 …) · 노선 위 그 측점에 * 알약 꼴(종류 색) · 일반 측점 눈금 · 스냅 · 간격과 무관. * · 고름 = 알약 고름과 같은 길(`select` · 공용 고름 382-2 — 평면 마커 고름 = 종단 알약 강조 · 반대도). * 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391). * · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 · * 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본). * · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름. * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로 · * 좌측 목록 강조 · 폼은 풂 409-2). * 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`. * ========================================================================== */ import { pickPx } from "../A00_Common/touch_bridge"; import { draftProjectId } from "../A00_Common/design/design_store"; import { NO_SELECTION, PIPE_MATCH_M, readSelection, selectDesign, } from "../A00_Common/design/design_selection"; import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { structureAnchorM, type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; import { balloonElement, laneStructures, positionText, structurePillTexts, } from "./B05_Profile_UI_Structures_Marks"; import { balloonLines } from "./logic/B05_Profile_UI_Structures_Balloon"; import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input"; import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer"; import { PLAN_PICK_FROM } from "../A00_Common/design/design_pick_escape"; import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; import { arrowStep, isArrowKey, type ArrowKey } from "./logic/B05_Profile_RouteEdit_KeyNudge"; import { arrowSign, markChainage, markStep, pickedMarkId, } from "./logic/B05_Profile_RouteEdit_StructureMarks"; import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마커를 잡았다고 볼 거리(px). */ const MARK_HIT_PX = 10; /** 알약 마커 반 길이 · 굵기(px) — 고르면 조금 크게. */ const MARK_HALF_PX = 5; const MARK_WIDTH_PX = 8; /** 고른 마커 바깥 테 — 법선과 같은 하늘색(391). */ const PICKED_RING = "#0ea5e9"; /** 법선 좌우 길이(m) — `_Hover` 법선과 같은 값(391-1) · 정보 창을 그 밖으로. */ const NORMAL_HALF_M = 20; /** 종단 보기가 건네는 것 — 알약 목록 · 종류 · 측점 간격 · 고름 · 옮김(알약과 같은 길). */ export interface PlanMarkSource { marks: () => ReadonlyArray; types: () => ReadonlyArray; intervalM: () => number; select: (structureId: string) => void; move: (structureId: string, toChainageM: number) => void; } const sources = new Map(); /** 목록이 바뀌면 다시 그릴 편집 창(프로젝트마다). */ const redraws = new Map void>>(); /** 알약 목록이 바뀜(추가 · 옮김 · 노선 따라감) — 그 프로젝트 편집 창을 다시 그림. */ export function planMarksChanged(projectId: string): void { redraws.get(projectId)?.forEach((redraw) => redraw()); } /** 프로젝트의 마커 재료를 건넴 — 돌려준 함수로 뗌(보기를 떠날 때). */ export function setPlanMarkSource(projectId: string, source: PlanMarkSource): () => void { sources.set(projectId, source); return () => void (sources.get(projectId) === source && sources.delete(projectId)); } interface PlanMark { id: string; chainageM: number; structure: StructureInstance; type: StructureType | undefined; name: string; } /** 옮기는 중(끌기 · 방향키 누름) — 놓을 때 · 뗄 때 `to` 로 한 번 옮김. 창(캔버스)마다 따로. * `done` = 옮겼고 알약 목록이 아직 안 따라옴(관 목록은 늦게 옴) — 따라올 때까지 그 자리 · 그 고름 그대로. */ interface Moving { id: string; name: string; from: number; to: number; totalM: number; done?: true; } const movings = new WeakMap(); /** 끄는 중 · 누르는 중인 것(옮긴 뒤 기다리는 것은 아님). */ const activeMoving = (canvas: HTMLCanvasElement): Moving | null => { const moving = movings.get(canvas); return moving && !moving.done ? moving : null; }; const balloons = new WeakMap(); const lengthOf = (line: ReadonlyArray): number => line.reduce( (sum, vertex, index) => index ? sum + Math.hypot(vertex[0] - line[index - 1][0], vertex[1] - line[index - 1][1]) : 0, 0, ); /** 지금 마커 목록(옮기는 것은 그 자리) · 고른 것 — 재료가 없으면 null. */ function planMarks( canvas: HTMLCanvasElement, ): { source: PlanMarkSource; marks: PlanMark[]; picked: string | null } | null { const projectId = draftProjectId(); const source = sources.get(projectId); if (!source) return null; const list = laneStructures(source.marks()); const types = new Map(source.types().map((type) => [type.type_id, type])); const texts = structurePillTexts(list, types); const held = movings.get(canvas) ?? null; const lagging = list.some( (structure) => structure.structure_id === held?.id && structureAnchorM(structure) !== held?.to, ); if (held?.done && !lagging) movings.set(canvas, null); // 목록이 따라옴(같은 id 가 없어도 끝) const moving = held?.done && !lagging ? null : held; const marks = list .filter((structure) => structure.structure_id) .map((structure): PlanMark => { const id = structure.structure_id as string; return { id, chainageM: moving?.id === id ? moving.to : structureAnchorM(structure), structure, type: types.get(structure.type_id), name: texts.get(id)?.name ?? structure.type_id, }; }); // 옮기는 중엔 그것 — 관 고름은 누가거리만 들어 옮긴 자리로는 못 찾음 const picked = moving?.id ?? pickedMarkId(marks, readSelection(projectId), PIPE_MATCH_M); return { source, marks, picked }; } /** 알약 꼴 마커 한 개 — 노선 방향으로 누운 둥근 막대(종류 색 · 흰 테 · 고르면 하늘 테). */ function paintMark( context: CanvasRenderingContext2D, scene: RouteEditScene, line: ReadonlyArray, mark: PlanMark, picked: boolean, halo: string, ): void { const frame = stationFrameAt(line, mark.chainageM); if (!frame) return; const [x, y] = scene.toScreen(frame.origin); const [fx, fy] = scene.toScreen([ frame.origin[0] + frame.left[1], frame.origin[1] - frame.left[0], ]); // 앞쪽 1 m(왼쪽 축을 시계로 90°) const half = picked ? MARK_HALF_PX + 2 : MARK_HALF_PX; context.save(); context.translate(x, y); context.rotate(Math.atan2(fy - y, fx - x)); context.lineCap = "round"; const stroke = (color: string, width: number): void => { context.strokeStyle = color; context.lineWidth = width; context.beginPath(); context.moveTo(-half, 0); context.lineTo(half, 0); context.stroke(); }; if (picked) stroke(PICKED_RING, MARK_WIDTH_PX + 7); stroke(halo, MARK_WIDTH_PX + 3); stroke(mark.type?.style?.color ?? "#888", MARK_WIDTH_PX); context.restore(); } /** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 맨 마지막에 부름(409). */ export function drawStructureMarks( context: CanvasRenderingContext2D, scene: RouteEditScene, line: ReadonlyArray, style: CSSStyleDeclaration, ): void { const canvas = context.canvas; const found = line.length >= 2 ? planMarks(canvas) : null; const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; const picked = found?.marks.find((mark) => mark.id === found.picked) ?? null; found?.marks.forEach( (mark) => mark !== picked && paintMark(context, scene, line, mark, false, halo), ); if (picked) { drawStationNormal(context, scene, line, picked.chainageM); paintMark(context, scene, line, picked, true, halo); } showBalloon(canvas, scene, line, picked && found ? { mark: picked, source: found.source } : null); } /** 고른 마커 위 정보 창 — 알약 벌룬 그대로 · 마커 위(위가 모자라면 아래) · 누름은 지나감. */ function showBalloon( canvas: HTMLCanvasElement, scene: RouteEditScene, line: ReadonlyArray, pick: { mark: PlanMark; source: PlanMarkSource } | null, ): void { const kept = balloons.get(canvas); const frame = pick && stationFrameAt(line, pick.mark.chainageM); if (!pick || !frame || !canvas.parentElement) { kept?.element.remove(); balloons.delete(canvas); return; } const { mark } = pick; const structure = { ...mark.structure, chainage_m: mark.chainageM }; const position = positionText(structure, pick.source.intervalM()); const lines = balloonLines(structure, mark.type, { name: mark.name, position }); const key = JSON.stringify([lines, mark.type?.style?.color]); let element = kept?.element; if (!element || kept?.key !== key) { const next = balloonElement(lines, mark.type); next.classList.add("is-center"); next.style.pointerEvents = "none"; next.style.zIndex = "1"; // 오른쪽 횡단 판(z 2) · 펼친 판 아래로 — 지도 위에만 if (element) element.replaceWith(next); else canvas.parentElement.append(next); element = next; balloons.set(canvas, { key, element }); } // 그림은 캔버스 가운데를 축으로 돎 — 창 자리도 같은 만큼 돌림 const [sx, sy] = scene.toScreen(frame.origin); const cx = scene.view.width / 2; const cy = scene.view.height / 2; const cos = Math.cos(scene.rotationRad); const sin = Math.sin(scene.rotationRad); const x = cx + (sx - cx) * cos - (sy - cy) * sin; const y = cy + (sx - cx) * sin + (sy - cy) * cos; element.style.left = `${x}px`; element.style.top = `${y}px`; element.style.bottom = "auto"; // 법선(좌우 20 m) 끝 밖으로 띄움 — 크게 확대해도 60 px 넘게는 안 떨어짐 const gap = Math.min(NORMAL_HALF_M * scene.pxPerMeter, 60) + 10; element.style.transform = y < element.offsetHeight + gap + 10 ? `translate(-50%, ${gap}px)` : `translate(-50%, calc(-100% - ${gap}px))`; } export interface StructureMarkInput { /** 마커를 눌렀으면 고르고 끌기 시작 · true(재기 다음 맨 앞 · 409). */ press: (px: number, py: number) => boolean; /** 끄는 중이면 자리 바꿈 · true. */ move: (px: number, py: number) => boolean; /** 끄는 중이었으면 놓음(옮겼으면 한 번 옮김) · true. */ release: () => boolean; /** 마커 위인가 — 커서 몫. */ over: (px: number, py: number) => boolean; /** 이 평면 마커가 고른 것이면 풂 · true(409 — 마커 아닌 곳 누름 · 다른 화면이 고른 것은 그대로 · 440). */ deselect: () => boolean; } /** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */ export function bindStructureMarks( params: RouteEditPointerParams, dragging: () => boolean, ): StructureMarkInput { const { canvas, status } = params; const projectId = draftProjectId(); /** 이 평면 마커 누름이 고른 것(빈 곳 누름이 풀 것 · 440). */ let ownPick: string | null = null; const list = redraws.get(projectId) ?? new Set<() => void>(); redraws.set(projectId, list); const redraw = (): void => void (canvas.isConnected ? params.draw() : list.delete(redraw)); list.add(redraw); const lineNow = (): Vertex[] => { const state = params.state(); return state.plannedLine.length ? state.plannedLine : state.planned; }; const markAt = (px: number, py: number): PlanMark | null => { const found = planMarks(canvas); const line = lineNow(); let best: PlanMark | null = null; let bestDistance = pickPx(MARK_HIT_PX); for (const mark of found?.marks ?? []) { const frame = stationFrameAt(line, mark.chainageM); if (!frame) continue; const [x, y] = params.toScreen(frame.origin); const distance = Math.hypot(x - px, y - py); if (distance <= bestDistance) [best, bestDistance] = [mark, distance]; } return best; }; const say = (moving: Moving, done: boolean): void => { const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM; const shift = moving.to - moving.from; status.textContent = `${params.routeHead()} — 구조물 ${moving.name} ${formatStation(moving.from, interval)} → ` + `${formatStation(moving.to, interval)}(${shift > 0 ? "+" : ""}${shift} m)` + (done ? " 옮김 — [저장]에서 정본." : " 옮기는 중."); }; /** 놓음 · 뗌 — 옮겼으면 알약 끌기와 같은 길로 한 번. */ const finish = (): boolean => { const moving = activeMoving(canvas); if (!moving) return false; movings.set(canvas, moving.to !== moving.from ? { ...moving, done: true } : null); if (moving.to !== moving.from) { sources.get(draftProjectId())?.move(moving.id, moving.to); say(moving, true); } params.draw(); return true; }; const start = (mark: PlanMark): Moving => { const moving = { id: mark.id, name: mark.name, from: mark.chainageM, to: mark.chainageM, totalM: lengthOf(lineNow()), }; movings.set(canvas, moving); return moving; }; /** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판은 제 이름(`PLAN_PICK_FROM`)이라 안 비움. */ const deselect = (): boolean => { if (!planMarks(canvas)?.picked) return false; selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM }); ownPick = null; params.draw(); return true; }; // 방향키 — 마커를 골랐고 교각점을 안 골랐을 때 · 누름 ~ 뗌 한 번이 한 옮김(교각점 방향키와 같은 반복) let holdKey: ArrowKey | null = null; let timer = 0; const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y)); const step = (moving: Moving, key: ArrowKey): void => { const frame = stationFrameAt(lineNow(), moving.to); if (!frame) return; const tangent: Vertex = [frame.left[1], -frame.left[0]]; moving.to = markStep( moving.to, arrowSign(key, arrowStep(key, false, metricAt), tangent), moving.totalM, ); say(moving, false); params.draw(); }; const release = (): void => { window.clearTimeout(timer); window.clearInterval(timer); if (holdKey === null) return; holdKey = null; finish(); }; const detach = (): void => { window.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("keyup", onKeyUp, true); window.removeEventListener("blur", release); }; function onKeyDown(event: KeyboardEvent): void { if (!canvas.isConnected) return detach(); const escape = event.key === "Escape"; if ((!escape && !isArrowKey(event.key)) || !lastPointerWithin(".b05-routeedit")) return; const target = event.target as HTMLElement | null; if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return; // Esc = 마커 고름만 풂(공용 Esc 풀기는 오른쪽 판까지 비우므로 앞에서 받음) if (escape) return void (!activeMoving(canvas) && deselect() && event.preventDefault()); if (event.repeat || holdKey || dragging() || params.measureMode()) return; if (params.state().picked >= 0 || activeMoving(canvas)) return; // 교각점 방향키 몫 const found = planMarks(canvas); const mark = found?.marks.find((entry) => entry.id === found.picked); if (!mark) return; event.preventDefault(); const moving = start(mark); const key = (holdKey = event.key); step(moving, key); timer = window.setTimeout(() => { timer = window.setInterval(() => holdKey === key && step(moving, key), HOLD_INTERVAL_MS); }, HOLD_DELAY_MS); } function onKeyUp(event: KeyboardEvent): void { if (!canvas.isConnected) return detach(); if (event.key === holdKey) release(); } window.addEventListener("keydown", onKeyDown, true); window.addEventListener("keyup", onKeyUp, true); window.addEventListener("blur", release); return { press(px, py) { const mark = markAt(px, py); if (!mark) return false; release(); planMarks(canvas)?.source.select(mark.id); // 알약 고름과 같은 길(공용 고름) ownPick = mark.id; start(mark); params.draw(); return true; }, move(px, py) { const moving = activeMoving(canvas); if (!moving || holdKey) return false; const hit = routePointAtScreen(lineNow(), params.toScreen, px, py, Infinity); const near = hit ? markChainage(hit.chainageM, moving.totalM) : moving.to; // 옛 소수 자리(80.17)는 반올림 자리(80)에 머무는 동안 그대로 — 눌렀다 떼기만 해도 옮겨지지 않게 const to = near === Math.round(moving.from) ? moving.from : near; if (to !== moving.to) { moving.to = to; say(moving, false); params.draw(); } return true; }, release: () => !holdKey && finish(), over: (px, py) => markAt(px, py) !== null, // 빈 곳 누름 = 이 마커가 고른 것만 풂 — B06 카드 · 목록 · 알약이 고른 것은 그대로(440) · Esc 는 앞 그대로 deselect: () => planMarks(canvas)?.picked === ownPick && deselect(), }; }