- design_profile: 도구 고름 뒤 종단도 한 번 다시 그림 - RunHighlight · Render: 붉은 선을 계획선 세로 창 겹 안에 그림 - Render: 도구가 먹은 누름은 전파 막음 — 측점 고름 안 돎 - Panel_Tools: 도구 바꿀 때 겹 안 붉은 선도 같이 뗌 - test_271: 지킴 시험 더함 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
119 lines
5.7 KiB
TypeScript
119 lines
5.7 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Profile_RunHighlight.ts
|
|
* [쉬프트]로 고른 직선 구간 강조 오버레이 (2026-09-03 사용자 지시).
|
|
*
|
|
* 고른 직선을 눌러도 화면에 아무 표시가 없어 무엇이 잡혔는지 알 수 없었다. 강조 범위는
|
|
* **직선 + 양 끝 라운드(R)** 다 — 쉬프트는 직선을 통째로 올리고 내리므로 양 끝 호의
|
|
* 모양까지 함께 바뀌기 때문이다. 그래서 구간 시작 라운드의 BVC부터 끝 라운드의 EVC까지
|
|
* 계획선 위를 그대로 덧그린다.
|
|
*
|
|
* 종단 그래프 SVG(`B06_Section_UI_Longitudinal`)는 B05·B06 공용이라 건드리지 않고,
|
|
* 같은 좌표계 위에 별도 오버레이를 얹는다. Y 매핑은 그 렌더러가 넘겨 준 축 눈금
|
|
* (`onYAxis`)에서 되짚는다 — 두 눈금의 (표고, y) 두 쌍이면 1차식이 정해진다.
|
|
* ========================================================================== */
|
|
|
|
import type { ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
|
|
import type { StraightRun } from "./logic/B05_Profile_UI_Profile_Straighten";
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
/** 두 chainage를 같은 변화점으로 볼 허용 오차(m). */
|
|
const SAME_NODE_M = 1e-6;
|
|
|
|
export interface RunHighlightOptions {
|
|
alignment: ProfileAlignment;
|
|
/** 강조할 구간들(쉬프트 선택분). 비어 있으면 오버레이를 만들지 않는다. */
|
|
runs: StraightRun[];
|
|
/** 누가거리 → 화면 x(px). 종단 그래프와 같은 매핑이어야 선이 겹친다. */
|
|
x: (chainageM: number) => number;
|
|
/** 종단 렌더러가 넘겨 준 Y축 눈금 — 표고 → y(px) 를 되짚는 근거. */
|
|
axis: { ticks: Array<{ y: number; label: string }> } | null;
|
|
widthPx: number;
|
|
heightPx: number;
|
|
/**
|
|
* 계획선이 든 세로 창 겹 — 붉은 선을 여기 넣으면 스크롤 때 계획선과 같은 변환을 받는다.
|
|
* 없으면 덮개 안에 그대로 둔다(세로 맞춤을 못 따라감).
|
|
*/
|
|
windowLayer?: Element | null;
|
|
}
|
|
|
|
/** 눈금 라벨(`880m`)에서 표고를 읽는다. 숫자가 아니면 null. */
|
|
function tickElevation(label: string): number | null {
|
|
const value = Number.parseFloat(label);
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
/**
|
|
* 축 눈금 두 개로 표고 → y(px) 1차식을 만든다. 눈금이 모자라거나 겹치면 null.
|
|
* (렌더러와 같은 스케일을 쓰려는 것이므로 별도로 계산하지 않는다.)
|
|
*/
|
|
function elevationToY(
|
|
axis: { ticks: Array<{ y: number; label: string }> } | null,
|
|
): ((elevationM: number) => number) | null {
|
|
const points = (axis?.ticks ?? [])
|
|
.map((tick) => ({ y: tick.y, elevation: tickElevation(tick.label) }))
|
|
.filter((entry): entry is { y: number; elevation: number } => entry.elevation !== null);
|
|
if (points.length < 2) return null;
|
|
const first = points[0];
|
|
const last = points[points.length - 1];
|
|
const span = last.elevation - first.elevation;
|
|
if (Math.abs(span) < 1e-9) return null;
|
|
const scale = (last.y - first.y) / span;
|
|
return (elevationM: number): number => first.y + (elevationM - first.elevation) * scale;
|
|
}
|
|
|
|
/** 구간 양 끝 라운드까지 넓힌 강조 범위 [시작, 끝] (누가거리 m). */
|
|
function runSpanWithCurves(alignment: ProfileAlignment, run: StraightRun): [number, number] {
|
|
const startCurve = alignment.curves.find(
|
|
(curve) => Math.abs(curve.chainage_m - run.fromM) <= SAME_NODE_M && !curve.omitted,
|
|
);
|
|
const endCurve = alignment.curves.find(
|
|
(curve) => Math.abs(curve.chainage_m - run.toM) <= SAME_NODE_M && !curve.omitted,
|
|
);
|
|
return [startCurve ? startCurve.bvc_m : run.fromM, endCurve ? endCurve.evc_m : run.toM];
|
|
}
|
|
|
|
/**
|
|
* 고른 구간을 계획선 위에 빨갛게 덧그린 오버레이. 고른 것이 없으면 null.
|
|
* 반환한 요소는 종단 차트 래퍼(`b05-profile__chart`) 안에 그대로 붙이면 된다.
|
|
*/
|
|
export function createRunHighlight(options: RunHighlightOptions): SVGElement | null {
|
|
const { alignment, runs, x, widthPx, heightPx } = options;
|
|
if (!runs.length) return null;
|
|
const toY = elevationToY(options.axis);
|
|
if (!toY) return null;
|
|
|
|
const svg = document.createElementNS(SVG_NS, "svg");
|
|
svg.setAttribute("class", "b05-profile-runmark");
|
|
svg.setAttribute("width", String(widthPx));
|
|
svg.setAttribute("height", String(heightPx));
|
|
svg.setAttribute("viewBox", `0 0 ${widthPx} ${heightPx}`);
|
|
|
|
const samples = alignment.samples;
|
|
let drawn = 0;
|
|
for (const run of runs) {
|
|
const [fromM, toM] = runSpanWithCurves(alignment, run);
|
|
// 계획선 샘플에는 변화점·BVC·EVC가 모두 들어 있어 라운드 곡률까지 그대로 따라온다.
|
|
const points = samples
|
|
.filter((sample) => sample.chainage_m >= fromM - SAME_NODE_M)
|
|
.filter((sample) => sample.chainage_m <= toM + SAME_NODE_M)
|
|
.map((sample) => `${x(sample.chainage_m).toFixed(2)},${toY(sample.elevation_m).toFixed(2)}`);
|
|
if (points.length < 2) continue;
|
|
const line = document.createElementNS(SVG_NS, "polyline");
|
|
line.setAttribute("class", "b05-profile-runmark__line");
|
|
line.setAttribute("points", points.join(" "));
|
|
(options.windowLayer ?? svg).append(line);
|
|
drawn += 1;
|
|
// 양 끝 표시 — 어디까지가 이 구간인지(라운드 포함) 한눈에 보이게 세로 표식을 둔다.
|
|
for (const edge of [fromM, toM]) {
|
|
const tick = document.createElementNS(SVG_NS, "line");
|
|
tick.setAttribute("class", "b05-profile-runmark__edge");
|
|
tick.setAttribute("x1", x(edge).toFixed(2));
|
|
tick.setAttribute("x2", x(edge).toFixed(2));
|
|
tick.setAttribute("y1", "0");
|
|
tick.setAttribute("y2", String(heightPx));
|
|
svg.append(tick);
|
|
}
|
|
}
|
|
return drawn ? svg : null;
|
|
}
|