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>
338 lines
13 KiB
TypeScript
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();
|
|
});
|
|
}
|