329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B06_wf3_ProfileCross_UI_Cross_View.ts
|
|
* 개별 횡단면 카드 SVG 렌더러와 X:Y 1:1 스케일 지표 계산.
|
|
*
|
|
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 횡단 카드 렌더링만 분리했다. 공통 상수·유틸은
|
|
* `_UI_Section_Common`, 설계 지정 컨트롤/설계선 오버레이는 `_UI_Cross_Design`을 참조한다.
|
|
* 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossSection, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch";
|
|
import {
|
|
appendCrossDesignOverlay,
|
|
buildDesignControls,
|
|
} from "./B06_wf3_ProfileCross_UI_Cross_Design";
|
|
import {
|
|
CROSS_HEIGHT,
|
|
CROSS_PAD,
|
|
CROSS_WIDTH,
|
|
type DesignChangeHandler,
|
|
emptyView,
|
|
L,
|
|
stationLabel,
|
|
svgElement,
|
|
svgText,
|
|
validElevation,
|
|
} from "./B06_wf3_ProfileCross_UI_Section_Common";
|
|
|
|
interface CrossPlotMetrics {
|
|
sourceSamples: SectionSample[];
|
|
minOffset: number;
|
|
maxOffset: number;
|
|
elevationMid: number;
|
|
displaySpan: number;
|
|
displayMax: number;
|
|
pixelsPerMeter: number;
|
|
heightPx: number;
|
|
}
|
|
|
|
/**
|
|
* 횡단 카드의 X:Y 1:1 스케일 지표를 계산한다.
|
|
* X pixels-per-meter는 반폭(offset 범위)이 카드 폭을 채우도록 고정하고, 같은 ppm을 Y에도 써서
|
|
* 형상 왜곡을 없앤다(과장=1 → 정확히 1:1). 카드 높이는 데이터가 요구하는 자연 높이에 최소
|
|
* `CROSS_HEIGHT`(250px) 바닥을 적용하며, `forcedHeightPx`가 오면 그 높이로 강제한다(같은 행 높이 통일).
|
|
* 어느 경우든 displaySpan을 실제 plotHeight에 맞춰 되계산하므로 ppm(=1:1)은 그대로 보존되고,
|
|
* 남는 세로 공간은 elevationMid를 중심으로 대칭 여백이 된다.
|
|
*/
|
|
function crossPlotMetrics(
|
|
section: CrossSection,
|
|
verticalExaggeration: number,
|
|
widthPx: number,
|
|
crossHalfWidth?: number,
|
|
designElevation?: number,
|
|
forcedHeightPx?: number,
|
|
): CrossPlotMetrics | null {
|
|
const sourceSamples = section.samples.filter(
|
|
(sample) =>
|
|
crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6,
|
|
);
|
|
const valid = sourceSamples.filter(validElevation);
|
|
if (!valid.length) return null;
|
|
const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0);
|
|
const minOffset = Math.min(...offsets, -1);
|
|
const maxOffset = Math.max(...offsets, 1);
|
|
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
|
|
const elevations = valid.map((sample) => sample.elevation_m);
|
|
if (hasDesign) elevations.push(designElevation as number);
|
|
const rawMin = Math.min(...elevations);
|
|
const rawMax = Math.max(...elevations);
|
|
const elevationMid = (rawMin + rawMax) / 2;
|
|
const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5;
|
|
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
|
const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right;
|
|
const xSpan = Math.max(maxOffset - minOffset, 1e-6);
|
|
const pixelsPerMeter = plotWidth / xSpan;
|
|
const rawSpan = Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1e-6);
|
|
const naturalHeight = rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom;
|
|
// 강제 높이가 있으면 그것을, 없으면 자연 높이에 250px 바닥 적용.
|
|
const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT);
|
|
const plotHeight = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6);
|
|
// 실제 plotHeight에 맞춰 표시 표고폭을 되계산 → ppm(1:1) 보존, 여유분은 상하 대칭 여백.
|
|
const displaySpan = plotHeight / pixelsPerMeter;
|
|
const displayMax = elevationMid + displaySpan / 2;
|
|
return {
|
|
sourceSamples,
|
|
minOffset,
|
|
maxOffset,
|
|
elevationMid,
|
|
displaySpan,
|
|
displayMax,
|
|
pixelsPerMeter,
|
|
heightPx,
|
|
};
|
|
}
|
|
|
|
/** 같은 행 높이 통일을 위해 카드를 만들지 않고 자연(바닥 적용) 높이만 계산한다. */
|
|
export function crossCardNaturalHeight(
|
|
section: CrossSection,
|
|
verticalExaggeration: number,
|
|
widthPx: number,
|
|
crossHalfWidth?: number,
|
|
designElevation?: number,
|
|
): number {
|
|
return (
|
|
crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation)
|
|
?.heightPx ?? CROSS_HEIGHT
|
|
);
|
|
}
|
|
|
|
export function createCrossSectionCard(
|
|
section: CrossSection,
|
|
selected: boolean,
|
|
verticalExaggeration: number,
|
|
onSelect: (stationId: string) => void,
|
|
stationInterval: number,
|
|
crossHalfWidth?: number,
|
|
widthPx = CROSS_WIDTH,
|
|
forcedHeightPx?: number,
|
|
designElevation?: number,
|
|
onDesignChange?: DesignChangeHandler,
|
|
): HTMLElement {
|
|
const card = document.createElement("article");
|
|
card.id = `cross-${section.station_id}`;
|
|
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
|
|
card.tabIndex = 0;
|
|
card.addEventListener("click", () => onSelect(section.station_id));
|
|
card.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
|
|
});
|
|
|
|
const header = document.createElement("header");
|
|
const title = document.createElement("div");
|
|
const label = document.createElement("strong");
|
|
label.textContent = stationLabel(section.chainage_m, stationInterval);
|
|
const chainage = document.createElement("span");
|
|
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
|
|
title.append(label, chainage);
|
|
const kind = document.createElement("span");
|
|
kind.textContent =
|
|
section.kind === "ep"
|
|
? L("B06_Profile_View_Kind_EP")
|
|
: section.kind === "bp"
|
|
? L("B06_Profile_View_Kind_BP")
|
|
: L("B06_Profile_View_Kind_Station");
|
|
// kind 라벨(일반측점/BP/EP) 좌측에 구조물 정보를 표기(비정규 측점이 확정 시 실어온 값).
|
|
const meta = document.createElement("div");
|
|
meta.className = "b06-cross-card__meta";
|
|
if (section.structure) {
|
|
const structure = document.createElement("span");
|
|
structure.className = "b06-cross-card__structure";
|
|
structure.textContent = section.structure;
|
|
structure.title = `구조물: ${section.structure}`;
|
|
meta.append(structure);
|
|
}
|
|
meta.append(kind);
|
|
header.append(title, meta);
|
|
card.append(header);
|
|
if (onDesignChange) card.append(buildDesignControls(section, onDesignChange));
|
|
|
|
const metrics = crossPlotMetrics(
|
|
section,
|
|
verticalExaggeration,
|
|
widthPx,
|
|
crossHalfWidth,
|
|
designElevation,
|
|
forcedHeightPx,
|
|
);
|
|
if (!metrics) {
|
|
card.append(emptyView(L("B06_Profile_View_NoCross")));
|
|
} else {
|
|
const {
|
|
sourceSamples,
|
|
minOffset,
|
|
maxOffset,
|
|
elevationMid,
|
|
displaySpan,
|
|
displayMax,
|
|
pixelsPerMeter,
|
|
heightPx,
|
|
} = metrics;
|
|
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
|
|
const valid = sourceSamples.filter(validElevation);
|
|
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
|
const x = (offset: number) => CROSS_PAD.left + (offset - minOffset) * pixelsPerMeter;
|
|
const y = (elevation: number) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter;
|
|
const svg = svgElement("svg", {
|
|
class: "b06-section__chart",
|
|
width: widthPx,
|
|
height: heightPx,
|
|
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
|
role: "img",
|
|
"aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`,
|
|
});
|
|
svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" }));
|
|
|
|
const xTicks = Array.from(
|
|
{ length: 7 },
|
|
(_, index) => minOffset + ((maxOffset - minOffset) * index) / 6,
|
|
);
|
|
for (const tick of xTicks) {
|
|
svg.append(
|
|
svgElement("line", {
|
|
x1: x(tick),
|
|
y1: CROSS_PAD.top,
|
|
x2: x(tick),
|
|
y2: heightPx - CROSS_PAD.bottom,
|
|
class: "b06-chart__grid",
|
|
}),
|
|
svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), {
|
|
x: x(tick),
|
|
y: heightPx - CROSS_PAD.bottom + 16,
|
|
"text-anchor": "middle",
|
|
class: "b06-chart__tick",
|
|
}),
|
|
);
|
|
}
|
|
const rawDisplaySpan = displaySpan / exaggeration;
|
|
for (let index = 0; index < 5; index += 1) {
|
|
const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4;
|
|
const displayTick = elevationMid + (tick - elevationMid) * exaggeration;
|
|
svg.append(
|
|
svgElement("line", {
|
|
x1: CROSS_PAD.left,
|
|
y1: y(displayTick),
|
|
x2: widthPx - CROSS_PAD.right,
|
|
y2: y(displayTick),
|
|
class: "b06-chart__grid",
|
|
}),
|
|
svgText(tick.toFixed(1), {
|
|
x: CROSS_PAD.left - 7,
|
|
y: y(displayTick) + 3,
|
|
"text-anchor": "end",
|
|
class: "b06-chart__tick",
|
|
}),
|
|
);
|
|
}
|
|
|
|
const segments: string[] = [];
|
|
let current: string[] = [];
|
|
for (const sample of sourceSamples) {
|
|
if (!validElevation(sample)) {
|
|
if (current.length > 1) segments.push(current.join(" "));
|
|
current = [];
|
|
continue;
|
|
}
|
|
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
|
current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`);
|
|
}
|
|
if (current.length > 1) segments.push(current.join(" "));
|
|
segments.forEach((points) =>
|
|
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
|
);
|
|
|
|
if (section.design) {
|
|
const toDisplayY = (elevation: number): number =>
|
|
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
|
appendCrossDesignOverlay(svg, section.design, x, toDisplayY);
|
|
}
|
|
|
|
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
|
|
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0))
|
|
return sample;
|
|
return nearest;
|
|
}, null);
|
|
const centerX = x(0);
|
|
// 십자선은 계획 노선(계획고) 위치를 가리킨다. 계획선이 없는 구 데이터는 지반고로 폴백한다.
|
|
const centerElevation = hasDesign
|
|
? (designElevation as number)
|
|
: (centerSample?.elevation_m ?? null);
|
|
const centerY =
|
|
centerElevation !== null
|
|
? y(elevationMid + (centerElevation - elevationMid) * exaggeration)
|
|
: heightPx / 2;
|
|
const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`;
|
|
svg.append(
|
|
svgElement("line", {
|
|
x1: CROSS_PAD.left,
|
|
y1: heightPx - CROSS_PAD.bottom,
|
|
x2: widthPx - CROSS_PAD.right,
|
|
y2: heightPx - CROSS_PAD.bottom,
|
|
class: "b06-chart__axis",
|
|
}),
|
|
svgElement("line", {
|
|
x1: CROSS_PAD.left,
|
|
y1: CROSS_PAD.top,
|
|
x2: CROSS_PAD.left,
|
|
y2: heightPx - CROSS_PAD.bottom,
|
|
class: "b06-chart__axis",
|
|
}),
|
|
svgElement("line", {
|
|
x1: centerX,
|
|
y1: centerY - 18,
|
|
x2: centerX,
|
|
y2: centerY + 18,
|
|
class: centerMarkerClass,
|
|
}),
|
|
svgElement("line", {
|
|
x1: centerX - 18,
|
|
y1: centerY,
|
|
x2: centerX + 18,
|
|
y2: centerY,
|
|
class: centerMarkerClass,
|
|
}),
|
|
svgText(L("B06_Profile_View_CrossXAxis"), {
|
|
x: widthPx / 2,
|
|
y: heightPx - 8,
|
|
"text-anchor": "middle",
|
|
class: "b06-chart__axis-label",
|
|
}),
|
|
svgText(L("B06_Profile_View_ElevationAxis"), {
|
|
x: 13,
|
|
y: heightPx / 2,
|
|
"text-anchor": "middle",
|
|
transform: `rotate(-90 13 ${heightPx / 2})`,
|
|
class: "b06-chart__axis-label",
|
|
}),
|
|
);
|
|
card.append(svg);
|
|
}
|
|
|
|
const footer = document.createElement("footer");
|
|
const center = document.createElement("span");
|
|
center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`;
|
|
const azimuth = document.createElement("span");
|
|
azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`;
|
|
footer.append(center, azimuth);
|
|
card.append(footer);
|
|
return card;
|
|
}
|