Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Api_HaulPlan.ts
|
||||
* 유토 **배분**(평형선·운반거리·장비)을 서버에서 미리 받아 두는 자리.
|
||||
*
|
||||
* 왜 서버인가(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에
|
||||
* 남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul`), 그 결과를
|
||||
* 여기로 보내 배분을 받아 쥔다. 계산은 여전히 한 벌이다 — 서버가 같은 TS 를 Node 로 돈다.
|
||||
*
|
||||
* **조용히 따라오게 한다** — 편집이 멈추면 뒤에서 물어 두므로, 유토곡선 패널을 펼치는
|
||||
* 순간에는 이미 도착해 있다. 늦게 온 응답은 버린다(최신 요청만 채택).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
|
||||
/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다.
|
||||
* `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */
|
||||
export type HaulPlanPayload = HaulPlan | null;
|
||||
|
||||
/** 편집이 멈춘 것으로 볼 시간(ms). 계획고를 연속으로 누르는 동안은 안 보낸다. */
|
||||
const SETTLE_MS = 400;
|
||||
/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */
|
||||
const TIMEOUT_MS = 20000;
|
||||
|
||||
export interface HaulPlanPrefetch {
|
||||
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
|
||||
schedule: (result: unknown) => void;
|
||||
/** 지금 쥐고 있는 배분. 아직 못 받았으면 null. */
|
||||
current: () => HaulPlanPayload;
|
||||
/** 화면을 떠날 때 예약을 지운다. */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배분 선반입기를 만든다. `onReady` 는 값이 새로 도착했을 때만 불린다 —
|
||||
* 부르는 쪽은 그때 곡선을 다시 그리면 된다.
|
||||
*/
|
||||
export function createHaulPlanPrefetch(
|
||||
projectId: string,
|
||||
routeId: () => number | undefined,
|
||||
onReady: () => void,
|
||||
): HaulPlanPrefetch {
|
||||
let timer = 0;
|
||||
let sequence = 0;
|
||||
let plan: HaulPlanPayload = null;
|
||||
let pending: unknown = null;
|
||||
|
||||
async function send(result: unknown, seq: number): Promise<void> {
|
||||
const route = routeId();
|
||||
if (!route) return;
|
||||
const controller = new AbortController();
|
||||
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result }),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
|
||||
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
||||
if (seq !== sequence) return;
|
||||
plan = payload.haul_plan ?? null;
|
||||
onReady();
|
||||
} catch {
|
||||
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
|
||||
} finally {
|
||||
window.clearTimeout(abort);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(result) {
|
||||
pending = result;
|
||||
sequence += 1;
|
||||
const seq = sequence;
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void send(pending, seq), SETTLE_MS);
|
||||
},
|
||||
current: () => plan,
|
||||
dispose() {
|
||||
window.clearTimeout(timer);
|
||||
sequence += 1; // 남아 있는 응답을 모두 무효로 만든다.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from "@util/common_util_mass_haul_types";
|
||||
import type { MassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import type { HaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan";
|
||||
import type { MassHaulAxis } from "@util/common_util_mass_haul_view";
|
||||
import {
|
||||
applyLegendToggle,
|
||||
@@ -40,11 +41,7 @@ import {
|
||||
MASS_HAUL_DEFAULT_VISIBLE,
|
||||
normalizeVisibleBasis,
|
||||
} from "@util/common_util_mass_haul";
|
||||
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import {
|
||||
badgeValuesFrom,
|
||||
type MassHaulBadgeValues,
|
||||
} from "@util/common_util_mass_haul_badge";
|
||||
import { badgeValuesFrom, type MassHaulBadgeValues } from "@util/common_util_mass_haul_badge";
|
||||
import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
createMassHaulChart,
|
||||
@@ -195,6 +192,8 @@ export function createRouteMassHaulPanel(
|
||||
onChanged: () => void,
|
||||
/** 총괄값이 새로 나올 때마다 부른다 — 종단 상단줄이 받아 적는다. */
|
||||
onSummary?: (summary: MassHaulSummaryValues | null) => void,
|
||||
/** 유토 배분 선반입기 — **배분은 서버가 만든다**(2026-09-06). 없으면 분배는 안 그린다. */
|
||||
haulPrefetch?: HaulPlanPrefetch,
|
||||
): RouteMassHaulPanel {
|
||||
// 손잡이는 다른 패널과 **같은 양식**의 표준 삼각형 손잡이 하나만 쓴다(2026-08-04 사용자
|
||||
// 지시 — 예전 풀폭 바 + "유토곡선" 캡션은 다른 패널들과 모양이 달랐다). 무엇의 손잡이인지는
|
||||
@@ -407,10 +406,12 @@ export function createRouteMassHaulPanel(
|
||||
const visible = readVisible();
|
||||
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
|
||||
const banded = series.find((entry) => visible.has(entry.key));
|
||||
// 배분은 **서버가 만든다** — 그 산식을 번들에 남기지 않으려고 옮겼다(2026-09-06).
|
||||
// 편집이 멈추면 선반입기가 뒤에서 받아 두므로 펼칠 때는 이미 도착해 있다.
|
||||
// 아직 못 받았으면 곡선만 그린다(분배 도형은 값이 오면 다시 그려진다).
|
||||
if (banded) haulPrefetch?.schedule(banded.result);
|
||||
const haulPlan: HaulPlan | null =
|
||||
banded && visible.has(MASS_HAUL_BALANCE_KEY)
|
||||
? computeHaulPlan(banded.result, context.haulLimits)
|
||||
: null;
|
||||
banded && visible.has(MASS_HAUL_BALANCE_KEY) ? (haulPrefetch?.current() ?? null) : null;
|
||||
// 요약 막대는 2026-09-06 사용자 지시로 뺐다 — 총괄값은 종단 상단줄이 늘 보여 주고,
|
||||
// 이 자리는 곡선이 넓게 쓴다. `bar` 는 안내 문구 자리로만 남는다.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { createHaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan";
|
||||
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
@@ -155,11 +156,22 @@ export function createRouteProfilePanel(
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
||||
bodyWrap.append(massBadge.root);
|
||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||
const massHaul = createRouteMassHaulPanel(subPanelChanged, (summary) => {
|
||||
const before = massHaulSummary;
|
||||
massHaulSummary = summary;
|
||||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||||
});
|
||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||||
const haulPrefetch = createHaulPlanPrefetch(
|
||||
projectId,
|
||||
() => routeId ?? undefined,
|
||||
subPanelChanged,
|
||||
);
|
||||
const massHaul = createRouteMassHaulPanel(
|
||||
subPanelChanged,
|
||||
(summary) => {
|
||||
const before = massHaulSummary;
|
||||
massHaulSummary = summary;
|
||||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||||
},
|
||||
haulPrefetch,
|
||||
);
|
||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""유토 **배분(평형선·운반거리·장비)** 만 내주는 창구.
|
||||
|
||||
왜 따로 있나(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에
|
||||
남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul.ts`), 편집이
|
||||
멈추면 그 결과를 여기로 보내 배분을 받아 쥔다. 그래서 유토곡선 패널을 펼치는 순간이
|
||||
즉시가 된다(미리 받아 뒀으므로).
|
||||
|
||||
**계산은 한 벌이다** — 화면이 쓰던 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`)으로
|
||||
그대로 돌린다(CLAUDE.md 5장). 파이썬으로 옮기면 저장 정본과 화면 값이 갈린다.
|
||||
|
||||
정본을 만들지 않는다 — 이 응답은 **표시 전용**이다. 저장 정본은 [저장]·[확정] 뒤
|
||||
`recompute_server_side` 가 따로 낸다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE, _mass_haul_context
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
|
||||
_NPM_SCRIPT = "build:server-calc"
|
||||
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
|
||||
_MAX_POINTS = 5000
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None)
|
||||
async def compute_haul_plan(
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
) -> JSONResponse:
|
||||
"""브라우저가 낸 누가토량 결과를 받아 **배분만** 돌려준다.
|
||||
|
||||
입력은 `common_util_mass_haul.computeMassHaul` 의 결과 한 벌이다(`points` 포함).
|
||||
출력은 `{"haul_plan": {...} | null}` — 화면이 그대로 그린다.
|
||||
"""
|
||||
result = payload.get("result")
|
||||
points = result.get("points") if isinstance(result, dict) else None
|
||||
if not isinstance(points, list) or not points:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "누가토량 결과가 비어 있습니다."},
|
||||
)
|
||||
if len(points) > _MAX_POINTS:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "측점 수가 너무 많습니다."},
|
||||
)
|
||||
try:
|
||||
output = await asyncio.to_thread(
|
||||
run_bundle_json,
|
||||
BUNDLE,
|
||||
_NPM_SCRIPT,
|
||||
{"haul_plan_for": result, "context": _mass_haul_context()},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
|
||||
)
|
||||
plan = output.get("haul_plan") if isinstance(output, dict) else None
|
||||
return JSONResponse(content={"status": "success", "haul_plan": plan})
|
||||
@@ -21,12 +21,18 @@
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
|
||||
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
|
||||
interface ServerCalcInput {
|
||||
detail: SectionDetailResponse;
|
||||
detail?: SectionDetailResponse;
|
||||
/**
|
||||
* 유토 **배분만** 낼 때 쓰는 입력 — 브라우저가 누가토량(`computeMassHaul`)까지 내고
|
||||
* 그 결과를 보내면 여기서 배분·운반거리만 얹어 돌려준다(2026-09-06).
|
||||
* 배분 코드를 브라우저 번들에서 빼기 위한 길이라, 이 갈래는 `detail` 을 안 받는다.
|
||||
*/
|
||||
haul_plan_for?: Parameters<typeof computeHaulPlan>[0];
|
||||
context?: {
|
||||
earthwork_conversion?: Parameters<typeof computeMassHaul>[1];
|
||||
natural_spoil_min_ground_slope?: number | null;
|
||||
@@ -41,6 +47,16 @@ if (!inputPath || !outputPath) {
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput;
|
||||
|
||||
// 배분만 내는 갈래 — 화면이 편집을 멈추면 조용히 물어보는 자리(유토곡선 배경 선반입).
|
||||
if (input.haul_plan_for) {
|
||||
// **화면이 쓰는 꼴 그대로** 내보낸다(직렬화 형태 `haulPlanPayload` 가 아니다) — 그래야
|
||||
// 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다.
|
||||
const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits);
|
||||
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const sections: CrossSection[] = input.detail?.cross_sections ?? [];
|
||||
|
||||
const areas = structureAreaRows(sections);
|
||||
@@ -56,8 +72,10 @@ const result = conversion
|
||||
input.context?.natural_spoil_min_ground_slope ?? undefined,
|
||||
)
|
||||
: null;
|
||||
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
|
||||
const plan = result ? computeHaulPlan(result, input.context?.haul_equipment_limits) : null;
|
||||
const massHaul = result
|
||||
? massHaulPayload(result, computeHaulPlan(result, input.context?.haul_equipment_limits))
|
||||
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
|
||||
: null;
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul }));
|
||||
|
||||
@@ -29,8 +29,6 @@ import {
|
||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
|
||||
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import { L } from "./B06_Section_UI_Page_Common";
|
||||
|
||||
@@ -216,25 +214,13 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
|
||||
const context = ctx.context();
|
||||
const result =
|
||||
detail && context?.earthwork_conversion
|
||||
? computeMassHaul(
|
||||
detail.cross_sections,
|
||||
context.earthwork_conversion,
|
||||
context.natural_spoil_min_ground_slope ?? undefined,
|
||||
)
|
||||
: null;
|
||||
// 유토곡선 **정본은 서버가 낸다**(2026-09-06 사용자 확정) — 저장 뒤 `recompute_server_side`
|
||||
// 가 Node 로 다시 계산해 덮어쓴다. 그래서 여기서는 곡선도 배분도 만들지 않는다.
|
||||
// 서버가 만들 수 없는 것 하나만 보낸다: 사용자가 끌어 옮긴 balloon 위치(화면값).
|
||||
const offsets = balloonOffsetsPayload();
|
||||
return {
|
||||
crossPatches,
|
||||
massHaul: result
|
||||
? massHaulPayload(
|
||||
result,
|
||||
computeHaulPlan(result, context?.haul_equipment_limits),
|
||||
balloonOffsetsPayload(),
|
||||
)
|
||||
: undefined,
|
||||
massHaul: offsets ? { balloon_offsets: offsets } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View_MassHaul.ts
|
||||
* B06 상단 패널의 **유토곡선 몫**만 떼어 낸 조립기 (2026-09-03 · 700줄 제한).
|
||||
*
|
||||
* 뷰 컨트롤러(`_UI_Section_View`)가 종단면도와 카드 그리드를 맡고, 곡선 계산 → 차트 →
|
||||
* 범례 → 요약줄까지의 한 덩어리는 여기서 만든다. 계산·판정은 전부 공용 모듈
|
||||
* (`common_util_mass_haul*`)이 하고 여기서는 **어디에 무엇을 붙일지**만 정한다.
|
||||
*
|
||||
* 낡음 판정(`hasStaleDesigns`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금
|
||||
* 계획선과 어긋나면 곡선을 그리지 않고 안내만 띄운다(2026-09-03 사용자 확정:
|
||||
* 「새 값만 보여주기」). 옛 계획고로 만든 면적을 잠깐 보여 주고 정본으로 갈아 끼우면
|
||||
* 사용자가 옛 그림을 본다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
import type { EarthworkConversion, HaulEquipmentLimit } from "./B06_Section_Api_Fetch";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
computeMassHaulSeries,
|
||||
MASS_HAUL_BALANCE_KEY,
|
||||
type MassHaulSeries,
|
||||
} from "@util/common_util_mass_haul";
|
||||
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulSummary,
|
||||
createMassHaulWindowState,
|
||||
scheduleMassHaulSettle,
|
||||
type MassHaulWindowState,
|
||||
} from "@util/common_util_mass_haul_view";
|
||||
import {
|
||||
L,
|
||||
longitudinalMaxChainage,
|
||||
LONG_PAD,
|
||||
hasStaleDesigns,
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/** 유토곡선 Y축 눈금 — 가로 스크롤 고정 오버레이가 그대로 받는다. */
|
||||
export interface MassHaulAxisTicks {
|
||||
padLeft: number;
|
||||
ticks: Array<{ y: number; label: string }>;
|
||||
}
|
||||
|
||||
export interface MassHaulPanelInput {
|
||||
detail: SectionDetailResponse;
|
||||
conversion: EarthworkConversion | undefined;
|
||||
haulLimits: HaulEquipmentLimit[] | undefined;
|
||||
naturalSpoilSlope: number | undefined;
|
||||
visibleSeries: Set<string>;
|
||||
selectedStationId: string | null;
|
||||
stationInterval: number;
|
||||
chartWidth: number;
|
||||
minWidth: number;
|
||||
/** 유토곡선 몫 높이(px)와 그 위 종단면도 높이(px) — 범례·Y축 자리를 잡는 값. */
|
||||
massHeight: number;
|
||||
longHeight: number;
|
||||
selectStation: (stationId: string) => void;
|
||||
toggleSeries: (key: string) => void;
|
||||
/** 범례에서 도형 위치를 초기화한 뒤 패널을 다시 그린다. */
|
||||
redraw: () => void;
|
||||
/** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */
|
||||
viewRange?: { fromM: number; toM: number };
|
||||
/** 세로창 버티기·부드러운 이동 상태(2026-09-04). */
|
||||
windowState?: MassHaulWindowState;
|
||||
}
|
||||
|
||||
export interface MassHaulPanelResult {
|
||||
/** 차트 SVG — 곡선이 없으면 null(그 자리는 비운다). */
|
||||
chart: Element | null;
|
||||
axis: MassHaulAxisTicks | null;
|
||||
/** 범례·요약줄 — 패널 본문에 붙일 순서대로. */
|
||||
overlays: HTMLElement[];
|
||||
/** 곡선을 못 그린 이유(있으면 상태줄에 그대로 적는다). 그릴 수 있으면 빈 문자열. */
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유토곡선 차트·범례·요약줄을 만든다. DOM 에 붙이는 것은 호출한 쪽 몫이다 —
|
||||
* 차트는 종단면도와 **같은 부모의 형제**여야 하고(감싸는 상자가 하나라도 끼면 스크롤
|
||||
* 컨테이너 폭 계산이 어긋나 측점 세로선이 밀린다) 범례는 스크롤 컨테이너 **밖**이라
|
||||
* 붙일 자리가 서로 다르기 때문이다.
|
||||
*/
|
||||
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(
|
||||
detail.longitudinal,
|
||||
detail.cross_sections,
|
||||
input.conversion,
|
||||
input.naturalSpoilSlope,
|
||||
)
|
||||
: [];
|
||||
// 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을
|
||||
// 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다.
|
||||
const bandedSeries = series.find((entry) => visibleSeries.has(entry.key));
|
||||
const haulPlan =
|
||||
bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY)
|
||||
? computeHaulPlan(bandedSeries.result, input.haulLimits)
|
||||
: null;
|
||||
|
||||
let axis: MassHaulAxisTicks | null = null;
|
||||
const chart = series.length
|
||||
? createMassHaulChart(
|
||||
series,
|
||||
visibleSeries,
|
||||
detail.longitudinal,
|
||||
{
|
||||
maxChainageM: longitudinalMaxChainage(detail.longitudinal),
|
||||
padLeft: LONG_PAD.left,
|
||||
padRight: LONG_PAD.right,
|
||||
viewRange: input.viewRange,
|
||||
window: input.windowState,
|
||||
},
|
||||
input.selectedStationId,
|
||||
input.stationInterval,
|
||||
input.chartWidth,
|
||||
input.massHeight,
|
||||
input.minWidth,
|
||||
input.selectStation,
|
||||
haulPlan,
|
||||
(next) => {
|
||||
axis = next;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
const overlays: HTMLElement[] = [];
|
||||
if (series.length) {
|
||||
// 범례는 유토곡선 우측 상단에 겹쳐 놓는다(2026-08-02 사용자 지시). 세로 자리는
|
||||
// 종단면도 높이로 잡는다.
|
||||
const legend = createMassHaulLegend(series, visibleSeries, input.toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
input.redraw();
|
||||
});
|
||||
legend.style.top = `${input.longHeight + 6}px`;
|
||||
overlays.push(legend);
|
||||
}
|
||||
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
|
||||
if (bandedSeries) {
|
||||
overlays.push(createMassHaulSummary(bandedSeries, haulPlan));
|
||||
return { chart, axis, overlays, statusText: "" };
|
||||
}
|
||||
const statusText = pendingRecalc
|
||||
? L("B06_MassHaul_Recalculating")
|
||||
: L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
|
||||
return { chart, axis, overlays, statusText };
|
||||
}
|
||||
|
||||
/** 유토곡선을 붙일 자리 — 뷰 컨트롤러가 들고 있는 DOM 이다. */
|
||||
export interface MassHaulMountTargets {
|
||||
/** 차트가 들어가는 가로 스크롤 컨테이너. 종단면도와 **같은 부모의 형제**여야 한다. */
|
||||
chartWrap: HTMLElement;
|
||||
/** 옛 범례·요약줄을 찾아 지울 뿌리. */
|
||||
panel: HTMLElement;
|
||||
/** 새 범례·요약줄을 붙일 자리(스크롤 컨테이너 밖). */
|
||||
panelBody: HTMLElement;
|
||||
/** 곡선을 못 그린 이유를 적는 상태줄. */
|
||||
statusNode: HTMLElement;
|
||||
}
|
||||
|
||||
/** 붙여 둔 유토곡선 노드 — 다음 갱신에서 **제자리 교체**하는 데 쓴다. */
|
||||
export interface MountedMassHaul {
|
||||
chart: Element | null;
|
||||
axis: HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유토곡선을 만들어 붙인다. 이미 붙어 있으면 **그 자리에서 갈아 끼운다** — 종단면도는
|
||||
* 건드리지 않는다(2026-09-04). 가로로 스크롤할 때마다 곡선의 세로 창이 따라와야 하는데,
|
||||
* 상단 패널을 통째로 다시 그리면 종단 그래프까지 새로 만들어져 화면이 한 번 끊긴다.
|
||||
*/
|
||||
export function mountMassHaulPanel(
|
||||
input: MassHaulPanelInput,
|
||||
targets: MassHaulMountTargets,
|
||||
previous: MountedMassHaul,
|
||||
): MountedMassHaul {
|
||||
const built = buildMassHaulPanel(input);
|
||||
let chart = previous.chart;
|
||||
if (built.chart) {
|
||||
if (chart?.isConnected) chart.replaceWith(built.chart);
|
||||
else targets.chartWrap.append(built.chart);
|
||||
chart = built.chart;
|
||||
} else if (chart?.isConnected) {
|
||||
chart.remove();
|
||||
chart = null;
|
||||
}
|
||||
|
||||
let axis = previous.axis;
|
||||
if (built.axis) {
|
||||
// 고정 Y축은 0크기 sticky 앵커라 **첫 자식**이어야 세로 기준이 컨테이너 상단이 된다.
|
||||
// 안쪽(inner)은 종단 높이만큼 내려 자기 그래프 구간만 덮는다.
|
||||
const overlay = buildStickyYAxis(built.axis, input.massHeight);
|
||||
(overlay.firstElementChild as HTMLElement).style.top = `${input.longHeight}px`;
|
||||
if (axis?.isConnected) axis.replaceWith(overlay);
|
||||
else targets.chartWrap.prepend(overlay);
|
||||
axis = overlay;
|
||||
} else if (axis?.isConnected) {
|
||||
axis.remove();
|
||||
axis = null;
|
||||
}
|
||||
|
||||
targets.panel.querySelector(".b06-masshaul__legend")?.remove();
|
||||
targets.panel.querySelector(".b06-masshaul__summary")?.remove();
|
||||
targets.panelBody.append(...built.overlays);
|
||||
targets.statusNode.textContent = built.statusText;
|
||||
return { chart, axis };
|
||||
}
|
||||
|
||||
/**
|
||||
* 「보이는 구간만 바꿔 다시 그리는」 함수를 만든다 — 스크롤 갱신이 부를 것이다.
|
||||
* 붙여 둔 노드는 이 함수가 스스로 들고 있으므로 부르는 쪽은 구간만 넘기면 된다.
|
||||
*/
|
||||
export function createMassHaulRenderer(
|
||||
base: Omit<MassHaulPanelInput, "viewRange">,
|
||||
targets: MassHaulMountTargets,
|
||||
): (fromM: number, toM: number) => void {
|
||||
let mounted: MountedMassHaul = { chart: null, axis: null };
|
||||
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;
|
||||
}
|
||||
@@ -40,8 +40,6 @@ import type {
|
||||
MassHaulLongitudinal,
|
||||
MassHaulSection,
|
||||
} from "./common_util_mass_haul_types";
|
||||
import type { HaulPlan } from "./common_util_mass_haul_balance";
|
||||
import { haulPlanPayload } from "./common_util_mass_haul_balance";
|
||||
|
||||
/** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */
|
||||
export interface GroundVolumes {
|
||||
@@ -464,18 +462,21 @@ export function computeMassHaulSeries(
|
||||
|
||||
/**
|
||||
* 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다.
|
||||
* 토량 분배(평형선)까지 냈으면 `haul_plan`으로 함께 실어 B08 수량·B09 견적이 되받게 한다.
|
||||
*
|
||||
* **토량 분배(`haul_plan`)는 여기서 만들지 않는다** — 그 코드를 브라우저 번들에서 빼려고
|
||||
* 서버(Node 진입점)가 얹는다(2026-09-06 사용자 확정). 이 함수는 누가토량만 다룬다.
|
||||
* 이미 만들어 둔 배분 조각이 있으면 `extra` 로 넘겨 그대로 실린다.
|
||||
*/
|
||||
export function massHaulPayload(
|
||||
result: MassHaulResult,
|
||||
haulPlan?: HaulPlan | null,
|
||||
extra?: Record<string, unknown> | 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) } : {}),
|
||||
...(extra ?? {}),
|
||||
// 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다).
|
||||
...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}),
|
||||
cut_natural_m3: {
|
||||
|
||||
@@ -51,6 +51,9 @@ from B06_Section.B06_Section_Router import router as b06_section_router
|
||||
from B06_Section.B06_Section_Router_Confirm import (
|
||||
router as b06_section_confirm_router,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_HaulPlan import (
|
||||
router as b06_section_haul_plan_router,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
||||
from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
|
||||
@@ -481,6 +484,7 @@ app.include_router(b05_route_replan_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_structures_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_design_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_quantity_router, dependencies=protected_with_company)
|
||||
|
||||
Reference in New Issue
Block a user