Files
Aislo/common_util/common_util_mass_haul_balloon.ts
T
eomsangdonandClaude Opus 5 fceec5fb24 refactor(common): 유토곡선 엔진·렌더러를 common_util로 공용화
B05 계획 유토곡선과 B06 정식 유토곡선이 같은 엔진을 쓰도록 모듈을 옮겼다.

- B06_wf3_ProfileCross_UI_MassHaul{,_Balance,_Balance_View,_Balloon,_Curve,
  _Settle,_View}.ts + Style_MassHaul.css → common_util/common_util_mass_haul*.
- common_util_mass_haul_types.ts 신설: GroundType/EarthworkConversion/
  HaulEquipmentLimit/BalloonOffsets 정의처를 한 곳으로 모으고 B06 Api_Fetch가
  재수출. 엔진 입력은 페이지 API 타입 대신 구조적 부분집합으로 받는다.
- common_util_svg.ts 신설: svgElement/svgText/L/stationLabel/
  inferStationInterval 이관, B06 _UI_Section_Common은 재수출로 경로 유지.
- createMassHaulChart에 MassHaulAxis 인자 추가 — 종단 렌더러 상수 의존을
  걷어내고 호출한 쪽이 X축(누가거리 최댓값·좌우 여백)을 주입한다.

동작 무변경. npm run typecheck 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:35:02 +09:00

338 lines
13 KiB
TypeScript

