Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -45,7 +45,9 @@ import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulSummary,
|
||||
createMassHaulWindowState,
|
||||
MASS_HAUL_MIN_HEIGHT,
|
||||
scheduleMassHaulSettle,
|
||||
} from "@util/common_util_mass_haul_view";
|
||||
import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
@@ -306,7 +308,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
bar.append(empty);
|
||||
}
|
||||
|
||||
// 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04).
|
||||
const windowState = createMassHaulWindowState();
|
||||
/** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */
|
||||
let lastParams: RouteMassHaulDrawParams | null = null;
|
||||
|
||||
function draw(params: RouteMassHaulDrawParams): void {
|
||||
lastParams = params;
|
||||
clearSelection = params.onClearSelection;
|
||||
overlay.hidden = !open;
|
||||
syncHandlePosition();
|
||||
@@ -329,6 +337,8 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
}
|
||||
pendingChip.hidden = true;
|
||||
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
|
||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
const series: MassHaulSeries[] = computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
@@ -365,7 +375,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
series,
|
||||
visible,
|
||||
params.stationSource,
|
||||
params.axis,
|
||||
{ ...params.axis, window: windowState },
|
||||
params.selectedStationId,
|
||||
params.stationInterval,
|
||||
params.widthPx,
|
||||
@@ -396,6 +406,10 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
}),
|
||||
);
|
||||
drawnOnce = true;
|
||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
||||
scheduleMassHaulSettle(windowState, () => {
|
||||
if (lastParams) draw(lastParams);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -85,6 +85,9 @@ const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
|
||||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 60;
|
||||
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
|
||||
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
|
||||
const STATION_SNAP_TOLERANCE_M = 0.1;
|
||||
|
||||
/**
|
||||
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
|
||||
@@ -341,6 +344,36 @@ export function createRouteProfilePanel(
|
||||
세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */
|
||||
const profileZoom = createProfileZoom(() => draw());
|
||||
|
||||
/**
|
||||
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
|
||||
*
|
||||
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
|
||||
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
|
||||
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
|
||||
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||||
*
|
||||
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
|
||||
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
|
||||
*/
|
||||
function snapToMasterStations(stations: IrregularStation[]): IrregularStation[] {
|
||||
const master = alignment?.stations;
|
||||
if (!master?.length) return stations;
|
||||
return stations.map((station) => {
|
||||
let best: number | null = null;
|
||||
let gap = STATION_SNAP_TOLERANCE_M;
|
||||
for (const node of master) {
|
||||
const distance = Math.abs(node.chainage_m - station.chainage_m);
|
||||
if (distance <= gap) {
|
||||
gap = distance;
|
||||
best = node.chainage_m;
|
||||
}
|
||||
}
|
||||
return best === null || best === station.chainage_m
|
||||
? station
|
||||
: { ...station, chainage_m: best };
|
||||
});
|
||||
}
|
||||
|
||||
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
|
||||
let elevationWindow: { min: number; max: number } | undefined;
|
||||
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */
|
||||
@@ -652,7 +685,7 @@ export function createRouteProfilePanel(
|
||||
},
|
||||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||||
setIrregularStations(stations: IrregularStation[]) {
|
||||
irregularStations = stations;
|
||||
irregularStations = snapToMasterStations(stations);
|
||||
draw();
|
||||
},
|
||||
/** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulSummary,
|
||||
createMassHaulWindowState,
|
||||
scheduleMassHaulSettle,
|
||||
type MassHaulWindowState,
|
||||
} from "@util/common_util_mass_haul_view";
|
||||
import {
|
||||
L,
|
||||
@@ -59,6 +62,8 @@ export interface MassHaulPanelInput {
|
||||
redraw: () => void;
|
||||
/** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */
|
||||
viewRange?: { fromM: number; toM: number };
|
||||
/** 세로창 버티기·부드러운 이동 상태(2026-09-04). */
|
||||
windowState?: MassHaulWindowState;
|
||||
}
|
||||
|
||||
export interface MassHaulPanelResult {
|
||||
@@ -80,6 +85,9 @@ export interface MassHaulPanelResult {
|
||||
export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult {
|
||||
const { detail, visibleSeries } = input;
|
||||
const pendingRecalc = hasStaleDesigns(detail);
|
||||
// 계산 결과를 아껴 두지 **않는다**. 횡단 설계는 같은 객체를 제자리에서 고치므로
|
||||
// (`refreshCrossDesigns`) 객체가 같은지로는 바뀐 것을 못 잰다 — 2026-09-04 에 캐시를
|
||||
// 넣었다가 계획고를 조절해도 횡단 기준 곡선이 그대로였다(사용자 보고).
|
||||
const series: MassHaulSeries[] =
|
||||
input.conversion && !pendingRecalc
|
||||
? computeMassHaulSeries(
|
||||
@@ -108,6 +116,7 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu
|
||||
padLeft: LONG_PAD.left,
|
||||
padRight: LONG_PAD.right,
|
||||
viewRange: input.viewRange,
|
||||
window: input.windowState,
|
||||
},
|
||||
input.selectedStationId,
|
||||
input.stationInterval,
|
||||
@@ -213,7 +222,15 @@ export function createMassHaulRenderer(
|
||||
targets: MassHaulMountTargets,
|
||||
): (fromM: number, toM: number) => void {
|
||||
let mounted: MountedMassHaul = { chart: null, axis: null };
|
||||
return (fromM, toM) => {
|
||||
mounted = mountMassHaulPanel({ ...base, viewRange: { fromM, toM } }, targets, mounted);
|
||||
const windowState = createMassHaulWindowState();
|
||||
const render = (fromM: number, toM: number): void => {
|
||||
mounted = mountMassHaulPanel(
|
||||
{ ...base, viewRange: { fromM, toM }, windowState },
|
||||
targets,
|
||||
mounted,
|
||||
);
|
||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더(2026-09-04 「부드럽게」).
|
||||
scheduleMassHaulSettle(windowState, () => render(fromM, toM));
|
||||
};
|
||||
return render;
|
||||
}
|
||||
|
||||
@@ -130,6 +130,16 @@ function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: numb
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 지금 그려져 있는 눈금 글자 중 새 창 안에 남는 개수. */
|
||||
function countTicksInside(baked: Baked, scale: number, offset: number): number {
|
||||
let count = 0;
|
||||
baked.svg.querySelectorAll<SVGTextElement>(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => {
|
||||
const moved = scale * Number(label.getAttribute(Y_WINDOW_BAKED_ATTR)) + offset;
|
||||
if (moved >= baked.top && moved <= baked.top + baked.height) count += 1;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 그래프의 세로 창을 옮긴다. 대상 그래프가 없거나(고정 배율 B06) 창이 없으면 null.
|
||||
* 좌측 고정 축(`b05-profile__yaxis-tick`)도 같은 식으로 따라 옮긴다.
|
||||
@@ -147,14 +157,20 @@ export function applyElevationWindow(
|
||||
const pxPerM = (baked.exaggeration * baked.height) / span;
|
||||
const scale = pxPerM / baked.pxPerM;
|
||||
|
||||
// 배율이 크게 벌어지면 눈금 간격이 원래 의도와 어긋난다 — 눈금만 새로 만든다(요소 20개
|
||||
// 안팎). 그래프 전체를 다시 만들던 예전 방식이 34ms 끊김의 원인이었다.
|
||||
const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM;
|
||||
|
||||
// 눈금을 새로 만들 때 — 둘 중 하나만 걸려도 만든다(요소 20개 안팎이라 싸다).
|
||||
// ① 배율이 크게 벌어짐 — 눈금 간격이 원래 의도(1·2·5 계열)와 어긋난다.
|
||||
// ② **새 창에 있어야 할 눈금 수와 지금 화면에 남은 수가 다름** — 창이 위아래로 이동만
|
||||
// 하면 배율은 그대로인데 눈금이 한쪽으로 쓸려 나가 그 자리가 빈다(2026-09-04 사용자
|
||||
// 보고: 「종단 그래프 왔다갔다하면 Y축 값이 비는 경우」).
|
||||
const wanted = yWindowTickValues(center, span / baked.exaggeration, baked.height).length;
|
||||
const staying = countTicksInside(baked, scale, offset);
|
||||
let rebuiltTicks = false;
|
||||
if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX) {
|
||||
if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX || staying !== wanted) {
|
||||
rebuiltTicks = rebuildTicks(baked, root, center, span);
|
||||
}
|
||||
|
||||
const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM;
|
||||
const transform = `translate(0 ${offset}) scale(1 ${scale})`;
|
||||
baked.svg.querySelectorAll<SVGGElement>(`.${Y_WINDOW_CLASS}`).forEach((layer) => {
|
||||
layer.setAttribute("transform", transform);
|
||||
|
||||
@@ -90,7 +90,11 @@ class PipePoint:
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
# 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다.
|
||||
payload: dict[str, Any] = {
|
||||
"chainage_m": round(float(self.chainage_m), 2),
|
||||
# 누가거리는 좌표(x·y)와 **같은 밀리미터 기준**으로 남긴다. cm 로 반올림하던
|
||||
# 옛 규칙은 종단 정본 변화점(3자리)과 최대 5mm 어긋났고, 그 값으로 계획고를
|
||||
# 편집하면 정본 옆 mm 자리에 가짜 변화점이 하나 더 서서 종단곡선이 mm 로
|
||||
# 쭈그러들었다 — 그 구간 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||||
"chainage_m": round(float(self.chainage_m), 3),
|
||||
"source": self.source,
|
||||
}
|
||||
if self.facility != PIPE_FACILITY_PIPE:
|
||||
|
||||
@@ -442,7 +442,14 @@ function appendTransfer(
|
||||
* 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다
|
||||
* (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다.
|
||||
*/
|
||||
export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void {
|
||||
export function appendBalanceLayer(
|
||||
svg: SVGSVGElement,
|
||||
plan: HaulPlan,
|
||||
box: BalanceLayerBox,
|
||||
/** 실제로 붙일 자리 — 그래프 칸 밖을 자르는 겹이 있으면 그쪽(2026-09-04). 재는 일은
|
||||
* `svg`(뷰박스가 필요)가 그대로 맡는다. */
|
||||
host: SVGSVGElement | SVGGElement = svg,
|
||||
): void {
|
||||
const group = svgElement("g", { class: "b06-balance" });
|
||||
appendBalanceLine(group, plan, box);
|
||||
|
||||
@@ -482,7 +489,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
|
||||
balloons.forEach((balloon, index) => bind(balloon, index));
|
||||
// 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다.
|
||||
svg.addEventListener("click", clearAll);
|
||||
svg.append(group);
|
||||
host.append(group);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,8 +49,52 @@ export interface MassHaulAxis {
|
||||
* 전 구간 기준 ±200㎥ 고정이다.
|
||||
*/
|
||||
viewRange?: { fromM: number; toM: number };
|
||||
/** 세로창 버티기·부드러운 이동 상태. 넘기면 창이 한 칸에 확 튀지 않는다(2026-09-04). */
|
||||
window?: MassHaulWindowState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세로창이 스크롤 한 칸에 몇 배씩 튀는 것을 막는 상태(2026-09-04 사용자 확정: 「버티기 +
|
||||
* 부드럽게」). 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 폭이 한 번에
|
||||
* 4~5배로 바뀌었다(실측 228 → 1,019㎥).
|
||||
*
|
||||
* 규칙 둘.
|
||||
* ① **버티기** — 곡선이 지금 창 안에 들어오고 창을 절반 넘게 채우면 **그대로 둔다**.
|
||||
* ② **부드럽게** — 그래도 바꿔야 하면 한 번에 안 가고 다시 그릴 때마다 남은 만큼 좁힌다.
|
||||
*/
|
||||
export interface MassHaulWindowState {
|
||||
/** 지금 쓰고 있는 창. */
|
||||
held: { min: number; max: number } | null;
|
||||
/** 목표에 도착했는가 — 아니면 부르는 쪽이 다음 프레임에 다시 그린다. */
|
||||
settled: boolean;
|
||||
/** 예약해 둔 다음 프레임(중복 예약 방지). */
|
||||
frame: number;
|
||||
}
|
||||
|
||||
export function createMassHaulWindowState(): MassHaulWindowState {
|
||||
return { held: null, settled: true, frame: 0 };
|
||||
}
|
||||
|
||||
/** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */
|
||||
export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void {
|
||||
if (state.frame) cancelAnimationFrame(state.frame);
|
||||
state.frame = 0;
|
||||
if (state.settled) return;
|
||||
state.frame = requestAnimationFrame(() => {
|
||||
state.frame = 0;
|
||||
redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/** 새 창에 두는 여유(위아래 각각). 종단 그래프와 같은 20%. */
|
||||
const WINDOW_PAD_RATIO = 0.2;
|
||||
/** 곡선이 창을 이만큼 채우고 있으면 창을 그대로 둔다(버티기). */
|
||||
const WINDOW_KEEP_FILL = 0.55;
|
||||
/** 다시 그릴 때마다 목표까지 남은 거리에서 좁히는 비율. 0.2면 약 0.2초에 걸쳐 미끄러진다 —
|
||||
* 0.4는 0.08초 만에 끝나 여전히 「툭」 바뀌어 보였고, 0.15는 그리는 횟수가 늘어 프레임을
|
||||
* 더 먹었다(2026-09-04 실측). */
|
||||
const WINDOW_EASE = 0.2;
|
||||
|
||||
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
|
||||
export const MASS_HAUL_MIN_HEIGHT = 90;
|
||||
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
|
||||
@@ -137,6 +181,7 @@ const VOLUME_RANGE_BASE_M3 = 200;
|
||||
function volumeRange(
|
||||
series: MassHaulSeries[],
|
||||
viewRange?: { fromM: number; toM: number },
|
||||
state?: MassHaulWindowState,
|
||||
): { min: number; max: number } {
|
||||
let rawMin = 0;
|
||||
let rawMax = 0;
|
||||
@@ -168,8 +213,35 @@ function volumeRange(
|
||||
}
|
||||
if (found) {
|
||||
// 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다.
|
||||
const padding = Math.max((rawMax - rawMin) * 0.05, 1);
|
||||
return { min: rawMin - padding, max: rawMax + padding };
|
||||
const padding = Math.max((rawMax - rawMin) * WINDOW_PAD_RATIO, 1);
|
||||
const target = { min: rawMin - padding, max: rawMax + padding };
|
||||
if (!state) return target;
|
||||
const held = state.held;
|
||||
if (held) {
|
||||
// 버티기 — 곡선이 지금 창 안에 들어오고 절반 넘게 채우면 아예 건드리지 않는다.
|
||||
const heldSpan = held.max - held.min;
|
||||
const inside = rawMin >= held.min && rawMax <= held.max;
|
||||
const fills = rawMax - rawMin >= heldSpan * WINDOW_KEEP_FILL;
|
||||
if (inside && fills) {
|
||||
state.settled = true;
|
||||
return held;
|
||||
}
|
||||
// 부드럽게 — 남은 만큼씩 좁혀 간다(다시 그릴 때마다 한 걸음).
|
||||
const next = {
|
||||
min: held.min + (target.min - held.min) * WINDOW_EASE,
|
||||
max: held.max + (target.max - held.max) * WINDOW_EASE,
|
||||
};
|
||||
const tolerance = Math.max((target.max - target.min) * 0.01, 0.5);
|
||||
const done =
|
||||
Math.abs(next.min - target.min) <= tolerance &&
|
||||
Math.abs(next.max - target.max) <= tolerance;
|
||||
state.held = done ? target : next;
|
||||
state.settled = done;
|
||||
return state.held;
|
||||
}
|
||||
state.held = target;
|
||||
state.settled = true;
|
||||
return target;
|
||||
}
|
||||
}
|
||||
for (const entry of series) {
|
||||
@@ -241,7 +313,8 @@ function textWidth(value: string): number {
|
||||
* 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다.
|
||||
*/
|
||||
function appendVolumeCallout(
|
||||
svg: SVGSVGElement,
|
||||
/** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */
|
||||
svg: SVGSVGElement | SVGGElement,
|
||||
point: MassHaulPoint,
|
||||
stationInterval: number,
|
||||
box: CalloutPlacement,
|
||||
@@ -364,10 +437,35 @@ export function createMassHaulChart(
|
||||
// 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 —
|
||||
// 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백.
|
||||
const rangeSource = series.filter((entry) => visibleKeys.has(entry.key));
|
||||
const { min, max } = volumeRange(rangeSource.length ? rangeSource : series, axis.viewRange);
|
||||
const { min, max } = volumeRange(
|
||||
rangeSource.length ? rangeSource : series,
|
||||
axis.viewRange,
|
||||
axis.window,
|
||||
);
|
||||
const span = Math.max(max - min, 1e-6);
|
||||
const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight;
|
||||
|
||||
// 그래프 칸 밖으로 나간 것은 그리지 않는다(2026-09-04 사용자 보고) — 보이는 구간에
|
||||
// 세로를 맞추면 0선과 그 채움색이 눈금 상한을 넘어 **범례·토글 버튼 위까지** 칠해졌다.
|
||||
// 종단 그래프는 진작 자르고 있었는데 유토곡선만 빠져 있었다.
|
||||
const plotBottom = heightPx - MASS_PAD_BOTTOM;
|
||||
const clipId = `b06-mass-clip-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const clip = svgElement("clipPath", { id: clipId });
|
||||
clip.append(
|
||||
svgElement("rect", {
|
||||
x: axisX,
|
||||
y: padTop,
|
||||
width: Math.max(0, widthPx - axis.padRight - axisX),
|
||||
height: Math.max(0, plotHeight),
|
||||
}),
|
||||
);
|
||||
const defs = svgElement("defs", {});
|
||||
defs.append(clip);
|
||||
// 두 겹으로 나누는 이유: 측점 세로선이 면과 곡선 **사이**에 서야 한다(그리는 순서 유지).
|
||||
const plotBack = svgElement("g", { "clip-path": `url(#${clipId})` });
|
||||
const plotFront = svgElement("g", { "clip-path": `url(#${clipId})` });
|
||||
svg.append(defs);
|
||||
|
||||
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
|
||||
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
|
||||
const axisTicks: Array<{ y: number; label: string }> = [];
|
||||
@@ -395,8 +493,10 @@ export function createMassHaulChart(
|
||||
}
|
||||
|
||||
const zeroY = y(0);
|
||||
// 0선은 격자보다 진하게 따로 그리므로 고정 축 눈금에도 함께 실어 준다.
|
||||
axisTicks.push({ y: zeroY, label: "0㎥" });
|
||||
// 0선이 창 안에 있을 때만 눈금에 싣는다 — 밖으로 나갔는데도 실으면 고정 축 맨 위에
|
||||
// 남의 값과 겹쳐 찍힌다(2026-09-04 사용자 보고).
|
||||
const zeroInside = zeroY >= padTop && zeroY <= plotBottom;
|
||||
if (zeroInside) axisTicks.push({ y: zeroY, label: "0㎥" });
|
||||
onAxis?.({ padLeft: axisX, ticks: axisTicks });
|
||||
const visible = series.filter((entry) => visibleKeys.has(entry.key));
|
||||
|
||||
@@ -420,7 +520,7 @@ export function createMassHaulChart(
|
||||
const banded = visible[0];
|
||||
if (banded) {
|
||||
const last = banded.result.points[banded.result.points.length - 1];
|
||||
svg.append(
|
||||
plotBack.append(
|
||||
svgElement("path", {
|
||||
// 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다.
|
||||
d:
|
||||
@@ -431,6 +531,8 @@ export function createMassHaulChart(
|
||||
);
|
||||
}
|
||||
|
||||
svg.append(plotBack);
|
||||
|
||||
// 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화).
|
||||
// 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지).
|
||||
for (const station of longitudinal.stations) {
|
||||
@@ -465,27 +567,33 @@ export function createMassHaulChart(
|
||||
svg.append(marker);
|
||||
}
|
||||
|
||||
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다.
|
||||
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다.
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: axisX,
|
||||
y1: zeroY,
|
||||
x2: widthPx - axis.padRight,
|
||||
y2: zeroY,
|
||||
class: "b06-masshaul__zero",
|
||||
}),
|
||||
svgText("0", {
|
||||
x: axisX - 9,
|
||||
y: zeroY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick b06-masshaul__zero-tick",
|
||||
}),
|
||||
);
|
||||
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다.
|
||||
// 보이는 구간에 세로를 맞추면 0이 창 밖으로 나갈 수 있다(2026-09-04 사용자 확정:
|
||||
// 「나가도 됨」) — 그때는 아예 그리지 않는다. 글자는 축 **왼쪽**이라 자르는 겹에 넣으면
|
||||
// 사라지므로 따로 붙인다.
|
||||
if (zeroInside) {
|
||||
plotFront.append(
|
||||
svgElement("line", {
|
||||
x1: axisX,
|
||||
y1: zeroY,
|
||||
x2: widthPx - axis.padRight,
|
||||
y2: zeroY,
|
||||
class: "b06-masshaul__zero",
|
||||
}),
|
||||
);
|
||||
svg.append(
|
||||
svgText("0", {
|
||||
x: axisX - 9,
|
||||
y: zeroY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick b06-masshaul__zero-tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
|
||||
for (const entry of [...visible].reverse()) {
|
||||
svg.append(
|
||||
plotFront.append(
|
||||
svgElement("path", {
|
||||
d: curvePath(entry.result.points, x, y),
|
||||
class: seriesClass(entry, "b06-masshaul__curve"),
|
||||
@@ -504,7 +612,7 @@ export function createMassHaulChart(
|
||||
// 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다.
|
||||
const labelX = Math.max(epX - 8, axis.padLeft + labelWidth);
|
||||
const labelY = Math.min(Math.max(epY - 8, padTop + 12), heightPx - MASS_PAD_BOTTOM - 4);
|
||||
svg.append(
|
||||
plotFront.append(
|
||||
svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }),
|
||||
svgElement("rect", {
|
||||
x: labelX - labelWidth,
|
||||
@@ -527,23 +635,29 @@ export function createMassHaulChart(
|
||||
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
|
||||
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
|
||||
if (haulPlan) {
|
||||
appendBalanceLayer(svg, haulPlan, {
|
||||
x,
|
||||
y,
|
||||
left: axis.padLeft,
|
||||
right: widthPx - axis.padRight,
|
||||
top: padTop,
|
||||
bottom: heightPx - MASS_PAD_BOTTOM,
|
||||
curveYAt,
|
||||
stationInterval,
|
||||
});
|
||||
appendBalanceLayer(
|
||||
svg,
|
||||
haulPlan,
|
||||
{
|
||||
x,
|
||||
y,
|
||||
left: axis.padLeft,
|
||||
right: widthPx - axis.padRight,
|
||||
top: padTop,
|
||||
bottom: heightPx - MASS_PAD_BOTTOM,
|
||||
curveYAt,
|
||||
stationInterval,
|
||||
},
|
||||
// 그리는 자리는 자르는 겹 — 창 밖으로 나간 평형선·말풍선이 눈금 위로 새지 않는다.
|
||||
plotFront,
|
||||
);
|
||||
}
|
||||
|
||||
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
|
||||
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
|
||||
if (focus) {
|
||||
const focusX = x(focus.chainage_m);
|
||||
svg.append(
|
||||
plotFront.append(
|
||||
svgElement("circle", {
|
||||
cx: focusX,
|
||||
cy: y(focus.cumulative_volume_m3),
|
||||
@@ -551,7 +665,7 @@ export function createMassHaulChart(
|
||||
class: "b06-masshaul__focus",
|
||||
}),
|
||||
);
|
||||
appendVolumeCallout(svg, focus, stationInterval, {
|
||||
appendVolumeCallout(plotFront, focus, stationInterval, {
|
||||
anchorX: focusX,
|
||||
anchorY: y(focus.cumulative_volume_m3),
|
||||
left: axis.padLeft,
|
||||
@@ -562,6 +676,8 @@ export function createMassHaulChart(
|
||||
});
|
||||
}
|
||||
|
||||
svg.append(plotFront);
|
||||
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: axisX,
|
||||
|
||||
Reference in New Issue
Block a user