Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
218 lines
7.4 KiB
TypeScript
218 lines
7.4 KiB
TypeScript
/* =============================================================================
|
|
* 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 };
|
|
}
|