- 측점 사이 종단기울기(지반고 %) 라벨 — 측점 간격이 화면 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
225 lines
9.6 KiB
TypeScript
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;
|
|
}
|
|
}
|