fix(B06): 유토곡선을 포물선 경로로 렌더 + balloon 위치 영구 보관·초기화
[1] 수평선이 곡선에서 떨어져 보이던 문제. 엔진은 포물선으로 교점을 잡는데 곡선만 직선(polyline)으로 그리고 있었다. 평형선·장비 경계현·평균운반거리선의 양 끝이 곡선 위가 아니게 되어 어긋나 보였다. 곡선을 2차 베지에 경로로 바꿨다. 제어점 (x0 + Δ/2, V0 + a0Δ/2)에 두면 2차 베지에가 구간 포물선과 정확히 같아진다. 0선 아래 음영도 같은 경로로 닫고, 띠 테두리도 측점만 잇던 것을 구간마다 4등분해 곡선에 붙였다. [2] balloon 위치 저장 경로를 둘로 나눴다. - 프론트 캐시(localStorage, 프로젝트+경로별 키): 끌어 옮기는 즉시 - 영구저장소: 횡단 확정 시 mass_haul.balloon_offsets로 저장 진입 시에는 영구저장소 값이 이긴다 - 다른 브라우저에서도 같은 자리에 뜬다. 상세 응답에 balloon_offsets를 실어 내려보낸다. [3] 범례 줄에 "도형 위치 초기화" 버튼(분배 레이어를 켰을 때만). 프론트 캐시를 비우고 다시 그리며, 비워진 값이 다음 확정 때 영구저장소를 덮는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,9 @@ export interface LongitudinalSection {
|
||||
profile_alignment?: unknown;
|
||||
}
|
||||
|
||||
/** 유토곡선 balloon을 끌어 옮긴 위치(띠 번호 → [dx, dy], px). */
|
||||
export type BalloonOffsets = Record<string, [number, number]>;
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
samples: SectionSample[];
|
||||
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
|
||||
@@ -192,6 +195,8 @@ export interface CrossSection extends SectionStation {
|
||||
export interface SectionDetailResponse {
|
||||
longitudinal: LongitudinalSection;
|
||||
cross_sections: CrossSection[];
|
||||
/** 확정 시 저장해 둔 유토곡선 balloon 위치. 브라우저가 바뀌어도 같은 자리에 뜬다. */
|
||||
balloon_offsets?: BalloonOffsets | null;
|
||||
}
|
||||
|
||||
/** 종횡단 확정 결과 (SectionConfirmResponse) */
|
||||
|
||||
@@ -224,6 +224,29 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||
|
||||
|
||||
def _read_balloon_offsets(row: dict[str, Any]) -> dict[str, list[float]] | None:
|
||||
"""확정 시 저장해 둔 유토곡선 balloon 위치를 꺼낸다. 없거나 형태가 깨졌으면 None."""
|
||||
data = row.get("data")
|
||||
if isinstance(data, (str, bytes)):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
offsets = (data.get("mass_haul") or {}).get("balloon_offsets")
|
||||
if not isinstance(offsets, dict):
|
||||
return None
|
||||
cleaned: dict[str, list[float]] = {}
|
||||
for key, value in offsets.items():
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
try:
|
||||
cleaned[str(key)] = [float(value[0]), float(value[1])]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return cleaned or None
|
||||
|
||||
|
||||
@router.get("/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse)
|
||||
async def get_section_detail(
|
||||
project_id: UUID, route_id: int
|
||||
@@ -257,7 +280,7 @@ async def get_section_detail(
|
||||
await asyncio.to_thread(
|
||||
_attach_default_designs, detail["longitudinal"], detail["cross_sections"]
|
||||
)
|
||||
return SectionDetailResponse(**detail)
|
||||
return SectionDetailResponse(**detail, balloon_offsets=_read_balloon_offsets(longitudinal))
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
|
||||
@@ -164,3 +164,7 @@ class SectionDetailResponse(BaseModel):
|
||||
|
||||
longitudinal: dict[str, Any]
|
||||
cross_sections: list[dict[str, Any]]
|
||||
# 유토곡선 balloon을 사용자가 끌어 옮긴 위치(띠 번호 -> [dx, dy], px).
|
||||
# longitudinal_sections.data.mass_haul.balloon_offsets에 확정 시점에 저장된 값이며,
|
||||
# 브라우저가 바뀌어도 같은 자리에 뜨도록 진입 시 프론트 캐시의 씨앗으로 내려보낸다.
|
||||
balloon_offsets: dict[str, list[float]] | None = None
|
||||
|
||||
@@ -410,12 +410,15 @@ export function computeMassHaulSeries(
|
||||
export function massHaulPayload(
|
||||
result: MassHaulResult,
|
||||
haulPlan?: HaulPlan | null,
|
||||
balloonOffsets?: Record<string, [number, number]>,
|
||||
): Record<string, unknown> {
|
||||
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||
return {
|
||||
basis: "compacted",
|
||||
conversion: result.conversion,
|
||||
...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}),
|
||||
// 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다).
|
||||
...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}),
|
||||
cut_natural_m3: {
|
||||
soil: round(result.cut_natural_m3.soil),
|
||||
ripping_rock: round(result.cut_natural_m3.ripping_rock),
|
||||
|
||||
@@ -52,8 +52,10 @@ import type { MassHaulPoint, MassHaulResult } from "./B06_wf3_ProfileCross_UI_Ma
|
||||
import {
|
||||
crossFrom,
|
||||
EPSILON,
|
||||
evaluate,
|
||||
extremaIndices,
|
||||
pruneExtrema,
|
||||
segmentOf,
|
||||
} from "./B06_wf3_ProfileCross_UI_MassHaul_Curve";
|
||||
|
||||
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
|
||||
@@ -186,6 +188,8 @@ const MIN_SWING_RATIO = 0.02;
|
||||
const CHORD_SCAN_STEPS = 64;
|
||||
/** 훑어서 찾은 칸 안에서 좁히는 이분법 반복 수. 24회면 칸 폭의 1/1600만 남는다. */
|
||||
const CHORD_SOLVE_STEPS = 24;
|
||||
/** 띠 테두리를 곡선(포물선)에 붙이려고 구간 하나를 쪼개는 수. */
|
||||
const OUTLINE_STEPS = 4;
|
||||
|
||||
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
|
||||
const factor = conversion?.[ground]?.compacted;
|
||||
@@ -323,10 +327,23 @@ function bandOutline(
|
||||
lowLevel: number,
|
||||
highLevel: number,
|
||||
): CurvePoint[] {
|
||||
const between = (a: number, b: number): CurvePoint[] =>
|
||||
points
|
||||
.filter((point) => point.chainage_m > a && point.chainage_m < b)
|
||||
.map((point) => ({ m: point.chainage_m, v: point.cumulative_volume_m3 }));
|
||||
// 띠 테두리도 **곡선과 같은 포물선**을 따라야 한다 — 측점만 이으면 직선이 되어
|
||||
// 곡선과 벌어진다. 구간마다 잘게 쪼개 실제 곡선 위 점을 찍는다.
|
||||
const between = (a: number, b: number): CurvePoint[] => {
|
||||
const out: CurvePoint[] = [];
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const segment = segmentOf(points, index);
|
||||
if (!segment) continue;
|
||||
const low = Math.max(segment.x0, a);
|
||||
const high = Math.min(segment.x0 + segment.span, b);
|
||||
if (!(high > low)) continue;
|
||||
for (let step = 1; step <= OUTLINE_STEPS; step += 1) {
|
||||
const m = low + ((high - low) * step) / OUTLINE_STEPS;
|
||||
out.push({ m, v: evaluate(segment, m - segment.x0) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return [
|
||||
{ m: low.from, v: lowLevel },
|
||||
...between(low.from, high.from),
|
||||
|
||||
@@ -71,36 +71,68 @@ const MIN_BAND_WIDTH_PX = 34;
|
||||
const MIN_BAND_THICKNESS_PX = 9;
|
||||
|
||||
/**
|
||||
* 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]). 다시 그릴 때마다 자동 배치로
|
||||
* 되돌아가면 옮긴 보람이 없으므로 세션에 남긴다(패널 높이·접힘과 같은 규칙).
|
||||
* 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]).
|
||||
*
|
||||
* 저장 경로가 둘이다:
|
||||
* ① **프론트 캐시**(localStorage) — 끌어 옮기는 즉시. 새로고침·재접속에도 남는다.
|
||||
* ② **영구저장소** — 횡단 확정 시점에 `mass_haul.balloon_offsets`로 넘어간다.
|
||||
* 진입할 때는 ②가 있으면 그것으로 캐시를 덮어써 **다른 브라우저에서도 같은 자리**에 뜬다
|
||||
* (2026-08-02 사용자 지시). 캐시는 경로별로 갈라 다른 노선의 위치가 섞이지 않게 한다.
|
||||
*/
|
||||
const BALLOON_OFFSET_KEY = "b06:balance-balloon-offset";
|
||||
const BALLOON_OFFSET_PREFIX = "b06:balance-balloon-offset";
|
||||
let offsetStorageKey = BALLOON_OFFSET_PREFIX;
|
||||
let balloonOffsets = new Map<number, [number, number]>();
|
||||
|
||||
function readOffsets(): Map<number, [number, number]> {
|
||||
function parseOffsets(source: unknown): Map<number, [number, number]> {
|
||||
const result = new Map<number, [number, number]>();
|
||||
if (!source || typeof source !== "object") return result;
|
||||
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
||||
if (!Array.isArray(value) || value.length !== 2) continue;
|
||||
const [dx, dy] = value as [number, number];
|
||||
if (Number.isFinite(dx) && Number.isFinite(dy)) result.set(Number(key), [dx, dy]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeOffsets(): void {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(BALLOON_OFFSET_KEY);
|
||||
if (!raw) return new Map();
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") return new Map();
|
||||
return new Map(
|
||||
Object.entries(parsed as Record<string, [number, number]>).map(([key, value]) => [
|
||||
Number(key),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
localStorage.setItem(offsetStorageKey, JSON.stringify(balloonOffsetsPayload()));
|
||||
} catch {
|
||||
return new Map();
|
||||
/* 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
function writeOffsets(offsets: Map<number, [number, number]>): void {
|
||||
/**
|
||||
* 노선이 바뀌거나 상세를 새로 받았을 때 캐시를 맞춘다.
|
||||
* `stored`(영구저장소 값)가 있으면 **그것이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로
|
||||
* 받아야 하기 때문이다. 없으면 이 브라우저의 캐시를 그대로 이어 쓴다.
|
||||
*/
|
||||
export function configureBalloonOffsets(scope: string, stored?: unknown): void {
|
||||
offsetStorageKey = `${BALLOON_OFFSET_PREFIX}:${scope}`;
|
||||
if (stored && Object.keys(stored as object).length) {
|
||||
balloonOffsets = parseOffsets(stored);
|
||||
writeOffsets();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
BALLOON_OFFSET_KEY,
|
||||
JSON.stringify(Object.fromEntries([...offsets].map(([key, value]) => [String(key), value]))),
|
||||
);
|
||||
balloonOffsets = parseOffsets(JSON.parse(localStorage.getItem(offsetStorageKey) ?? "null"));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */
|
||||
balloonOffsets = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/** 확정 시 영구저장소로 넘길 형태. */
|
||||
export function balloonOffsetsPayload(): Record<string, [number, number]> {
|
||||
return Object.fromEntries([...balloonOffsets].map(([key, value]) => [String(key), value]));
|
||||
}
|
||||
|
||||
/** 자동 배치로 되돌린다 — 프론트 캐시를 비우고, 다음 확정 때 영구저장소도 빈 값으로 덮인다. */
|
||||
export function resetBalloonOffsets(): void {
|
||||
balloonOffsets = new Map();
|
||||
try {
|
||||
localStorage.removeItem(offsetStorageKey);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +282,6 @@ function attachBalloonDrag(
|
||||
leader: SVGLineElement,
|
||||
svg: SVGSVGElement,
|
||||
key: number,
|
||||
offsets: Map<number, [number, number]>,
|
||||
home: { x: number; y: number },
|
||||
): void {
|
||||
let start: { x: number; y: number; dx: number; dy: number } | null = null;
|
||||
@@ -269,7 +300,7 @@ function attachBalloonDrag(
|
||||
// 카드·측점 선택으로 번지면 그래프가 다시 그려져 끌던 balloon이 사라진다.
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const current = offsets.get(key) ?? [0, 0];
|
||||
const current = balloonOffsets.get(key) ?? [0, 0];
|
||||
start = { x: event.clientX, y: event.clientY, dx: current[0], dy: current[1] };
|
||||
balloon.setPointerCapture(event.pointerId);
|
||||
balloon.classList.add("is-dragging");
|
||||
@@ -279,7 +310,7 @@ function attachBalloonDrag(
|
||||
const factor = scale();
|
||||
const dx = start.dx + (event.clientX - start.x) * factor;
|
||||
const dy = start.dy + (event.clientY - start.y) * factor;
|
||||
offsets.set(key, [dx, dy]);
|
||||
balloonOffsets.set(key, [dx, dy]);
|
||||
apply(dx, dy);
|
||||
});
|
||||
const finish = (event: PointerEvent): void => {
|
||||
@@ -287,16 +318,16 @@ function attachBalloonDrag(
|
||||
start = null;
|
||||
balloon.releasePointerCapture(event.pointerId);
|
||||
balloon.classList.remove("is-dragging");
|
||||
writeOffsets(offsets);
|
||||
writeOffsets();
|
||||
};
|
||||
balloon.addEventListener("pointerup", finish);
|
||||
balloon.addEventListener("pointercancel", finish);
|
||||
// 두 번 누르면 자동 배치로 되돌린다 — 잘못 끌었을 때 되돌릴 길을 남긴다.
|
||||
balloon.addEventListener("dblclick", (event) => {
|
||||
event.stopPropagation();
|
||||
offsets.delete(key);
|
||||
balloonOffsets.delete(key);
|
||||
apply(0, 0);
|
||||
writeOffsets(offsets);
|
||||
writeOffsets();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,7 +414,6 @@ function appendBandBalloon(
|
||||
band: HaulBand,
|
||||
box: BalanceLayerBox,
|
||||
placed: PlacedBox[],
|
||||
offsets: Map<number, [number, number]>,
|
||||
): SVGGElement | null {
|
||||
if (box.x(block.to_m) - box.x(block.from_m) < MIN_BAND_WIDTH_PX) return null;
|
||||
|
||||
@@ -448,13 +478,13 @@ function appendBandBalloon(
|
||||
);
|
||||
group.append(leader, balloon);
|
||||
|
||||
const saved = offsets.get(band.index);
|
||||
const saved = balloonOffsets.get(band.index);
|
||||
if (saved) {
|
||||
balloon.setAttribute("transform", `translate(${saved[0]} ${saved[1]})`);
|
||||
leader.setAttribute("x2", String(home.x + saved[0]));
|
||||
leader.setAttribute("y2", String(home.y + saved[1]));
|
||||
}
|
||||
attachBalloonDrag(balloon, leader, svg, band.index, offsets, home);
|
||||
attachBalloonDrag(balloon, leader, svg, band.index, home);
|
||||
return balloon;
|
||||
}
|
||||
|
||||
@@ -580,10 +610,9 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
|
||||
for (const band of block.bands) faces.push(appendBandGeometry(group, block, band, box));
|
||||
}
|
||||
const placed: PlacedBox[] = [];
|
||||
const offsets = readOffsets();
|
||||
for (const block of plan.blocks) {
|
||||
for (const band of block.bands)
|
||||
balloons.push(appendBandBalloon(group, svg, block, band, box, placed, offsets));
|
||||
balloons.push(appendBandBalloon(group, svg, block, band, box, placed));
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { HaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
|
||||
import type { LocaleKey } from "@ui/ui_template_locale";
|
||||
import { MASS_HAUL_BALANCE_KEY } from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import { appendBalanceLayer, haulPlanChips } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View";
|
||||
import { segmentOf } from "./B06_wf3_ProfileCross_UI_MassHaul_Curve";
|
||||
import {
|
||||
L,
|
||||
LONG_PAD,
|
||||
@@ -62,6 +63,40 @@ function seriesClass(series: MassHaulSeries, base: string): string {
|
||||
return `${base} ${base}--${series.basis}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선을 **2차 베지에 경로**로 그린다. 유토곡선은 측점 사이에서 포물선이므로
|
||||
* (단면적이 선형 → 그 적분인 누가토량은 2차식) 직선으로 이으면 실제 곡선과 어긋난다.
|
||||
*
|
||||
* 이게 눈에 보이는 이유는 **수평선 때문**이다. 평형선·장비 경계현·평균운반거리선의 양 끝은
|
||||
* 포물선과의 교점으로 계산되는데, 곡선만 직선으로 그리면 그 끝점이 곡선에서 떨어져 보인다
|
||||
* (2026-08-02 사용자 지적). 두 곳의 기하를 같은 것으로 맞춘다.
|
||||
*
|
||||
* 제어점은 `(x₀ + Δ/2, V₀ + a₀Δ/2)` — 이 자리에 두면 2차 베지에가 구간 포물선과
|
||||
* **정확히** 같아진다. 순단면적이 없으면 곡률이 0이 되어 자동으로 직선으로 떨어진다.
|
||||
*/
|
||||
function curvePath(
|
||||
points: MassHaulPoint[],
|
||||
x: (chainage: number) => number,
|
||||
y: (volume: number) => number,
|
||||
): string {
|
||||
if (!points.length) return "";
|
||||
const parts = [`M ${x(points[0].chainage_m)},${y(points[0].cumulative_volume_m3)}`];
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const point = points[index];
|
||||
const segment = segmentOf(points, index);
|
||||
if (!segment) {
|
||||
parts.push(`L ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
|
||||
continue;
|
||||
}
|
||||
const controlX = segment.x0 + segment.span / 2;
|
||||
const controlY = segment.v0 + (segment.a0 * segment.span) / 2;
|
||||
parts.push(
|
||||
`Q ${x(controlX)},${y(controlY)} ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`,
|
||||
);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */
|
||||
export function formatVolume(value: number): string {
|
||||
return value.toLocaleString(undefined, {
|
||||
@@ -303,15 +338,13 @@ export function createMassHaulChart(
|
||||
// 면(band)은 첫 번째 표시 곡선에만 깐다 — 여러 곡선에 겹쳐 칠하면 서로 가려 못 읽는다.
|
||||
const banded = visible[0];
|
||||
if (banded) {
|
||||
const areaTop: string[] = [];
|
||||
const areaBottom: string[] = [];
|
||||
for (const point of banded.result.points) {
|
||||
areaTop.push(`${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
|
||||
areaBottom.unshift(`${x(point.chainage_m)},${zeroY}`);
|
||||
}
|
||||
const last = banded.result.points[banded.result.points.length - 1];
|
||||
svg.append(
|
||||
svgElement("polygon", {
|
||||
points: [...areaTop, ...areaBottom].join(" "),
|
||||
svgElement("path", {
|
||||
// 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다.
|
||||
d:
|
||||
`${curvePath(banded.result.points, x, y)} ` +
|
||||
`L ${x(last.chainage_m)},${zeroY} L ${x(banded.result.points[0].chainage_m)},${zeroY} Z`,
|
||||
class: seriesClass(banded, "b06-masshaul__band"),
|
||||
}),
|
||||
);
|
||||
@@ -372,10 +405,8 @@ export function createMassHaulChart(
|
||||
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
|
||||
for (const entry of [...visible].reverse()) {
|
||||
svg.append(
|
||||
svgElement("polyline", {
|
||||
points: entry.result.points
|
||||
.map((point) => `${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`)
|
||||
.join(" "),
|
||||
svgElement("path", {
|
||||
d: curvePath(entry.result.points, x, y),
|
||||
class: seriesClass(entry, "b06-masshaul__curve"),
|
||||
}),
|
||||
);
|
||||
@@ -473,6 +504,7 @@ export function createMassHaulLegend(
|
||||
series: MassHaulSeries[],
|
||||
visibleKeys: ReadonlySet<string>,
|
||||
onToggle: (key: string) => void,
|
||||
onResetBalloons?: () => void,
|
||||
): HTMLElement {
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "b06-masshaul__legend";
|
||||
@@ -504,6 +536,16 @@ export function createMassHaulLegend(
|
||||
L("B06_MassHaul_Balance_Layer"),
|
||||
"b06-masshaul__legend-item b06-masshaul__legend-item--balance",
|
||||
);
|
||||
// 끌어 옮긴 balloon을 한 번에 자동 배치로 되돌린다(프론트 캐시 + 다음 확정 시 영구저장소).
|
||||
if (onResetBalloons && visibleKeys.has(MASS_HAUL_BALANCE_KEY)) {
|
||||
const reset = document.createElement("button");
|
||||
reset.type = "button";
|
||||
reset.className = "b06-masshaul__legend-item b06-masshaul__legend-item--reset";
|
||||
reset.textContent = L("B06_MassHaul_Balloon_Reset");
|
||||
reset.title = L("B06_MassHaul_Balloon_Reset_Tip");
|
||||
reset.addEventListener("click", onResetBalloons);
|
||||
legend.append(reset);
|
||||
}
|
||||
return legend;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "./B06_wf3_ProfileCross_UI_Section_View";
|
||||
import { computeMassHaul, massHaulPayload } from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
|
||||
import { balloonOffsetsPayload } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View";
|
||||
import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common";
|
||||
import {
|
||||
createStandardPanel,
|
||||
@@ -354,6 +355,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationInterval,
|
||||
context?.earthwork_conversion,
|
||||
context?.haul_equipment_limits,
|
||||
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -401,7 +403,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
standardPanel?.getValues(),
|
||||
crossPatches.length ? crossPatches : undefined,
|
||||
massHaul
|
||||
? massHaulPayload(massHaul, computeHaulPlan(massHaul, context?.haul_equipment_limits))
|
||||
? massHaulPayload(
|
||||
massHaul,
|
||||
computeHaulPlan(massHaul, context?.haul_equipment_limits),
|
||||
// 확정 시점에 프론트 캐시의 balloon 위치를 영구저장소로 넘긴다.
|
||||
balloonOffsetsPayload(),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
|
||||
@@ -39,6 +39,10 @@ import {
|
||||
type MassHaulSeries,
|
||||
} from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
|
||||
import {
|
||||
configureBalloonOffsets,
|
||||
resetBalloonOffsets,
|
||||
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View";
|
||||
import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
@@ -144,6 +148,8 @@ export interface SectionViewController {
|
||||
stationInterval?: number,
|
||||
earthworkConversion?: EarthworkConversion,
|
||||
haulEquipmentLimits?: HaulEquipmentLimit[],
|
||||
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
|
||||
balloonScope?: string,
|
||||
) => void;
|
||||
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
|
||||
refreshCard: (chainageM: number) => void;
|
||||
@@ -492,7 +498,10 @@ export function createSectionView(
|
||||
// 밖**에서 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 끼어들어 두 그래프의
|
||||
// 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). 세로 위치는 종단면도 높이로 잡는다.
|
||||
if (series.length) {
|
||||
const legend = createMassHaulLegend(series, visibleSeries, toggleSeries);
|
||||
const legend = createMassHaulLegend(series, visibleSeries, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
drawPanel();
|
||||
});
|
||||
legend.style.top = `${heights.long + 6}px`;
|
||||
panelBody.append(legend);
|
||||
}
|
||||
@@ -616,6 +625,7 @@ export function createSectionView(
|
||||
stationInterval,
|
||||
earthworkConversion,
|
||||
haulEquipmentLimits,
|
||||
balloonScope,
|
||||
) {
|
||||
currentDetail = detail;
|
||||
currentExaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
@@ -625,6 +635,8 @@ export function createSectionView(
|
||||
stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined;
|
||||
if (earthworkConversion) currentConversion = earthworkConversion;
|
||||
if (haulEquipmentLimits?.length) currentHaulLimits = haulEquipmentLimits;
|
||||
// balloon 위치는 **영구저장소 값이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 받는다.
|
||||
configureBalloonOffsets(balloonScope ?? "default", detail.balloon_offsets ?? undefined);
|
||||
// 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로
|
||||
// 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다.
|
||||
renderWidth = contentWidth();
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 도형 위치 초기화 — 곡선 토글이 아니라 동작 버튼이라 스와치를 두지 않는다. */
|
||||
.b06-masshaul__legend-item--reset {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b06-masshaul__legend-item.is-off {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,11 @@ export const ui_locales_b2 = {
|
||||
/* 검산 — 성토는 전부 운반으로 채워지므로 운반 합계 = 총 성토량이어야 한다. */
|
||||
B06_MassHaul_Check: ["운반·성토 검산", "Haul vs fill check"],
|
||||
B06_MassHaul_Check_Ok: ["일치", "Balanced"],
|
||||
B06_MassHaul_Balloon_Reset: ["도형 위치 초기화", "Reset label positions"],
|
||||
B06_MassHaul_Balloon_Reset_Tip: [
|
||||
"끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. 횡단을 확정하면 저장된 위치도 함께 지워집니다.",
|
||||
"Restores dragged mass-haul labels to automatic placement. Confirming the sections also clears the stored positions.",
|
||||
],
|
||||
|
||||
/* --- B06 측점 표준횡단 설계 지정 --- */
|
||||
B06_Design_Ground_Legend: ["지반유형", "Ground type"],
|
||||
|
||||
Reference in New Issue
Block a user