- 직선화 첫 클릭이 직선 안이면 직선 통째 고름(붉은 강조)으로 새던 갈림 지움 · 빈 곳 클릭 측점선에 붙음 - 이미 직선인 범위 = 부분 구간 고름(보라 띠 · 안내) · ▲▼ = 가운데 + 양 끝 기본 L 곡선 - 직선화 · 자유지정 끝 R 이 저장 자동 R 이면 지움 = 끝 곡선 L 20 · 사용자 R 은 둠 - 법정 미달 판정 오차 1e-3 짝(TS · 파이썬) — L 19.99998 거짓 미달 없앰 - 새 도우미 design_profile_range_band — 직선화 · 자유지정 범위 단계 실시간 띠 · 시작–끝 · 길이 글 - Esc = 시작만 취소 · 모드 유지 · 고름 풀기로 안 샘 - 시험 test_271_straighten_pick (거울 포함) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
/* =============================================================================
|
||
* A00_Common/design/design_profile_range_band.ts
|
||
* 종단 범위 실시간 띠 — 시작 세로선을 고른 뒤 마우스 측점까지 띠 · 세로선 · 「시작–끝 · 길이」 글(271-2 · 3).
|
||
*
|
||
* [직선화]는 `createRangeBandView` 로 그래프에 붙이고, [자유지정]은 `design_profile_free_view` 가
|
||
* 같은 `appendRangeBand` 로 그린다 — 띠 한 벌. 마우스 자리는 가장 가까운 측점선에 붙음 · 그래프 밖 =
|
||
* 시작선만 · 다시 들어오면 이어짐. 색 · 굵기는 자유지정 범위 띠(`b05-profile-freemark__*`)와 같음.
|
||
* ========================================================================== */
|
||
|
||
import { snapStation } from "./logic/design_profile_free";
|
||
import type { FreeChartGeom } from "./design_profile_free_view";
|
||
|
||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||
|
||
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
|
||
const element = document.createElementNS(SVG_NS, tag);
|
||
Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value)));
|
||
return element;
|
||
}
|
||
|
||
/** 범위 띠를 붙인다 — 끝이 없거나 시작과 같으면 시작선만 · 반대 방향도 같은 띠. */
|
||
export function appendRangeBand(
|
||
svg: SVGElement,
|
||
x: (chainageM: number) => number,
|
||
heightPx: number,
|
||
startM: number,
|
||
endM: number | null,
|
||
): void {
|
||
const ends =
|
||
endM === null || Math.abs(endM - startM) < 1e-6
|
||
? [startM]
|
||
: [Math.min(startM, endM), Math.max(startM, endM)];
|
||
if (ends.length === 2) {
|
||
const [left, right] = ends.map(x);
|
||
svg.append(
|
||
svgEl("rect", {
|
||
class: "b05-profile-freemark__band",
|
||
x: left,
|
||
y: 0,
|
||
width: Math.max(0, right - left),
|
||
height: heightPx,
|
||
}),
|
||
);
|
||
const label = svgEl("text", {
|
||
class: "b05-profile-freemark__span",
|
||
x: (left + right) / 2,
|
||
y: 14,
|
||
"text-anchor": "middle",
|
||
});
|
||
label.textContent = `${ends[0].toFixed(1)}–${ends[1].toFixed(1)}m · ${(ends[1] - ends[0]).toFixed(1)}m`;
|
||
svg.append(label);
|
||
}
|
||
ends.forEach((chainage) =>
|
||
svg.append(
|
||
svgEl("line", {
|
||
class: "b05-profile-freemark__edge",
|
||
x1: x(chainage),
|
||
x2: x(chainage),
|
||
y1: 0,
|
||
y2: heightPx,
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
export interface RangeBandView {
|
||
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
|
||
mount: (geom: FreeChartGeom | null, active: boolean) => void;
|
||
redraw: () => void;
|
||
}
|
||
|
||
/** 범위 띠 화면 — `source(마우스 측점)` 가 [시작, 끝 | null] 을 주면 그림 · null 이면 지움. */
|
||
export function createRangeBandView(
|
||
source: (hoverM: number | null) => [number, number | null] | null,
|
||
): RangeBandView {
|
||
let geom: FreeChartGeom | null = null;
|
||
let active = false;
|
||
let hover: number | null = null;
|
||
/** 마지막 마우스 x(화면) — 스크롤 · 다시 그림 뒤에도 커서 밑 측점을 다시 잡는다. */
|
||
let clientX: number | null = null;
|
||
let overlay: SVGElement | null = null;
|
||
let listeners: AbortController | null = null;
|
||
|
||
function redraw(): void {
|
||
overlay?.remove();
|
||
overlay = null;
|
||
const range = geom && active ? source(hover) : null;
|
||
if (!geom || !range) return;
|
||
const { widthPx, heightPx } = geom;
|
||
overlay = svgEl("svg", {
|
||
class: "b05-profile-freemark",
|
||
width: widthPx,
|
||
height: heightPx,
|
||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||
});
|
||
appendRangeBand(overlay, geom.x, heightPx, range[0], range[1]);
|
||
geom.chart.append(overlay);
|
||
}
|
||
|
||
/** 커서 밑 측점을 다시 잡는다 — 바뀌었을 때만 다시 그림. */
|
||
function track(): void {
|
||
let next: number | null = null;
|
||
if (geom && clientX !== null) {
|
||
const at = geom.toChainage(clientX - geom.chart.getBoundingClientRect().left);
|
||
next = Number.isFinite(at) ? snapStation(geom.stations, at) : null;
|
||
}
|
||
if (next === hover) return;
|
||
hover = next;
|
||
redraw();
|
||
}
|
||
|
||
function mount(next: FreeChartGeom | null, on: boolean): void {
|
||
listeners?.abort();
|
||
listeners = null;
|
||
if (next) geom = next;
|
||
active = on;
|
||
if (!on) [hover, clientX] = [null, null];
|
||
if (!geom || !on || !geom.chart.isConnected) {
|
||
overlay?.remove();
|
||
overlay = null;
|
||
return;
|
||
}
|
||
listeners = new AbortController();
|
||
const { signal } = listeners;
|
||
const chart = geom.chart;
|
||
chart.addEventListener(
|
||
"mousemove",
|
||
(event) => {
|
||
clientX = event.clientX;
|
||
track();
|
||
},
|
||
{ signal },
|
||
);
|
||
chart.addEventListener(
|
||
"mouseleave",
|
||
() => {
|
||
clientX = null;
|
||
track();
|
||
},
|
||
{ signal },
|
||
);
|
||
geom.scroller.addEventListener("scroll", track, { signal, passive: true });
|
||
hover = null;
|
||
track();
|
||
redraw();
|
||
}
|
||
|
||
return { mount, redraw };
|
||
}
|