/* ============================================================================= * B05_Profile_UI_RouteEdit_Slope.ts * 계획노선 편집 모달의 **측점 사이 종단기울기 라벨**(계획서 18 ①). * * 확대해 측점 라벨이 하나하나 다 보이는 배율부터(`drawStationTicks` 가 솎지 않는 간격), * 측점과 측점 사이 노선 위에 그 구간의 기울기(%)를 적는다. 멀리 보면 숨긴다. * * 기울기는 **지반고** 기준이다 — 구간 재기(`_Measure`)와 같은 통로(`/route/elevations`)로 * 측점 자리 높이를 한 번에 묻는다. 확정 지표면을 읽기만 하는 통로라 「편집 중에는 계산이 * 안 나간다」와 부딪히지 않고, 노드를 끄는 동안에는 부르지 않는다(놓은 뒤 `refresh`). * ========================================================================== */ import { fetchRouteElevations } from "./B05_Profile_Api_Replan"; type Vertex = [number, number]; /** 측점 간격이 화면에서 이만큼(px) 이상이면 라벨을 낸다 — 측점 라벨을 솎지 않는 간격과 같다. */ const SLOPE_MIN_GAP_PX = 90; /** 노선에서 라벨을 띄우는 거리(px) — 측점 라벨(반대쪽 16px)과 안 겹치게 반대편에 둔다. */ const SLOPE_OFFSET_PX = 13; /** 높이를 묻기 전 잠깐 기다린다 — 연달아 고칠 때 한 번만 묻게. */ const FETCH_DELAY_MS = 250; const SLOPE_COLOR = "#0f766e"; /** 화면 가장자리 여유(px) — 반쯤 걸친 라벨도 그린다. */ const EDGE_PX = 20; export interface SlopeLabelParams { projectId: string; /** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */ isClosed: () => boolean; /** 높이를 받아 왔다 — 호출부가 다시 그린다. */ onChange: () => void; } export interface SlopeDrawArgs { /** 지금 그려지는 노선(원호 포함). 편집마다 **새 배열**이라 이것으로 낡음을 가린다. */ line: ReadonlyArray; intervalM: number; pxPerMeter: number; toScreen: (vertex: Vertex) => [number, number]; /** 글자만 되돌려 세울 각(라디안). */ uprightRad: number; /** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */ dragging: boolean; } export interface SlopeLabels { /** 측점 사이 라벨을 그린다. 그린 수를 돌려준다(검증 수치). */ draw: (context: CanvasRenderingContext2D, args: SlopeDrawArgs) => number; /** 끌기를 마쳤다 — 다음 그리기에서 새 노선 높이를 묻게 한다. */ refresh: () => void; } /** 측점 누가거리 — 0, 간격, 2·간격, … 그리고 종점(끝 자투리 구간도 기울기가 있다). */ function stationChainages(total: number, intervalM: number): number[] { const out: number[] = []; for (let chainage = 0; chainage < total - 1e-6; chainage += intervalM) out.push(chainage); out.push(total); return out; } /** 누가거리 목록 자리의 노선 위 점. `drawStationTicks` 와 같은 보간이다. */ function pointsAt(line: ReadonlyArray, chainages: number[]): Vertex[] { const cumulative: number[] = [0]; for (let index = 1; index < line.length; index += 1) { cumulative.push( cumulative[index - 1] + Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]), ); } let cursor = 1; return chainages.map((chainage) => { while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; const back = line[cursor - 1]; const front = line[cursor]; const segment = cumulative[cursor] - cumulative[cursor - 1] || 1; const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment)); return [back[0] + (front[0] - back[0]) * ratio, back[1] + (front[1] - back[1]) * ratio]; }); } function lengthOf(line: ReadonlyArray): number { let total = 0; for (let index = 1; index < line.length; index += 1) { total += Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]); } return total; } export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { /** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */ let cachedLine: ReadonlyArray | null = null; let cachedInterval = 0; let chainages: number[] = []; let heights: Array = []; /** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */ let stationPoints: Vertex[] = []; let middlePoints: Vertex[] = []; /** 묻는 중인 노선 — 같은 것을 두 번 묻지 않는다. */ let asking: ReadonlyArray | null = null; let timer: ReturnType | null = null; function schedule(line: ReadonlyArray, intervalM: number): void { if (asking === line) return; if (timer) clearTimeout(timer); asking = line; timer = setTimeout(() => { timer = null; const wanted = stationChainages(lengthOf(line), intervalM); const points = pointsAt(line, wanted); void fetchRouteElevations(params.projectId, points) .then((z) => { if (params.isClosed() || asking !== line) return; // 그 사이 노선이 또 바뀌었다. cachedLine = line; cachedInterval = intervalM; chainages = wanted; heights = z; stationPoints = points; middlePoints = pointsAt( line, wanted.slice(1).map((end, index) => (wanted[index] + end) / 2), ); params.onChange(); }) .catch(() => { /* 높이를 못 읽으면 라벨을 안 낸다 — 구간 재기와 같은 처리. */ }) .finally(() => { if (asking === line) asking = null; }); }, FETCH_DELAY_MS); } /** 그린 라벨 수와 첫 라벨(글·자리)을 캔버스에 적어 둔다 — 화면 검증이 읽는 수치. */ function report(context: CanvasRenderingContext2D, count: number, first: string): number { context.canvas.dataset.slopeLabels = String(count); context.canvas.dataset.slopeFirst = first; return count; } return { refresh() { asking = null; }, draw(context, args) { let first = ""; const count = paint(context, args, (text, x, y) => { if (!first) first = `${text}@${Math.round(x)},${Math.round(y)}`; }); return report(context, count, first); }, }; function paint( context: CanvasRenderingContext2D, args: SlopeDrawArgs, onLabel: (text: string, x: number, y: number) => void, ): number { const { line, intervalM } = args; if (line.length < 2 || !(intervalM > 0)) return 0; if (intervalM * args.pxPerMeter < SLOPE_MIN_GAP_PX) return 0; // 멀리 보면 숨긴다. const fresh = cachedLine === line && cachedInterval === intervalM; if (!fresh) { if (!args.dragging) schedule(line, intervalM); return 0; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다. } // 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 — // 돌린 지도에서도 실제로 보이는 라벨만 그리고 센다. const toDevice = context.getTransform(); const ratio = context.canvas.width / (context.canvas.clientWidth || context.canvas.width); const onScreen = (x: number, y: number): [number, number] | null => { const point = toDevice.transformPoint(new DOMPoint(x, y)); const [px, py] = [point.x / ratio, point.y / ratio]; const inside = px >= -EDGE_PX && py >= -EDGE_PX && px <= context.canvas.clientWidth + EDGE_PX && py <= context.canvas.clientHeight + EDGE_PX; return inside ? [px, py] : null; }; context.save(); context.font = "bold 11px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; let count = 0; for (let index = 0; index + 1 < chainages.length; index += 1) { const z0 = heights[index]; const z1 = heights[index + 1]; const run = chainages[index + 1] - chainages[index]; if (z0 === null || z1 === null || z0 === undefined || z1 === undefined) continue; if (run <= 0.5) continue; // 종점 자투리가 너무 짧으면 기울기가 튄다. const [sx, sy] = args.toScreen(middlePoints[index]); // 노선 방향 — 구간 양 끝 측점으로 잡는다. 라벨은 그 직각 반대편(측점 라벨의 맞은편)에. const [ax, ay] = args.toScreen(stationPoints[index]); const [bx, by] = args.toScreen(stationPoints[index + 1]); const length = Math.hypot(bx - ax, by - ay) || 1; const lx = sx - (-(by - ay) / length) * SLOPE_OFFSET_PX; const ly = sy - ((bx - ax) / length) * SLOPE_OFFSET_PX; const seen = onScreen(lx, ly); if (!seen) continue; const percent = ((z1 - z0) / run) * 100; const text = `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%`; context.save(); if (args.uprightRad) { context.translate(lx, ly); context.rotate(args.uprightRad); context.translate(-lx, -ly); } const width = context.measureText(text).width + 8; context.fillStyle = "rgba(255, 255, 255, 0.88)"; context.fillRect(lx - width / 2, ly - 8, width, 16); context.strokeStyle = SLOPE_COLOR; context.lineWidth = 1; context.strokeRect(lx - width / 2, ly - 8, width, 16); context.fillStyle = SLOPE_COLOR; context.fillText(text, lx, ly); context.restore(); onLabel(text, seen[0], seen[1]); // 화면에 보이는 자리(px)로 적는다. count += 1; } context.restore(); return count; } }