feat(B05): 종단 [자유지정] — 범위 고름 · 클릭 점 측점선 붙음 · 실시간 점선 · 기울기 빨강 · 꺾인 점 종단곡선 · 계획고 편집 캐시(215-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
@@ -453,7 +453,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
zoom: profileZoom.state,
|
||||
holdElevationRange: elevationHold.hold,
|
||||
toolActive: () => tools.mode() !== "none",
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
tools,
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
setElevationWindowUpdater: (update) => {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_free.ts
|
||||
* 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1).
|
||||
*
|
||||
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
||||
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
|
||||
*
|
||||
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
||||
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
|
||||
* (R = L / |대수차|). 양 끝 세로선의 라운드는 직선화처럼 그대로 둔다.
|
||||
*
|
||||
* 화면(`design_profile_free_view.ts`)은 클릭 · 마우스 · 키만 넘기고 여기 표시 자료를 그린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
ProfileAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
controlElevationAt,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
baseElevationAt,
|
||||
defaultCurveLength,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Straighten";
|
||||
|
||||
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
|
||||
const SAME_M = 1e-6;
|
||||
/** 클릭 높이를 이 단위(m)로 끊는다. */
|
||||
const ELEVATION_STEP_M = 0.01;
|
||||
|
||||
export interface FreeVertex {
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
}
|
||||
|
||||
/** range = 범위 고르는 중 · armed = 시작 세로선을 기다림 · drawing = 점 찍는 중. */
|
||||
export type FreePhase = "range" | "armed" | "drawing";
|
||||
|
||||
/** 그래프 세로 창 — 그릴 때 기준(`data-yw-*`)과 지금 걸린 변환. */
|
||||
export interface ChartWindow {
|
||||
center: number;
|
||||
pxPerM: number;
|
||||
zero: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** 세로 창 변환 글(`translate(0 a) scale(1 b)`)을 읽는다 — 없으면 그릴 때 그대로. */
|
||||
export function parseWindowTransform(text: string | null): { scale: number; offset: number } {
|
||||
const num = "([-+0-9.eE]+)";
|
||||
const match = new RegExp(`translate\\(\\s*0[ ,]+${num}\\s*\\)\\s*scale\\(\\s*1[ ,]+${num}`).exec(
|
||||
text ?? "",
|
||||
);
|
||||
if (!match) return { scale: 1, offset: 0 };
|
||||
const offset = Number(match[1]);
|
||||
const scale = Number(match[2]);
|
||||
return Number.isFinite(offset) && Number.isFinite(scale) && scale > 0
|
||||
? { scale, offset }
|
||||
: { scale: 1, offset: 0 };
|
||||
}
|
||||
|
||||
/** 그래프 y(px) → 표고(m). 과장 배율은 `pxPerM` 에 들어 있다. */
|
||||
export function elevationAtChartY(view: ChartWindow, y: number): number {
|
||||
const baked = (y - view.offset) / view.scale;
|
||||
return view.center + (view.zero - baked) / view.pxPerM;
|
||||
}
|
||||
|
||||
/** 표고(m) → 그래프 y(px). `elevationAtChartY` 의 역. */
|
||||
export function chartYAtElevation(view: ChartWindow, elevationM: number): number {
|
||||
return view.scale * (view.zero - (elevationM - view.center) * view.pxPerM) + view.offset;
|
||||
}
|
||||
|
||||
/** 가장 가까운 측점 세로선. 범위를 주면 그 안(양 끝 포함)에서만 고른다. */
|
||||
export function snapStation(
|
||||
stations: number[],
|
||||
chainageM: number,
|
||||
range: [number, number] | null = null,
|
||||
): number | null {
|
||||
let best: number | null = null;
|
||||
for (const station of stations) {
|
||||
if (range && (station < range[0] - SAME_M || station > range[1] + SAME_M)) continue;
|
||||
if (best === null || Math.abs(station - chainageM) < Math.abs(best - chainageM)) {
|
||||
best = station;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 두 점 사이 기울기(%). */
|
||||
export function gradePct(from: FreeVertex, to: FreeVertex): number {
|
||||
const span = to.chainage_m - from.chainage_m;
|
||||
return Math.abs(span) > 1e-12 ? ((to.elevation_m - from.elevation_m) / span) * 100 : 0;
|
||||
}
|
||||
|
||||
/** 임도 기준 최대 기울기를 넘는가 — 판정은 선형 계산의 위반 판정(`violations`)과 같은 식. */
|
||||
export function overGrade(pct: number, maxPct: number): boolean {
|
||||
return Math.abs(pct) > maxPct + 1e-6;
|
||||
}
|
||||
|
||||
/** 점들을 잇는 꺾은선 위 표고(chainage 오름차순 꺾은선). */
|
||||
function elevationOnPath(path: FreeVertex[], chainageM: number): number {
|
||||
for (let index = 1; index < path.length; index += 1) {
|
||||
const left = path[index - 1];
|
||||
const right = path[index];
|
||||
if (chainageM > right.chainage_m + SAME_M) continue;
|
||||
const span = right.chainage_m - left.chainage_m;
|
||||
const ratio = span > 1e-12 ? (chainageM - left.chainage_m) / span : 0;
|
||||
return left.elevation_m + (right.elevation_m - left.elevation_m) * ratio;
|
||||
}
|
||||
return path[path.length - 1].elevation_m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
|
||||
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
|
||||
*/
|
||||
export function buildFreeEdits(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
vertices: FreeVertex[],
|
||||
): AlignmentEdits {
|
||||
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (path.length < 2) return edits;
|
||||
const fromM = path[0].chainage_m;
|
||||
const toM = path[path.length - 1].chainage_m;
|
||||
if (toM - fromM <= SAME_M) return edits;
|
||||
const inner = new Map<string, number>();
|
||||
buildAlignment(base, edits).pvi.forEach((node) => {
|
||||
if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) {
|
||||
inner.set(chainageKey(node.chainage_m), node.chainage_m);
|
||||
}
|
||||
});
|
||||
path
|
||||
.slice(1, -1)
|
||||
.forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m));
|
||||
const offsets = { ...edits.station_offsets };
|
||||
const radii = { ...edits.curve_radii };
|
||||
inner.forEach((chainage, key) => {
|
||||
const target = elevationOnPath(path, chainage);
|
||||
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
|
||||
delete radii[key];
|
||||
});
|
||||
for (let index = 1; index < path.length - 1; index += 1) {
|
||||
const gradeIn = gradePct(path[index - 1], path[index]) / 100;
|
||||
const gradeOut = gradePct(path[index], path[index + 1]) / 100;
|
||||
const deltaGrade = Math.abs(gradeOut - gradeIn);
|
||||
if (deltaGrade < 1e-9) continue;
|
||||
radii[chainageKey(path[index].chainage_m)] = Number(
|
||||
(defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6),
|
||||
);
|
||||
}
|
||||
return { ...edits, station_offsets: offsets, curve_radii: radii };
|
||||
}
|
||||
|
||||
export interface FreeSegment {
|
||||
from: FreeVertex;
|
||||
to: FreeVertex;
|
||||
grade_pct: number;
|
||||
over: boolean;
|
||||
/** 마지막 점 → 마우스 자리(아직 안 찍음). */
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
/** 오버레이 표시 자료 — 화면은 이것만 그린다. */
|
||||
export interface FreeMarks {
|
||||
/** 고른 범위(강조 띠) — 둘 다 고르기 전엔 null. */
|
||||
band: [number, number] | null;
|
||||
/** 세로 강조선 chainage(범위 첫 측점 또는 범위 양 끝). */
|
||||
edges: number[];
|
||||
segments: FreeSegment[];
|
||||
/** 찍힌 점(시작 점 포함). */
|
||||
dots: FreeVertex[];
|
||||
}
|
||||
|
||||
export interface FreeDrawDeps {
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
|
||||
apply: (next: AlignmentEdits) => void;
|
||||
/** 모드를 끈다(적용 뒤 · Esc). */
|
||||
close: () => void;
|
||||
/** 단계 · 안내가 바뀜. */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
export interface FreeDraw {
|
||||
phase: () => FreePhase;
|
||||
reset: () => void;
|
||||
/** 그래프 클릭 — 날 chainage · 표고와 그래프 측점선 목록. 먹었으면 true. */
|
||||
click: (chainageM: number, elevationM: number, stations: number[]) => boolean;
|
||||
/** 마우스 자리 — 미리 보기 대상. 그래프 밖이면 null. */
|
||||
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
|
||||
/** Enter · Escape · Backspace — 먹었으면 true. */
|
||||
key: (key: string) => boolean;
|
||||
marks: () => FreeMarks;
|
||||
hint: () => string;
|
||||
}
|
||||
|
||||
export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
let phase: FreePhase = "range";
|
||||
let pending: number | null = null;
|
||||
let range: [number, number] | null = null;
|
||||
let anchor: number | null = null;
|
||||
let points: FreeVertex[] = [];
|
||||
let target: FreeVertex | null = null;
|
||||
|
||||
function reset(): void {
|
||||
phase = "range";
|
||||
pending = null;
|
||||
range = null;
|
||||
anchor = null;
|
||||
points = [];
|
||||
target = null;
|
||||
}
|
||||
|
||||
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
|
||||
function heightAt(chainageM: number): number | null {
|
||||
const alignment = deps.alignment();
|
||||
return alignment ? controlElevationAt(alignment, chainageM) : null;
|
||||
}
|
||||
|
||||
const finishM = (): number | null =>
|
||||
range && anchor !== null ? (anchor === range[0] ? range[1] : range[0]) : null;
|
||||
const same = (a: number | null, b: number | null): boolean =>
|
||||
a !== null && b !== null && Math.abs(a - b) < SAME_M;
|
||||
|
||||
/** 지나온 점(시작 포함) — 마지막 것이 다음 선의 출발점. */
|
||||
function path(): FreeVertex[] {
|
||||
if (anchor === null) return [];
|
||||
const z = heightAt(anchor);
|
||||
return z === null ? [] : [{ chainage_m: anchor, elevation_m: z }, ...points];
|
||||
}
|
||||
|
||||
/** 클릭 · 마우스 자리 → 다음 점 후보. 끝 세로선이면 그 계획고, 뒤로 가면 null. */
|
||||
function candidate(chainageM: number, elevationM: number, stations: number[]): FreeVertex | null {
|
||||
if (phase !== "drawing" || !range) return null;
|
||||
const station = snapStation(stations, chainageM, range);
|
||||
const end = finishM();
|
||||
if (station === null || end === null) return null;
|
||||
if (same(station, end)) {
|
||||
const z = heightAt(end);
|
||||
return z === null ? null : { chainage_m: end, elevation_m: z };
|
||||
}
|
||||
const walked = path();
|
||||
const last = walked[walked.length - 1];
|
||||
if (!last || (station - last.chainage_m) * Math.sign(end - last.chainage_m) <= SAME_M) {
|
||||
return null;
|
||||
}
|
||||
const z = Math.round(elevationM / ELEVATION_STEP_M) * ELEVATION_STEP_M;
|
||||
return { chainage_m: station, elevation_m: Number(z.toFixed(3)) };
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
const base = deps.base();
|
||||
const end = finishM();
|
||||
const z = end === null ? null : heightAt(end);
|
||||
if (!base || end === null || z === null) return;
|
||||
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices);
|
||||
reset();
|
||||
deps.apply(next);
|
||||
deps.close();
|
||||
}
|
||||
|
||||
return {
|
||||
phase: () => phase,
|
||||
reset,
|
||||
click(chainageM, elevationM, stations) {
|
||||
if (phase === "range") {
|
||||
const station = snapStation(stations, chainageM);
|
||||
if (station === null) return true;
|
||||
if (pending === null || same(pending, station)) pending = station;
|
||||
else {
|
||||
range = [Math.min(pending, station), Math.max(pending, station)];
|
||||
pending = null;
|
||||
phase = "armed";
|
||||
}
|
||||
} else if (phase === "armed" && range) {
|
||||
const station = snapStation(stations, chainageM, range);
|
||||
if (same(station, range[0]) || same(station, range[1])) {
|
||||
anchor = station;
|
||||
points = [];
|
||||
phase = "drawing";
|
||||
}
|
||||
} else if (phase === "drawing") {
|
||||
const next = candidate(chainageM, elevationM, stations);
|
||||
if (!next) return true;
|
||||
if (same(next.chainage_m, finishM())) {
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
points.push(next);
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
},
|
||||
hover(pointer) {
|
||||
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
|
||||
},
|
||||
key(key) {
|
||||
if (key === "Escape") {
|
||||
reset();
|
||||
deps.close();
|
||||
return true;
|
||||
}
|
||||
if (key === "Enter") {
|
||||
if (phase !== "drawing") return false;
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
if (key === "Backspace") {
|
||||
if (phase !== "drawing") return false;
|
||||
if (points.length) points.pop();
|
||||
else {
|
||||
anchor = null;
|
||||
phase = "armed";
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
marks() {
|
||||
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
|
||||
const dots = path();
|
||||
const line = target ? [...dots, target] : dots;
|
||||
const segments: FreeSegment[] = [];
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const grade = gradePct(line[index - 1], line[index]);
|
||||
segments.push({
|
||||
from: line[index - 1],
|
||||
to: line[index],
|
||||
grade_pct: grade,
|
||||
over: overGrade(grade, maxPct),
|
||||
preview: target !== null && index === line.length - 1,
|
||||
});
|
||||
}
|
||||
return {
|
||||
band: range,
|
||||
edges: range ? [...range] : pending !== null ? [pending] : [],
|
||||
segments,
|
||||
dots,
|
||||
};
|
||||
},
|
||||
hint() {
|
||||
if (phase === "range") {
|
||||
return pending === null
|
||||
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
||||
}
|
||||
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_free_view.ts
|
||||
* 종단 [자유지정] 화면 — 그래프 클릭 · 마우스 · 오버레이(범위 강조 · 점선 · 기울기 글)(215-1).
|
||||
*
|
||||
* 계산 · 상태는 `design_profile_free.ts` 몫. 여기서는 마우스 자리를 (chainage, 표고)로 바꿔
|
||||
* 넘기고 돌려받은 표시 자료를 그린다. 표고 변환은 종단 그래프 세로 창(`data-yw-*` + 지금 걸린
|
||||
* 변환)을 그대로 되짚는다 — 스크롤로 창이 바뀌어도 같은 식이다.
|
||||
*
|
||||
* 그래프는 그릴 때마다 새로 만들어지므로 그릴 때마다 `mount` 를 다시 부른다.
|
||||
* 자유지정 중엔 그래프 클릭 · 누르기를 여기서 먹는다(측점 고르기 · 구조물 끌기로 새지 않게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import {
|
||||
chartYAtElevation,
|
||||
elevationAtChartY,
|
||||
parseWindowTransform,
|
||||
type ChartWindow,
|
||||
type FreeDraw,
|
||||
} from "./design_profile_free";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 종단 그래프 한 벌의 가로 매핑 — 렌더러가 그릴 때마다 넘긴다. */
|
||||
export interface FreeChartGeom {
|
||||
/** 그래프 래퍼(`b05-profile__chart`). */
|
||||
chart: HTMLElement;
|
||||
/** 가로 스크롤러 — 스크롤하면 세로 창이 바뀌어 다시 그린다. */
|
||||
scroller: HTMLElement;
|
||||
x: (chainageM: number) => number;
|
||||
toChainage: (px: number) => number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
/** 그래프 세로 측점선 chainage(정규 + 구조물). */
|
||||
stations: number[];
|
||||
}
|
||||
|
||||
export interface FreeView {
|
||||
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
|
||||
mount: (geom: FreeChartGeom | null, active: boolean) => void;
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 지금 그래프의 세로 창 — 자동 창 SVG 가 없으면 null. */
|
||||
function readWindow(chart: HTMLElement): { view: ChartWindow; top: number } | null {
|
||||
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
|
||||
if (!svg) return null;
|
||||
const read = (name: string): number => Number(svg.getAttribute(name));
|
||||
const layer = svg.querySelector(`.${Y_WINDOW_CLASS}`);
|
||||
const view: ChartWindow = {
|
||||
center: read(Y_WINDOW_ATTRS.center),
|
||||
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
|
||||
zero: read(Y_WINDOW_ATTRS.zero),
|
||||
...parseWindowTransform(layer?.getAttribute("transform") ?? null),
|
||||
};
|
||||
if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) {
|
||||
return null;
|
||||
}
|
||||
// 그래프 SVG 가 래퍼 위쪽에서 떨어져 있으면 그만큼 오버레이 y 를 맞춘다.
|
||||
const top = svg.getBoundingClientRect().top - chart.getBoundingClientRect().top;
|
||||
return { view, top };
|
||||
}
|
||||
|
||||
export function createFreeView(free: FreeDraw): FreeView {
|
||||
let geom: FreeChartGeom | null = null;
|
||||
let active = false;
|
||||
let overlay: SVGSVGElement | null = null;
|
||||
let listeners: AbortController | null = null;
|
||||
|
||||
/** 마우스 자리 → (chainage, 표고). 그래프 밖이면 null. */
|
||||
function pointerAt(event: MouseEvent) {
|
||||
if (!geom) return null;
|
||||
const win = readWindow(geom.chart);
|
||||
if (!win) return null;
|
||||
const rect = geom.chart.getBoundingClientRect();
|
||||
const chainageM = geom.toChainage(event.clientX - rect.left);
|
||||
if (!Number.isFinite(chainageM)) return null;
|
||||
const elevationM = elevationAtChartY(win.view, event.clientY - rect.top - win.top);
|
||||
return { chainageM, elevationM, stations: geom.stations };
|
||||
}
|
||||
|
||||
function redraw(): void {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
if (!geom || !active) return;
|
||||
const win = readWindow(geom.chart);
|
||||
if (!win) return;
|
||||
const { x, widthPx, heightPx } = geom;
|
||||
const y = (elevationM: number): number => chartYAtElevation(win.view, elevationM) + win.top;
|
||||
const marks = free.marks();
|
||||
const svg = svgEl("svg", {
|
||||
class: "b05-profile-freemark",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
}) as SVGSVGElement;
|
||||
if (marks.band) {
|
||||
const [left, right] = marks.band.map(x);
|
||||
svg.append(
|
||||
svgEl("rect", {
|
||||
class: "b05-profile-freemark__band",
|
||||
x: left,
|
||||
y: 0,
|
||||
width: Math.max(0, right - left),
|
||||
height: heightPx,
|
||||
}),
|
||||
);
|
||||
}
|
||||
marks.edges.forEach((chainage) =>
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: "b05-profile-freemark__edge",
|
||||
x1: x(chainage),
|
||||
x2: x(chainage),
|
||||
y1: 0,
|
||||
y2: heightPx,
|
||||
}),
|
||||
),
|
||||
);
|
||||
marks.segments.forEach((segment) => {
|
||||
const x1 = x(segment.from.chainage_m);
|
||||
const x2 = x(segment.to.chainage_m);
|
||||
const y1 = y(segment.from.elevation_m);
|
||||
const y2 = y(segment.to.elevation_m);
|
||||
const over = segment.over ? " is-over" : "";
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: `b05-profile-freemark__line${over}${segment.preview ? " is-preview" : ""}`,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
}),
|
||||
);
|
||||
const label = svgEl("text", {
|
||||
class: `b05-profile-freemark__grade${over}`,
|
||||
x: (x1 + x2) / 2,
|
||||
y: (y1 + y2) / 2 - 6,
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
label.textContent = `${segment.grade_pct.toFixed(1)}%`;
|
||||
svg.append(label);
|
||||
});
|
||||
marks.dots.forEach((dot) =>
|
||||
svg.append(
|
||||
svgEl("circle", {
|
||||
class: "b05-profile-freemark__dot",
|
||||
cx: x(dot.chainage_m),
|
||||
cy: y(dot.elevation_m),
|
||||
r: 3.5,
|
||||
}),
|
||||
),
|
||||
);
|
||||
overlay = svg;
|
||||
geom.chart.append(svg);
|
||||
}
|
||||
|
||||
function mount(next: FreeChartGeom | null, on: boolean): void {
|
||||
listeners?.abort();
|
||||
listeners = null;
|
||||
if (next) geom = next;
|
||||
active = on;
|
||||
if (!on) free.reset();
|
||||
if (!geom || !on || !geom.chart.isConnected) {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
return;
|
||||
}
|
||||
listeners = new AbortController();
|
||||
const { signal } = listeners;
|
||||
const chart = geom.chart;
|
||||
const swallow = (event: Event): void => {
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
// 누르기는 삼킴 — 구조물 측점선 끌기 · 계획고 버튼 길게 누르기가 시작되지 않게.
|
||||
chart.addEventListener("pointerdown", swallow, { capture: true, signal });
|
||||
chart.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
swallow(event);
|
||||
event.preventDefault();
|
||||
const pointer = pointerAt(event);
|
||||
if (pointer) free.click(pointer.chainageM, pointer.elevationM, pointer.stations);
|
||||
free.hover(pointerAt(event));
|
||||
redraw();
|
||||
},
|
||||
{ capture: true, signal },
|
||||
);
|
||||
chart.addEventListener(
|
||||
"mousemove",
|
||||
(event) => {
|
||||
free.hover(pointerAt(event));
|
||||
redraw();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
chart.addEventListener(
|
||||
"mouseleave",
|
||||
() => {
|
||||
free.hover(null);
|
||||
redraw();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
geom.scroller.addEventListener("scroll", redraw, { signal, passive: true });
|
||||
redraw();
|
||||
}
|
||||
|
||||
return { mount, redraw };
|
||||
}
|
||||
Reference in New Issue
Block a user