Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts
T
eomsangdonandClaude Opus 5.5 207c2e40eb feat(B05): 계획노선 편집 창 — 측점 사이 기울기 라벨 · 1m 등고선 켬끔 · 보던 화면 기억
- 측점 사이 종단기울기(지반고 %) 라벨 — 측점 간격이 화면 90px 이상일 때만 · 측점 라벨 맞은편 · 회전해도 글 바로 섬 · 끄는 동안 안 물음
- 「1m 등고선」 단추(기본 꺼짐 · LAS 없으면 잠김) — 1m 선 얇고 옅게 · 켰을 때 처음 한 번만 준비 · 5m 선은 한 단 짙게(--map-contour-major)
- 회전 · 가운데 · 배율을 화면 취향(routeedit-view · 프로젝트마다 · 계정에도 올라감)에 적고 다시 열면 그대로
- 700줄 한도 — 화면 셈·기억은 _View, 재기 배선은 _Measure, 바탕 읽기는 _Contour, 노드 끼우기·빼기는 _Edits 로 나눔
- 시험 test_b05_routeedit_view.py (창 크기 달라도 같은 자리 · 저장소 왕복 · 노드 편집값 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 09:33:57 +09:00

225 lines
9.6 KiB
TypeScript

/* =============================================================================
* 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<Vertex>;
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<Vertex>, 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<Vertex>): 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<Vertex> | null = null;
let cachedInterval = 0;
let chainages: number[] = [];
let heights: Array<number | null> = [];
/** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */
let stationPoints: Vertex[] = [];
let middlePoints: Vertex[] = [];
/** 묻는 중인 노선 — 같은 것을 두 번 묻지 않는다. */
let asking: ReadonlyArray<Vertex> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
function schedule(line: ReadonlyArray<Vertex>, 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;
}
}