/* =============================================================================
* common_util_mass_haul_balloon.ts
* 유토곡선 balloon의 공통 부품 — 좌표 상자 · 도형 · 자리 찾기 · 드래그 · 위치 보관.
*
* 토량 분배 렌더러(`_UI_MassHaul_Balance_View`)가 700줄을 넘겨, 「무엇을 그리나」(그쪽)와
* 「어떻게 놓고 어떻게 잡나」(여기)로 갈랐다. 띠 balloon과 사토·토취 라벨이 이 부품을
* **함께** 쓰므로 배치·드래그 규칙이 한 곳에서만 정의된다.
* ========================================================================== */
import { L } from "./common_util_svg";
import type { LocaleKey } from "@ui/ui_template_locale";
import { svgElement } from "./common_util_svg";
/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */
export interface BalanceLayerBox {
x: (chainageM: number) => number;
y: (volumeM3: number) => number;
left: number;
right: number;
top: number;
bottom: number;
/**
* 주어진 가로 구간에서 곡선이 실제로 지나는 세로 범위(없으면 null).
* balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다.
*/
curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
/** 사토·토취 발생 위치를 측점으로 적기 위한 측점 간격(m). */
stationInterval?: number;
}
export const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
free_haul: "B06_MassHaul_Equip_FreeHaul",
dozer: "B06_MassHaul_Equip_Dozer",
dump_truck: "B06_MassHaul_Equip_Dump",
};
/** balloon 도형 = 운반수단. 모르는 장비는 사각으로 떨어뜨린다. */
export type BalloonShape = "hexagon" | "ellipse" | "rect";
export const EQUIPMENT_SHAPE: Record<string, BalloonShape> = {
free_haul: "hexagon",
dozer: "ellipse",
dump_truck: "rect",
};
/* balloon 치수 — 도면과 달리 화면 그래프는 200px 안팎이라 최대한 작게 잡는다
(2026-08-02 사용자 지적: 도형이 크고 빈자리를 못 찾는다). 자세한 값은 툴팁이 맡는다. */
export const LINE_HEIGHT = 10;
export const PAD_X = 5;
export const PAD_Y = 3;
/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */
export const NOTCH = 5;
/** balloon과 곡선 사이 최소 틈(px). */
export const CURVE_CLEARANCE = 5;
/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */
export const MIN_BAND_WIDTH_PX = 34;
/**
* 그래프가 이만큼 높으면 balloon을 **도면 양식(항목별 세로 표기)**으로 편다
* (2026-08-02 사용자 지시). 도면 balloon이 `종무대 1 / Q= / L= / EA= / RR= / BR=` 6줄이다.
* 기본 패널 높이(유토곡선 190px)에서 바로 이 양식이 나온다.
*/
export const FULL_BALLOON_ROOM_PX = 165;
/** 그보다 낮으면 두 줄, 더 낮으면 물량 한 줄만 남긴다. */
export const TWO_LINE_ROOM_PX = 120;
/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */
export const MIN_BAND_THICKNESS_PX = 9;
/**
* 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]).
*
* 저장 경로가 둘이다:
* ① **프론트 캐시**(localStorage) — 끌어 옮기는 즉시. 새로고침·재접속에도 남는다.
* ② **영구저장소** — 횡단 확정 시점에 `mass_haul.balloon_offsets`로 넘어간다.
* 진입할 때는 ②가 있으면 그것으로 캐시를 덮어써 **다른 브라우저에서도 같은 자리**에 뜬다
* (2026-08-02 사용자 지시). 캐시는 경로별로 갈라 다른 노선의 위치가 섞이지 않게 한다.
*/
const BALLOON_OFFSET_PREFIX = "b06:balance-balloon-offset";
let offsetStorageKey = BALLOON_OFFSET_PREFIX;
export let balloonOffsets = new 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;
}
export function writeOffsets(): void {
try {
localStorage.setItem(offsetStorageKey, JSON.stringify(balloonOffsetsPayload()));
} catch {
/* 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */
}
}
/**
* 노선이 바뀌거나 상세를 새로 받았을 때 캐시를 맞춘다.
* `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 {
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 {
/* 무시 */
}
}
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
export function textWidth(value: string): number {
let width = 0;
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 9.5 : 5.2;
return width;
}
/** balloon 안 수치는 자리를 아껴야 한다 — 천 단위 구분만 두고 소수점은 버린다. */
export function compactVolume(value: number): string {
return Math.round(value).toLocaleString();
}
export function compactDistance(value: number): string {
return value >= 100 ? `${Math.round(value)}` : value.toFixed(1);
}
export function equipmentLabel(key: string | null): string {
if (!key) return "";
const locale = EQUIPMENT_LABEL[key];
return locale ? L(locale) : key;
}
/** 육각형 — 좌우 끝이 뾰족한 도면용 모양. */
export function hexagonPoints(cx: number, cy: number, width: number, height: number): string {
const halfW = width / 2;
const halfH = height / 2;
const notch = Math.min(NOTCH, width * 0.2);
return [
`${cx - halfW},${cy}`,
`${cx - halfW + notch},${cy - halfH}`,
`${cx + halfW - notch},${cy - halfH}`,
`${cx + halfW},${cy}`,
`${cx + halfW - notch},${cy + halfH}`,
`${cx - halfW + notch},${cy + halfH}`,
].join(" ");
}
export function balloonShape(
shape: BalloonShape,
cx: number,
cy: number,
width: number,
height: number,
): SVGElement {
if (shape === "ellipse") {
// 원형은 같은 글자를 담으려면 사각보다 커야 한다 — 모서리가 비기 때문이다.
return svgElement("ellipse", {
cx,
cy,
rx: width / 2 + 5,
ry: height / 2 + 3,
class: "b06-balance__balloon-shape",
});
}
if (shape === "rect") {
return svgElement("rect", {
x: cx - width / 2,
y: cy - height / 2,
width,
height,
rx: 2,
ry: 2,
class: "b06-balance__balloon-shape",
});
}
return svgElement("polygon", {
points: hexagonPoints(cx, cy, width, height),
class: "b06-balance__balloon-shape",
});
}
export interface PlacedBox {
left: number;
right: number;
top: number;
bottom: number;
}
export function overlaps(a: PlacedBox, b: PlacedBox): boolean {
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
/** 이 자리에 놓으면 곡선과 겹치는가. 곡선 정보를 못 받았으면 겹치지 않는 것으로 본다. */
export function hitsCurve(box: BalanceLayerBox, candidate: PlacedBox): boolean {
const band = box.curveYAt?.(candidate.left, candidate.right);
if (!band) return false;
return !(
candidate.bottom + CURVE_CLEARANCE <= band.top || candidate.top - CURVE_CLEARANCE >= band.bottom
);
}
/**
* 빈자리를 찾는다. 선호 지점에서 **가로·세로 양쪽으로** 후보를 넓혀 가며, 이미 놓인 balloon과
* 겹치지 않고 곡선도 피하는 첫 자리를 고른다(2026-08-02 사용자 지적: 남는 공간을 못 찾음).
*
* 곡선까지 피하는 자리가 끝내 없으면 **곡선 조건만 버리고** 다시 훑는다 — 겹치더라도 놓는 게
* 안 그리는 것보다 낫고, 사용자가 끌어 옮길 수 있다.
*/
export function findSlot(
placed: PlacedBox[],
preferredX: number,
preferredY: number,
width: number,
height: number,
box: BalanceLayerBox,
upward: boolean,
): { x: number; y: number } {
const stepY = height + 3;
const stepX = width * 0.55;
const clampX = (value: number): number =>
Math.min(Math.max(value, box.left + width / 2), box.right - width / 2);
const candidates: Array<{ x: number; y: number }> = [];
for (let ring = 0; ring < 7; ring += 1) {
for (const dy of ring === 0 ? [0] : upward ? [-ring, ring] : [ring, -ring]) {
for (const dx of ring === 0 ? [0] : [0, -1, 1, -2, 2]) {
candidates.push({ x: clampX(preferredX + dx * stepX), y: preferredY + dy * stepY });
}
}
}
for (const avoidCurve of [true, false]) {
for (const candidate of candidates) {
if (candidate.y - height / 2 < box.top || candidate.y + height / 2 > box.bottom) continue;
const rect: PlacedBox = {
left: candidate.x - width / 2,
right: candidate.x + width / 2,
top: candidate.y - height / 2,
bottom: candidate.y + height / 2,
};
if (placed.some((entry) => overlaps(entry, rect))) continue;
if (avoidCurve && hitsCurve(box, rect)) continue;
return candidate;
}
}
// 빈자리를 못 찾아도 **상자 안에는** 넣는다 — 안 그러면 라벨이 그래프 밖으로 나간다
// (2026-08-02 사용자 지적).
return {
x: clampX(preferredX),
y: Math.min(Math.max(preferredY, box.top + height / 2), box.bottom - height / 2),
};
}
/**
* balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는
* 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시).
* 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다.
*/
export function attachBalloonDrag(
balloon: SVGGElement,
leader: SVGLineElement,
svg: SVGSVGElement,
key: number,
home: { x: number; y: number },
/** 옮긴 자리를 그릴 수 있는 범위 안으로 자르는 함수. 없으면 자르지 않는다. */
clampOffset?: (dx: number, dy: number) => [number, number],
): void {
let start: { x: number; y: number; dx: number; dy: number } | null = null;
const scale = (): number => {
const rendered = svg.getBoundingClientRect().width;
const viewBox = svg.viewBox.baseVal?.width || rendered;
return rendered > 0 && viewBox > 0 ? viewBox / rendered : 1;
};
const clampTo = (dx: number, dy: number): [number, number] =>
clampOffset ? clampOffset(dx, dy) : [dx, dy];
const apply = (dx: number, dy: number): void => {
balloon.setAttribute("transform", `translate(${dx} ${dy})`);
leader.setAttribute("x2", String(home.x + dx));
leader.setAttribute("y2", String(home.y + dy));
};
balloon.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
// 카드·측점 선택으로 번지면 그래프가 다시 그려져 끌던 balloon이 사라진다.
event.stopPropagation();
event.preventDefault();
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");
});
balloon.addEventListener("pointermove", (event) => {
if (!start) return;
const factor = scale();
const [dx, dy] = clampTo(
start.dx + (event.clientX - start.x) * factor,
start.dy + (event.clientY - start.y) * factor,
);
balloonOffsets.set(key, [dx, dy]);
apply(dx, dy);
});
const finish = (event: PointerEvent): void => {
if (!start) return;
start = null;
balloon.releasePointerCapture(event.pointerId);
balloon.classList.remove("is-dragging");
writeOffsets();
};
balloon.addEventListener("pointerup", finish);
balloon.addEventListener("pointercancel", finish);
// 두 번 누르면 자동 배치로 되돌린다 — 잘못 끌었을 때 되돌릴 길을 남긴다.
balloon.addEventListener("dblclick", (event) => {
event.stopPropagation();
balloonOffsets.delete(key);
apply(0, 0);
writeOffsets();
});
}