Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
184 lines
7.1 KiB
TypeScript
184 lines
7.1 KiB
TypeScript
/* =============================================================================
|
|
* common_util_mass_haul_badge.ts
|
|
* 「최종 누가토량」 배지 — 종단 그래프 **좌측 상단에 겹쳐** 띄우는 한 줄.
|
|
*
|
|
* 왜 배지인가(2026-09-06 사용자 지시) — 유토곡선 그래프는 자리를 많이 먹는데, 계획선을
|
|
* 만지며 늘 봐야 하는 값은 **마지막 지점의 누가토량 하나**다. 그래서 그 값만 그래프 위에
|
|
* 겹쳐 두고, 곡선 자체는 B05 의 유토곡선 패널에서만 펼쳐 본다(B06 에서는 곡선을 뺐다).
|
|
*
|
|
* 기준은 **횡단**이다 — 종단 기준 곡선은 계획선만 보는 개략값이라 뒤에 없앨 예정이다
|
|
* (2026-09-06 사용자). 값을 넣는 쪽에서 횡단 기준 계열을 골라 넘긴다.
|
|
* ========================================================================== */
|
|
|
|
/** 배지에 실리는 값 한 벌 — 유토곡선 계산 결과에서 뽑는다. */
|
|
export interface MassHaulBadgeValues {
|
|
/** 마지막 측점의 누계 토량(㎥). 음수면 부족(토취). */
|
|
finalM3: number;
|
|
cutNaturalM3: number;
|
|
cutCompactedM3: number;
|
|
fillCompactedM3: number;
|
|
surplusM3: number;
|
|
shortageM3: number;
|
|
}
|
|
|
|
/** 누가토량 표기 — 천 단위 구분 + 소수점 1자리(유토곡선 요약과 같은 규칙). */
|
|
function volume(value: number): string {
|
|
return `${value.toLocaleString("ko-KR", { maximumFractionDigits: 1 })}㎥`;
|
|
}
|
|
|
|
export interface MassHaulBadge {
|
|
/** 그래프 컨테이너(`position: relative`)에 붙일 요소. */
|
|
root: HTMLElement;
|
|
/** 값을 갈아 끼운다. null 이면 배지를 숨긴다(아직 계산 못 한 상태). */
|
|
set: (values: MassHaulBadgeValues | null) => void;
|
|
}
|
|
|
|
/** 유토곡선 계산 결과 한 벌에서 배지 값을 뽑는다 — B05·B06 이 같은 규칙을 쓴다. */
|
|
export function badgeValuesFrom(result: {
|
|
points: ReadonlyArray<{ cumulative_volume_m3: number }>;
|
|
cut_natural_m3: { soil: number; ripping_rock: number; blasting_rock: number };
|
|
cut_compacted_m3: number;
|
|
fill_compacted_m3: number;
|
|
surplus_m3: number;
|
|
shortage_m3: number;
|
|
}): MassHaulBadgeValues {
|
|
const cut = result.cut_natural_m3;
|
|
const points = result.points;
|
|
return {
|
|
finalM3: points.length ? points[points.length - 1].cumulative_volume_m3 : 0,
|
|
cutNaturalM3: cut.soil + cut.ripping_rock + cut.blasting_rock,
|
|
cutCompactedM3: result.cut_compacted_m3,
|
|
fillCompactedM3: result.fill_compacted_m3,
|
|
surplusM3: result.surplus_m3,
|
|
shortageM3: result.shortage_m3,
|
|
};
|
|
}
|
|
|
|
/** 배지를 만든다. 붙이는 것은 부르는 쪽 몫이다(그래프 컨테이너 첫 자식). */
|
|
export function createMassHaulBadge(): MassHaulBadge {
|
|
const root = document.createElement("span");
|
|
root.className = "mass-haul-badge";
|
|
root.hidden = true;
|
|
const caption = document.createElement("em");
|
|
caption.textContent = "누가토량";
|
|
const value = document.createElement("strong");
|
|
root.append(caption, value);
|
|
return {
|
|
root,
|
|
set(values) {
|
|
if (!values) {
|
|
root.hidden = true;
|
|
return;
|
|
}
|
|
root.hidden = false;
|
|
root.classList.toggle("is-over", values.finalM3 < 0);
|
|
value.textContent = volume(values.finalM3);
|
|
root.title = [
|
|
`절토(자연) ${volume(values.cutNaturalM3)} · 절토(다짐) ${volume(values.cutCompactedM3)}`,
|
|
`성토(다짐) ${volume(values.fillCompactedM3)}`,
|
|
values.shortageM3 > 0
|
|
? `부족 ${volume(values.shortageM3)}`
|
|
: `잉여 ${volume(values.surplusM3)}`,
|
|
"기준: 횡단",
|
|
].join("\n");
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 알약 자리 저장 창구(화면 설정 한 칸) — 값 = `{"x":0~1,"y":0~1}` 글. */
|
|
export interface MassHaulBadgePlace {
|
|
read: () => string | null;
|
|
write: (value: string) => void;
|
|
}
|
|
|
|
/** 끈 것으로 보는 움직임(px) — 이 안이면 그냥 누름(지금 동작 그대로). */
|
|
const DRAG_SLOP_PX = 4;
|
|
|
|
/**
|
|
* 알약 끌어 옮기기(309-2) — 마우스 · 손가락 같은 pointer 길 · `area`(그래프 스크롤러)의 보이는 상자 안에서만.
|
|
* 영역 = 알약 처음 자리(Y축 띠 오른쪽 · 측점 ▲ 줄 아래)가 왼쪽 위 끝 · 스크롤러 보이는 오른쪽 · 아래 끝까지.
|
|
* 자리 = 영역 안 비율 — 폭 · 높이가 바뀌거나 다시 그려도 같은 비율. 알약은 그래프 형제라 끌기가 그래프 고름에 안 닿음.
|
|
*/
|
|
export function attachMassHaulBadgeDrag(
|
|
root: HTMLElement,
|
|
area: HTMLElement,
|
|
place: MassHaulBadgePlace,
|
|
): void {
|
|
let origin: { x: number; y: number } | null = null;
|
|
const bounds = () => {
|
|
if (!origin) {
|
|
const [left, top] = [root.style.left, root.style.top];
|
|
root.style.left = root.style.top = "";
|
|
origin = { x: root.offsetLeft, y: root.offsetTop };
|
|
[root.style.left, root.style.top] = [left, top];
|
|
}
|
|
const w = area.offsetLeft + area.clientWidth - root.offsetWidth - origin.x;
|
|
const h = area.offsetTop + area.clientHeight - root.offsetHeight - origin.y;
|
|
return { ...origin, w: Math.max(0, w), h: Math.max(0, h) };
|
|
};
|
|
const clamp = (v: number): number => Math.min(1, Math.max(0, v));
|
|
const put = (x: number, y: number): void => {
|
|
const b = bounds();
|
|
root.style.left = `${Math.round(b.x + clamp(x) * b.w)}px`;
|
|
root.style.top = `${Math.round(b.y + clamp(y) * b.h)}px`;
|
|
};
|
|
const restore = (): void => {
|
|
if (root.hidden || !root.isConnected) return;
|
|
try {
|
|
const v = JSON.parse(place.read() ?? "null") as { x?: unknown; y?: unknown } | null;
|
|
if (typeof v?.x === "number" && typeof v.y === "number") put(v.x, v.y);
|
|
} catch {
|
|
/* 못 읽는 값 = 처음 자리 */
|
|
}
|
|
};
|
|
new ResizeObserver(restore).observe(area);
|
|
new MutationObserver(restore).observe(root, { attributeFilter: ["hidden"] });
|
|
|
|
let drag: { id: number; px: number; py: number; left: number; top: number } | null = null;
|
|
let moved: { x: number; y: number } | null = null;
|
|
root.addEventListener("pointerdown", (e) => {
|
|
if (e.button !== 0) return;
|
|
drag = {
|
|
id: e.pointerId,
|
|
px: e.clientX,
|
|
py: e.clientY,
|
|
left: root.offsetLeft,
|
|
top: root.offsetTop,
|
|
};
|
|
moved = null;
|
|
root.setPointerCapture(e.pointerId);
|
|
});
|
|
root.addEventListener("pointermove", (e) => {
|
|
if (!drag || e.pointerId !== drag.id) return;
|
|
const dx = e.clientX - drag.px;
|
|
const dy = e.clientY - drag.py;
|
|
if (!moved && Math.hypot(dx, dy) < DRAG_SLOP_PX) return;
|
|
const b = bounds();
|
|
moved = {
|
|
x: b.w ? clamp((drag.left + dx - b.x) / b.w) : 0,
|
|
y: b.h ? clamp((drag.top + dy - b.y) / b.h) : 0,
|
|
};
|
|
root.classList.add("is-dragging");
|
|
put(moved.x, moved.y);
|
|
});
|
|
const end = (e: PointerEvent): void => {
|
|
if (!drag || e.pointerId !== drag.id) return;
|
|
drag = null;
|
|
root.classList.remove("is-dragging");
|
|
if (moved) place.write(JSON.stringify({ x: +moved.x.toFixed(4), y: +moved.y.toFixed(4) }));
|
|
};
|
|
root.addEventListener("pointerup", end);
|
|
root.addEventListener("pointercancel", end);
|
|
// 끈 뒤 따라오는 click 은 삼킴 — 안 끈 누름은 그대로 지나감
|
|
root.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
if (!moved) return;
|
|
moved = null;
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
},
|
|
true,
|
|
);
|
|
}
|