diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 70ec9bbc..eb8ae930 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -183,6 +183,9 @@ export interface LongitudinalSection { profile_alignment?: unknown; } +/** 유토곡선 balloon을 끌어 옮긴 위치(띠 번호 → [dx, dy], px). */ +export type BalloonOffsets = Record; + 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) */ diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 67fb67e7..e3fe5d42 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -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: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 81d99d39..c81917d3 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -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 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts index 7aafdd8e..79a0d75a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts @@ -410,12 +410,15 @@ export function computeMassHaulSeries( export function massHaulPayload( result: MassHaulResult, haulPlan?: HaulPlan | null, + balloonOffsets?: Record, ): Record { 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), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts index 5b44516f..61981da6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts @@ -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), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts index 62bcffd2..e6b7eb9c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts @@ -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(); -function readOffsets(): Map { +function parseOffsets(source: unknown): Map { + const result = new Map(); + if (!source || typeof source !== "object") return result; + for (const [key, value] of Object.entries(source as Record)) { + 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).map(([key, value]) => [ - Number(key), - value, - ]), - ); + localStorage.setItem(offsetStorageKey, JSON.stringify(balloonOffsetsPayload())); } catch { - return new Map(); + /* 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */ } } -function writeOffsets(offsets: Map): 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 { + 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, 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, ): 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 => { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts index 38565f26..318ed37f 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -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, 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; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index ffb646db..e3e939cc 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -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 { stationInterval, context?.earthwork_conversion, context?.haul_equipment_limits, + `${projectId ?? "-"}:${currentRouteId ?? "-"}`, ); } } @@ -401,7 +403,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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"); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 4471ffd0..746d78b8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -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(); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css index 2befd11c..e2e40ada 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -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); } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 33fc5927..c116b96c 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],