diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 2f2d670e..501f0712 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -62,6 +62,7 @@ import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css"; +import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross_Areas.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; /** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index e8e7dde2..7044655f 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -139,7 +139,19 @@ export function crossCardNaturalHeight( * viewBox만 조작하고 선은 `vector-effect: non-scaling-stroke`(CSS)로 굵기를 유지해 * 확대해도 선·글자가 선명하다. 최대 8배까지, 축소는 원본까지만 허용한다. */ -function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): void { +/** + * 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다. + * + * **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에 + * 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭. + */ +interface ZoomPanHandle { + /** 1보다 크면 확대, 작으면 축소. 보이는 화면의 중앙을 붙잡는다. */ + zoom: (factor: number) => void; + reset: () => void; +} + +function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): ZoomPanHandle { const base = { x: 0, y: 0, w: widthPx, h: heightPx }; const vb = { ...base }; const applyVB = (): void => svg.setAttribute("viewBox", `${vb.x} ${vb.y} ${vb.w} ${vb.h}`); @@ -148,26 +160,19 @@ function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): v vb.y = Math.min(Math.max(vb.y, base.y), base.y + base.h - vb.h); }; - svg.addEventListener( - "wheel", - (event) => { - event.preventDefault(); - const rect = svg.getBoundingClientRect(); - const mx = vb.x + ((event.clientX - rect.left) / rect.width) * vb.w; - const my = vb.y + ((event.clientY - rect.top) / rect.height) * vb.h; - // 휠을 **당기면 확대**, 밀면 축소한다 — B04·B05 2D 지도와 같은 방향(2026-08-02 사용자 지시). - const factor = event.deltaY > 0 ? 0.85 : 1 / 0.85; - const nw = Math.min(base.w, Math.max(base.w / 8, vb.w * factor)); - const nh = Math.min(base.h, Math.max(base.h / 8, vb.h * factor)); - vb.x = mx - (mx - vb.x) * (nw / vb.w); - vb.y = my - (my - vb.y) * (nh / vb.h); - vb.w = nw; - vb.h = nh; - clampPan(); - applyVB(); - }, - { passive: false }, - ); + // 보이는 화면의 **중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다. + const zoom = (factor: number): void => { + const mx = vb.x + vb.w / 2; + const my = vb.y + vb.h / 2; + const nw = Math.min(base.w, Math.max(base.w / 8, vb.w / factor)); + const nh = Math.min(base.h, Math.max(base.h / 8, vb.h / factor)); + vb.x = mx - (mx - vb.x) * (nw / vb.w); + vb.y = my - (my - vb.y) * (nh / vb.h); + vb.w = nw; + vb.h = nh; + clampPan(); + applyVB(); + }; let panning = false; let moved = false; @@ -219,15 +224,42 @@ function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): v svg.addEventListener("click", (event) => { if (moved) event.stopPropagation(); }); - // 더블클릭 원복. - svg.addEventListener("dblclick", (event) => { - event.stopPropagation(); + const reset = (): void => { vb.x = base.x; vb.y = base.y; vb.w = base.w; vb.h = base.h; applyVB(); + }; + // 더블클릭 원복. + svg.addEventListener("dblclick", (event) => { + event.stopPropagation(); + reset(); }); + return { zoom, reset }; +} + +/** 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. */ +function buildZoomControls(handle: ZoomPanHandle): HTMLElement { + const bar = document.createElement("div"); + bar.className = "b06-cross-card__zoom"; + const add = (label: string, title: string, action: () => void): void => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b06-cross-card__zoom-btn"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + // 카드 선택으로 번지면 그래프가 다시 그려져 방금 맞춘 배율이 날아간다. + event.stopPropagation(); + action(); + }); + bar.append(button); + }; + add("+", L("B06_Profile_View_ZoomIn"), () => handle.zoom(1 / 0.85)); + add("−", L("B06_Profile_View_ZoomOut"), () => handle.zoom(0.85)); + add("⤢", L("B06_Profile_View_ZoomFit"), () => handle.reset()); + return bar; } export function createCrossSectionCard( @@ -516,22 +548,16 @@ export function createCrossSectionCard( // 마우스 휠 줌·드래그 팬·더블클릭 원복(E-5). viewBox 조작 + non-scaling-stroke로 선명도 유지. const chartWrap = document.createElement("div"); chartWrap.className = "b06-cross-card__chart-wrap"; - attachZoomPan(svg, widthPx, heightPx); + const zoomPan = attachZoomPan(svg, widthPx, heightPx); // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. const readout = buildAreaReadout(section.design, toggleArea); setChipActive = readout.setActive; - chartWrap.append(svg, readout.root); + chartWrap.append(svg, readout.root, buildZoomControls(zoomPan)); // 다시 그려지기 전에 켜져 있던 강조를 되살린다(부모가 들고 있던 값). if (activeArea) { setBandActive(activeArea); setChipActive(activeArea); } - // 암 경계선 제어는 그래프 X축 제목 행 우측에 배치(E-7). 암 지반에서만. - if (rockBoundary && section.design?.geometry_preset === "rock") { - const rockControl = buildRockBoundaryControl(section, rockBoundary); - rockControl.classList.add("b06-cross-card__rockb"); - chartWrap.append(rockControl); - } card.append(chartWrap); } @@ -540,7 +566,15 @@ export function createCrossSectionCard( center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; const azimuth = document.createElement("span"); azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`; - footer.append(center, azimuth); + footer.append(center); + // 암 경계선 제어는 그래프 안이 아니라 **중심고·방위각 행 가운데**에 둔다 + // (2026-08-02 사용자 지시). 그래프 안에 있으면 도면 위에 겹쳐 단면을 가렸다. 암 지반만. + if (rockBoundary && section.design?.geometry_preset === "rock") { + const rockControl = buildRockBoundaryControl(section, rockBoundary); + rockControl.classList.add("b06-cross-card__rockb"); + footer.append(rockControl); + } + footer.append(azimuth); card.append(footer); return card; } 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 61981da6..5b13c257 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts @@ -127,6 +127,13 @@ export interface HaulResidual { /** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */ level_from_m3: number; level_to_m3: number; + /** + * 사토의 지반유형 안분(㎥). 사토는 절토에서 남은 흙이라 구성비를 물을 수 있다. + * **토취는 0이다** — 밖에서 사 오는 흙이라 이 노선의 지반유형이 없다. + */ + ea_m3: number; + rr_m3: number; + br_m3: number; } /** @@ -445,14 +452,19 @@ export function computeHaulPlan( const pushResidual = (fromM: number, toM: number, from: number, to: number): void => { const delta = to - from; if (Math.abs(delta) < EPSILON) return; + const volume = Math.abs(delta); residuals.push({ index: residuals.length + 1, kind: delta > 0 ? "spoil" : "borrow", from_m: fromM, to_m: toM, - volume_m3: Math.abs(delta), + volume_m3: volume, level_from_m3: from, level_to_m3: to, + // 사토만 지반유형을 물을 수 있다(절토에서 남은 흙). 토취는 밖에서 사 온다. + ...(delta > 0 + ? apportion(cutMix(points, fromM, toM, result.conversion), volume) + : { ea_m3: 0, rr_m3: 0, br_m3: 0 }), }); }; @@ -587,6 +599,11 @@ function settleResiduals( for (const pair of pairs) { const volume = Math.min(pair.spoil.volume_m3, pair.borrow.volume_m3); if (!(volume > EPSILON)) continue; + // 잔량이 깎인 만큼 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다. + const ratio = pair.spoil.volume_m3 > 0 ? 1 - volume / pair.spoil.volume_m3 : 0; + pair.spoil.ea_m3 *= ratio; + pair.spoil.rr_m3 *= ratio; + pair.spoil.br_m3 *= ratio; pair.spoil.volume_m3 -= volume; pair.borrow.volume_m3 -= volume; // 퍼오는 쪽이 절토 구간이므로 지반유형은 사토 구간에서 읽는다. @@ -649,6 +666,9 @@ export function haulPlanPayload(plan: HaulPlan): Record { from_m: round(residual.from_m), to_m: round(residual.to_m), volume_m3: round(residual.volume_m3), + ea_m3: round(residual.ea_m3), + rr_m3: round(residual.rr_m3), + br_m3: round(residual.br_m3), })), }; } 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 9b54412a..94a26acf 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 @@ -7,12 +7,13 @@ * 2. 장비 띠 면 — 경계현 두 개와 곡선 두 변으로 둘러싸인 다각형. 클릭 대상이다. * 3. 경계현(파선) — 그 현의 길이가 장비 경계거리와 같아지는 높이의 수평선. * 4. 평균운반거리(점선 + 화살촉) — 띠 중간 높이의 현. 방향은 산=좌→우, 골=우→좌. - * 5. balloon — 도형이 곧 운반수단이다: 종무대 육각 / 도쟈 원 / 덤프 사각. + * 5. 장거리 운반선(1점쇄선) — 떨어진 잉여 ↔ 부족을 잇는 선. + * 6. balloon — 도형이 곧 운반수단이다: 종무대 육각 / 도쟈 원 / 덤프 사각. * 사토·토취는 도형 없이 **언더바만**(2026-08-02 사용자 지시). * * 좌표 변환(x/y)과 그릴 상자는 유토곡선 렌더러가 넘겨준다 — 축을 두 번 정의하지 않는다. * 계산은 전부 `_UI_MassHaul_Balance`가 끝내 두므로 여기서는 배치만 판단한다. - * 700줄 제한 대응으로 `_UI_MassHaul_View`에서 분리했다. + * balloon의 도형·자리 찾기·드래그·위치 보관은 `_UI_MassHaul_Balloon`이 맡는다. * ========================================================================== */ import type { @@ -22,314 +23,35 @@ import type { HaulResidual, HaulTransfer, } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; -import type { LocaleKey } from "@ui/ui_template_locale"; +import type { BalanceLayerBox, PlacedBox } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; +import { + attachBalloonDrag, + balloonOffsets, + balloonShape, + compactDistance, + compactVolume, + EQUIPMENT_SHAPE, + equipmentLabel, + findSlot, + FULL_BALLOON_ROOM_PX, + LINE_HEIGHT, + MIN_BAND_THICKNESS_PX, + MIN_BAND_WIDTH_PX, + NOTCH, + PAD_X, + PAD_Y, + RESIDUAL_ARROW_PX, + textWidth, + TWO_LINE_ROOM_PX, +} from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; import { L, stationLabel, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common"; -/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(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; -} - -const EQUIPMENT_LABEL: Record = { - free_haul: "B06_MassHaul_Equip_FreeHaul", - dozer: "B06_MassHaul_Equip_Dozer", - dump_truck: "B06_MassHaul_Equip_Dump", -}; - -/** balloon 도형 = 운반수단. 모르는 장비는 사각으로 떨어뜨린다. */ -type BalloonShape = "hexagon" | "ellipse" | "rect"; -const EQUIPMENT_SHAPE: Record = { - free_haul: "hexagon", - dozer: "ellipse", - dump_truck: "rect", -}; - -/* balloon 치수 — 도면과 달리 화면 그래프는 200px 안팎이라 최대한 작게 잡는다 - (2026-08-02 사용자 지적: 도형이 크고 빈자리를 못 찾는다). 자세한 값은 툴팁이 맡는다. */ -const LINE_HEIGHT = 10; -const PAD_X = 5; -const PAD_Y = 3; -/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */ -const NOTCH = 5; -/** balloon과 곡선 사이 최소 틈(px). */ -const CURVE_CLEARANCE = 5; -/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */ -const MIN_BAND_WIDTH_PX = 34; -/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */ -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; -let balloonOffsets = new 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 { - 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 { - return Object.fromEntries([...balloonOffsets].map(([key, value]) => [String(key), value])); -} - -/** 자동 배치로 되돌린다 — 프론트 캐시를 비우고, 다음 확정 때 영구저장소도 빈 값으로 덮인다. */ -export function resetBalloonOffsets(): void { - balloonOffsets = new Map(); - try { - localStorage.removeItem(offsetStorageKey); - } catch { - /* 무시 */ - } -} - -/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */ -function textWidth(value: string): number { - let width = 0; - for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 9.5 : 5.2; - return width; -} - -/** balloon 안 수치는 자리를 아껴야 한다 — 천 단위 구분만 두고 소수점은 버린다. */ -function compactVolume(value: number): string { - return Math.round(value).toLocaleString(); -} - -function compactDistance(value: number): string { - return value >= 100 ? `${Math.round(value)}` : value.toFixed(1); -} - -function equipmentLabel(key: string | null): string { - if (!key) return ""; - const locale = EQUIPMENT_LABEL[key]; - return locale ? L(locale) : key; -} - -/** 육각형 — 좌우 끝이 뾰족한 도면용 모양. */ -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(" "); -} - -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", - }); -} - -interface PlacedBox { - left: number; - right: number; - top: number; - bottom: number; -} - -function overlaps(a: PlacedBox, b: PlacedBox): boolean { - return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom); -} - -/** 이 자리에 놓으면 곡선과 겹치는가. 곡선 정보를 못 받았으면 겹치지 않는 것으로 본다. */ -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 사용자 지적: 남는 공간을 못 찾음). - * - * 곡선까지 피하는 자리가 끝내 없으면 **곡선 조건만 버리고** 다시 훑는다 — 겹치더라도 놓는 게 - * 안 그리는 것보다 낫고, 사용자가 끌어 옮길 수 있다. - */ -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; - } - } - return { x: clampX(preferredX), y: preferredY }; -} - -/** - * balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는 - * 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시). - * 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다. - */ -function attachBalloonDrag( - balloon: SVGGElement, - leader: SVGLineElement, - svg: SVGSVGElement, - key: number, - home: { x: number; y: 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 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 = start.dx + (event.clientX - start.x) * factor; - const dy = 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(); - }); -} +export type { BalanceLayerBox } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; +export { + balloonOffsetsPayload, + configureBalloonOffsets, + resetBalloonOffsets, +} from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; /** 계단형 평형선(기선) — 칸 사이는 수직선으로 이어 계단을 만든다. */ function appendBalanceLine(group: SVGGElement, plan: HaulPlan, box: BalanceLayerBox): void { @@ -419,11 +141,23 @@ function appendBandBalloon( const equipment = equipmentLabel(band.equipment); const room = box.bottom - box.top; - // 화면 balloon은 도면과 달리 두 줄이 상한이다. EA/RR/BR·블록 번호는 툴팁이 맡는다. - const lines = [`${band.index} · ${compactVolume(band.volume_m3)}㎥`]; - if (room >= 120) { - lines.push(`L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`); - } + // 자리가 나면 도면과 같은 항목별 세로 표기, 좁아지면 줄을 접는다. + const lines = + room >= FULL_BALLOON_ROOM_PX + ? [ + `${equipment || "-"} ${band.index}`, + `Q=${compactVolume(band.volume_m3)}㎥`, + `L=${compactDistance(band.haul_distance_m)}m`, + `EA=${compactVolume(band.ea_m3)}㎥`, + `RR=${compactVolume(band.rr_m3)}㎥`, + `BR=${compactVolume(band.br_m3)}㎥`, + ] + : room >= TWO_LINE_ROOM_PX + ? [ + `${band.index} · ${compactVolume(band.volume_m3)}㎥`, + `L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`, + ] + : [`${band.index} · ${compactVolume(band.volume_m3)}㎥`]; const width = Math.max(...lines.map(textWidth)) + PAD_X * 2 + NOTCH; const height = lines.length * LINE_HEIGHT + PAD_Y * 2; @@ -492,13 +226,19 @@ function appendBandBalloon( } /** - * 사토·토취 — 평형선 단차를 세로 화살표로 찍고 수량은 **언더바만** 두른 라벨로 적는다 - * (2026-08-02 사용자 지시: 도형은 운반수단 표시 전용). + * 사토·토취 — **balloon과 같은 규칙**으로 그린다(2026-08-02 사용자 확정: B + D안). + * + * 예전에는 평형선 단차 높이만큼 세로 화살표를 통째로 그려서, 단차가 크면 화살표가 그래프 + * 높이를 거의 다 먹고 라벨이 곡선 한가운데 얹혔다. 이제 화살표는 **위치만 가리키는 짧은 + * 표시**로 자르고, 수량 라벨은 balloon과 같은 자리 찾기·지시선·드래그를 쓴다. + * 도형은 두르지 않고 **언더바만** 남긴다 — 도형은 운반수단 표시 전용이기 때문이다. */ function appendResidual( group: SVGGElement, + svg: SVGSVGElement, residual: HaulResidual, box: BalanceLayerBox, + placed: PlacedBox[], compact: boolean, ): void { const centerX = Math.min( @@ -507,42 +247,113 @@ function appendResidual( ); const y1 = box.y(residual.level_from_m3); const y2 = box.y(residual.level_to_m3); + // 단차가 그래프를 가로지를 만큼 커도 화살표는 짧게 — 크기는 라벨의 수량이 말한다. + const down = y2 > y1; + const tipY = Math.min( + Math.max(y1 + (down ? RESIDUAL_ARROW_PX : -RESIDUAL_ARROW_PX), box.top + 2), + box.bottom - 2, + ); // 도면은 사토 balloon에 거리(L)가 아니라 **측점**(M.N)을 적는다 — 사토는 운반이 아니라 // 그 자리 처리라 운반거리를 매기지 않기 때문이다(2026-08-02 도면 분석). const at = (residual.from_m + residual.to_m) / 2; - const station = box.stationInterval ? ` · ${stationLabel(at, box.stationInterval)}` : ""; - const label = - `${L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage")} ` + - `${compactVolume(residual.volume_m3)}㎥${station}`; + const station = box.stationInterval ? stationLabel(at, box.stationInterval) : "-"; + const kindLabel = L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage"); + const label = `${kindLabel} ${compactVolume(residual.volume_m3)}㎥ · ${station}`; + const marker = svgElement("g", { class: `b06-balance__residual b06-balance__residual--${residual.kind}`, }); const title = svgElement("title"); title.textContent = label; + const head = down ? tipY - 6 : tipY + 6; marker.append( title, - svgElement("line", { x1: centerX, y1, x2: centerX, y2, class: "b06-balance__step-arrow" }), + svgElement("line", { + x1: centerX, + y1, + x2: centerX, + y2: tipY, + class: "b06-balance__step-arrow", + }), svgElement("polygon", { - points: `${centerX},${y2} ${centerX - 3.5},${y2 + (y2 > y1 ? -6 : 6)} ${centerX + 3.5},${y2 + (y2 > y1 ? -6 : 6)}`, + points: `${centerX},${tipY} ${centerX - 3.5},${head} ${centerX + 3.5},${head}`, class: "b06-balance__step-head", }), ); - // 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다. - if (!compact) { - const textY = (y1 + y2) / 2 + 3; - const width = textWidth(label); - marker.append( - svgText(label, { x: centerX + 5, y: textY, class: "b06-balance__residual-text" }), - svgElement("line", { - x1: centerX + 5, - y1: textY + 3, - x2: centerX + 5 + width, - y2: textY + 3, - class: "b06-balance__residual-underline", - }), - ); - } group.append(marker); + // 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다. + if (compact) return; + + // 자리가 나면 도면 양식으로 편다. 사토는 `L=` 대신 **`M.N=측점`**이고, 토취는 밖에서 + // 사 오는 흙이라 지반유형(EA/RR/BR)이 없어 세 줄을 뺀다. + const room = box.bottom - box.top; + const lines = + room >= FULL_BALLOON_ROOM_PX + ? [ + `${kindLabel} ${residual.index}`, + `Q=${compactVolume(residual.volume_m3)}㎥`, + `M.N=${station}`, + ...(residual.kind === "spoil" + ? [ + `EA=${compactVolume(residual.ea_m3)}㎥`, + `RR=${compactVolume(residual.rr_m3)}㎥`, + `BR=${compactVolume(residual.br_m3)}㎥`, + ] + : []), + ] + : [label]; + const width = Math.max(...lines.map(textWidth)) + PAD_X * 2; + const height = lines.length * LINE_HEIGHT + PAD_Y * 2; + const home = findSlot( + placed, + centerX, + tipY + (down ? height : -height), + width, + height, + box, + !down, + ); + placed.push({ + left: home.x - width / 2, + right: home.x + width / 2, + top: home.y - height / 2, + bottom: home.y + height / 2, + }); + const leader = svgElement("line", { + x1: centerX, + y1: tipY, + x2: home.x, + y2: home.y, + class: "b06-balance__leader", + }); + const text = svgElement("g", { + class: `b06-balance__residual b06-balance__residual--${residual.kind} b06-balance__balloon`, + }); + const labelTitle = svgElement("title"); + labelTitle.textContent = label; + const bottomY = home.y - height / 2 + PAD_Y + LINE_HEIGHT * (lines.length - 0.2); + text.append( + labelTitle, + ...lines.map((line, index) => + svgText(line, { + x: home.x, + y: home.y - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8), + "text-anchor": "middle", + class: "b06-balance__residual-text", + }), + ), + // 도형 대신 언더바만 두른다 — 도형은 운반수단 표시 전용이다. + svgElement("line", { + x1: home.x - width / 2 + PAD_X, + y1: bottomY, + x2: home.x + width / 2 - PAD_X, + y2: bottomY, + class: "b06-balance__residual-underline", + }), + ); + group.append(leader, text); + // 잔량 라벨도 끌어 옮길 수 있다. 띠 번호와 겹치지 않게 **음수 키**를 쓴다. + attachBalloonDrag(text, leader, svg, -residual.index, home); } /** @@ -605,7 +416,6 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala const compact = box.bottom - box.top < 110; for (const transfer of plan.transfers) appendTransfer(group, transfer, box, compact); - for (const residual of plan.residuals) appendResidual(group, residual, box, compact); const faces: SVGGElement[] = []; const balloons: Array = []; @@ -617,6 +427,8 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala for (const band of block.bands) balloons.push(appendBandBalloon(group, svg, block, band, box, placed)); } + // 잔량 라벨은 balloon **뒤에** 자리를 잡는다 — 같은 `placed`를 공유해 서로 겹치지 않는다. + for (const residual of plan.residuals) appendResidual(group, svg, residual, box, placed, compact); const clearAll = (): void => { for (const face of faces) face.classList.remove("is-active"); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts new file mode 100644 index 00000000..97a1c964 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts @@ -0,0 +1,328 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts + * 유토곡선 balloon의 공통 부품 — 좌표 상자 · 도형 · 자리 찾기 · 드래그 · 위치 보관. + * + * 토량 분배 렌더러(`_UI_MassHaul_Balance_View`)가 700줄을 넘겨, 「무엇을 그리나」(그쪽)와 + * 「어떻게 놓고 어떻게 잡나」(여기)로 갈랐다. 띠 balloon과 사토·토취 라벨이 이 부품을 + * **함께** 쓰므로 배치·드래그 규칙이 한 곳에서만 정의된다. + * ========================================================================== */ + +import { L } from "./B06_wf3_ProfileCross_UI_Section_Common"; +import type { LocaleKey } from "@ui/ui_template_locale"; +import { svgElement } from "./B06_wf3_ProfileCross_UI_Section_Common"; + +/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(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 = { + 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 = { + 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; +/** 사토·토취 단차 화살표 길이(px). 단차가 아무리 커도 이만큼만 그린다 — 크기는 라벨이 말한다. */ +export const RESIDUAL_ARROW_PX = 22; + +/** + * 사용자가 끌어 옮긴 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(); + +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; +} + +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 { + 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; + } + } + return { x: clampX(preferredX), y: preferredY }; +} + +/** + * balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는 + * 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시). + * 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다. + */ +export function attachBalloonDrag( + balloon: SVGGElement, + leader: SVGLineElement, + svg: SVGSVGElement, + key: number, + home: { x: number; y: 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 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 = start.dx + (event.clientX - start.x) * factor; + const dy = 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(); + }); +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts index 4b05b256..795d7db5 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts @@ -2,10 +2,17 @@ * B06_wf3_ProfileCross_UI_MassHaul_Curve.ts * 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출. * - * 유토곡선은 측점 사이에서 **직선이 아니라 포물선**이다. 측점별 단면적이 선형으로 변하면 - * 그 적분인 누가토량은 2차식이 되기 때문이다. 이걸 직선으로 보면 평균운반거리(반종거 현)가 - * 참고 도면 대비 약 40% 짧게 나와 장비 경계 판정이 어긋난다 — 도면에서 측점간격 20m 구간의 - * 반종거가 `20/√2 = 14.14m`로 12번 반복해 찍히는 것이 근거다(직선이면 10m). + * ── 측점 사이를 무엇으로 잇는가: **직선**(2026-08-02 사용자 확정) ────────── + * 이론상으로는 포물선이다 — 평균단면법 `V = (A₁+A₂)/2 × L`이 "구간 내 단면적이 선형으로 + * 변한다"를 전제하므로, 그 적분인 누가토량은 2차식이 된다. 참고 도면의 종무대 평균운반거리에 + * `20/√2 = 14.14m`가 12번 찍히는 것도 포물선 + "종거 1/2 수평선" 규칙으로만 설명된다 + * (직선이면 `20/2 = 10m`). + * + * 그럼에도 **직선을 쓴다.** 측점을 기준으로 절·성토량을 읽고 판단하는 것이 실무이고, 도면도 + * 직선으로 그린다는 사용자(임도설계 실무자) 판단이다. 대가는 평균운반거리가 포물선 대비 + * 약 40% 짧게 나오는 것이며, 이는 20m·70m 장비 경계 판정에 직접 영향을 준다. + * + * 되돌릴 일이 생기면 `PARABOLIC_MASS_CURVE` 하나만 켜면 된다 — 그리기·재기가 함께 바뀐다. * * 토량 분배 계산(`_UI_MassHaul_Balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다. * ========================================================================== */ @@ -14,6 +21,13 @@ import type { MassHaulPoint } from "./B06_wf3_ProfileCross_UI_MassHaul"; export const EPSILON = 1e-9; +/** + * 누가토량 곡선을 측점 사이에서 포물선으로 볼지 여부. **false = 직선**(사용자 확정). + * 이 값 하나가 곡선 렌더링(`curvePath`)과 수평선 교점(`crossFrom`)을 함께 바꾼다 — + * 둘이 어긋나면 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 이미 겪은 문제). + */ +export const PARABOLIC_MASS_CURVE = false; + /** * 구간 하나의 2차식 계수. 측점 사이에서 단면적이 선형으로 변하므로 누가토량은 * `V(t) = V₀ + a₀·t + (a₁ − a₀)·t² / (2Δ)` (t = 구간 시작으로부터의 거리) @@ -47,8 +61,9 @@ export function segmentOf(points: MassHaulPoint[], index: number): Segment | nul x0: a.chainage_m, span, v0: a.cumulative_volume_m3, - a0: start + shift, - curvature: (end - start) / (2 * span), + // 직선 모드에서는 구간 평균 단면적 하나로 기울기를 잡는다 → 곡률 0 = 직선. + a0: PARABOLIC_MASS_CURVE ? start + shift : average, + curvature: PARABOLIC_MASS_CURVE ? (end - start) / (2 * span) : 0, }; } 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 318ed37f..123db1bd 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -64,15 +64,12 @@ function seriesClass(series: MassHaulSeries, base: string): string { } /** - * 곡선을 **2차 베지에 경로**로 그린다. 유토곡선은 측점 사이에서 포물선이므로 - * (단면적이 선형 → 그 적분인 누가토량은 2차식) 직선으로 이으면 실제 곡선과 어긋난다. + * 곡선 경로. **`_UI_MassHaul_Curve`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비 + * 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면 + * 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 겪은 문제). * - * 이게 눈에 보이는 이유는 **수평선 때문**이다. 평형선·장비 경계현·평균운반거리선의 양 끝은 - * 포물선과의 교점으로 계산되는데, 곡선만 직선으로 그리면 그 끝점이 곡선에서 떨어져 보인다 - * (2026-08-02 사용자 지적). 두 곳의 기하를 같은 것으로 맞춘다. - * - * 제어점은 `(x₀ + Δ/2, V₀ + a₀Δ/2)` — 이 자리에 두면 2차 베지에가 구간 포물선과 - * **정확히** 같아진다. 순단면적이 없으면 곡률이 0이 되어 자동으로 직선으로 떨어진다. + * 지금은 직선 모드(`PARABOLIC_MASS_CURVE = false`)라 `L` 명령만 나온다. 포물선 모드를 켜면 + * 제어점 `(x₀ + Δ/2, V₀ + a₀Δ/2)`의 2차 베지에가 구간 포물선과 **정확히** 같아진다. */ function curvePath( points: MassHaulPoint[], @@ -88,6 +85,11 @@ function curvePath( parts.push(`L ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`); continue; } + if (Math.abs(segment.curvature) < 1e-12) { + // 직선 모드 — 제어점을 둘 이유가 없다. 명령 자체를 직선으로 낸다. + 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( diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index e3e939cc..11a35ec2 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -43,6 +43,7 @@ import { } from "./B06_wf3_ProfileCross_UI_Standard_Panel"; import "./B06_wf3_ProfileCross_UI_Style.css"; import "./B06_wf3_ProfileCross_UI_Style_Cross.css"; +import "./B06_wf3_ProfileCross_UI_Style_Cross_Areas.css"; import "./B06_wf3_ProfileCross_UI_Style_MassHaul.css"; function L(key: keyof typeof ui_locales): string { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index 610cfea2..783820af 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -525,212 +525,46 @@ font-family: var(--font-mono); } -/* 암 경계선 제어(E-7) — 그래프 영역 **하단 행 가운데**(2026-08-02 사용자 지시). - 우측 맞춤이던 것을 가운데로 옮겼다. `left: 50%` + 음수 translate가 폭을 모르고도 - 가운데를 잡는 유일한 방법이다(절대 위치라 flex 정렬이 안 먹는다). */ +/* 암 경계선 제어(E-7) — **중심고·방위각 행 가운데**(2026-08-02 사용자 지시). + 그래프 안 오버레이였을 때는 단면을 가렸다. footer가 space-between이라 두 수치 사이에 + 끼우면 자연히 가운데로 온다. */ .b06-cross-card__rockb { - position: absolute; - left: 50%; - transform: translateX(-50%); - bottom: 2px; - background: color-mix(in srgb, var(--color-surface) 82%, transparent); + flex: 0 0 auto; border-radius: var(--radius-inputs); padding: 1px 4px; } -/* 절·성토 면적 오버레이(E-4) — 그래프 중상단, 배경색으로 그래프 선과 겹쳐도 가독. - 절토/성토 × EA·RR·BR·계 표라서 칩을 늘어놓을 때보다 가로 폭을 덜 먹는다. */ -.b06-cross-card__areas { +/* 줌 버튼 — 그래프 우측 상단 오버레이. 휠 대신 쓰는 조작구다(2026-08-02 사용자 확정). */ +.b06-cross-card__zoom { position: absolute; - top: var(--spacing-8); - left: 50%; - transform: translateX(-50%); - padding: 2px 6px; + top: var(--spacing-4); + right: var(--spacing-4); + z-index: 2; + display: flex; + overflow: hidden; + border: 1px solid var(--color-border); border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface) 88%, transparent); - font-size: 0.72rem; - font-family: var(--font-mono); - white-space: nowrap; - border-collapse: collapse; - pointer-events: none; + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } -.b06-cross-card__areas th, -.b06-cross-card__areas td { - padding: 0 5px; - text-align: center; - font-weight: var(--font-weight-regular); -} - -/* 열 제목은 가운데 맞춤 — 값은 우측 정렬이라 자릿수가 흔들려도 머리글은 열 중앙에 선다. */ -.b06-cross-card__areas thead th { - text-align: center; - color: var(--color-text-muted); -} - -/* 행 이름(절토/성토)은 왼쪽 정렬 — 숫자 열과 구분된다. */ -.b06-cross-card__areas tbody th { - text-align: left; -} - -/* 면적 표기 색은 **테마 토큰만** 쓴다. 고정 RGB를 박으면 다크 테마에서 글자가 배경에 - 묻힌다(발파암에 쓰던 검정이 그랬다). 차트 팔레트는 밝기가 두 테마 모두에 맞춰져 있다. */ -.b06-design__area--cut, -.b06-design__area--cut_total { - color: var(--color-danger); -} - -/* 절토 내역 — 토사(EA), 리핑암(RR), 발파암(BR)을 색으로 갈라 둔다. */ -.b06-design__area--cut_soil { - color: var(--color-chart-2); -} - -/* RR은 `--color-slate`를 쓰면 안 된다 — 그 토큰이 곧 `--color-text-secondary`라 - 라이트 테마에서 회색 글자가 "비활성"으로 읽히고, 28% 밴드는 흰 배경에 묻힌다. - `--color-warning`도 못 쓴다(암 경계선 점선이 이미 그 색이라 밴드가 제 경계선과 섞인다). */ -.b06-design__area--cut_rr { - color: var(--color-success); -} - -.b06-design__area--cut_br { - color: var(--color-chart-3); -} - -.b06-design__area--fill { - color: var(--color-chart-0); -} - -.b06-design__area--unset { - color: var(--color-text-muted); -} - -/* 선택된 카드에서만 값 칸이 버튼이 된다 — 부모가 pointer-events: none이라 여기서 되살린다. */ -.b06-cross-card__areas button { - padding: 0 2px; - border: none; - border-radius: var(--radius-inputs); - background: none; - color: inherit; - font-family: inherit; - font-size: inherit; - cursor: pointer; - pointer-events: auto; -} - -/* 켠 칸은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */ -.b06-cross-card__areas button.is-active { - background: color-mix(in srgb, currentcolor 18%, transparent); - box-shadow: inset 0 0 0 1px currentcolor; - font-weight: var(--font-weight-medium); -} - -/* 면적 강조 밴드 — 평소엔 클릭 대상으로만 남고, 켜질 때만 채워진다. */ -.b06-chart__area > polygon { - fill: transparent; - stroke: none; - cursor: pointer; -} - -/* 밴드 색은 위 면적표 글자색과 짝을 맞춘다 — 어느 숫자를 켰는지 색으로 바로 읽히게. */ -.b06-chart__area--cut_soil.is-active > polygon { - fill: color-mix(in srgb, var(--color-chart-2) 28%, transparent); - stroke: var(--color-chart-2); - stroke-width: 1.2; -} - -/* 암반 밴드는 하나지만 색은 암종을 따라간다 — 표의 RR/BR 글자색과 같아야 짝이 읽힌다. */ -.b06-chart__area--cut_rr.is-active > polygon { - fill: color-mix(in srgb, var(--color-success) 28%, transparent); - stroke: var(--color-success); - stroke-width: 1.2; -} - -.b06-chart__area--cut_br.is-active > polygon { - fill: color-mix(in srgb, var(--color-chart-3) 28%, transparent); - stroke: var(--color-chart-3); - stroke-width: 1.2; -} - -.b06-chart__area--fill.is-active > polygon { - fill: color-mix(in srgb, var(--color-chart-0) 28%, transparent); - stroke: var(--color-chart-0); - stroke-width: 1.2; -} - -/* 횡단 표준단면 설계선 오버레이. 기본은 실선. */ -.b06-chart__design-cross { - fill: none; - stroke: var(--color-royal-amethyst); - stroke-width: 1.8; - stroke-linejoin: round; -} - -/* 지면선과 겹치는 구간만 점선(dash:gap = 1:1)으로 그려 뒤 지표선이 빈 칸으로 비쳐 보이게 한다. */ -.b06-chart__design-cross--overlap { - stroke-dasharray: 4 4; -} - -/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ -.b06-chart__carriageway-tick { - stroke: var(--color-royal-amethyst); - stroke-width: 1.2; -} - -/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */ -.b06-chart__rock-boundary { - fill: none; - stroke: var(--color-warning); - stroke-width: 1.6; - stroke-dasharray: 6 4; - stroke-linejoin: round; - opacity: 0.9; -} - -/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */ -.b06-chart__pavement { - fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent); - stroke: var(--color-text-secondary); - stroke-width: 1; -} - -/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */ -.b06-design__rockb-btn { +.b06-cross-card__zoom-btn { width: 22px; height: 22px; padding: 0; - font-size: 0.7rem; - line-height: 1; - color: var(--color-text-secondary); - background: var(--color-surface); border: none; border-left: 1px solid var(--color-border); + background: none; + color: var(--color-text-secondary); + font-size: 0.8rem; + line-height: 1; cursor: pointer; } -.b06-design__rockb-btn:first-child { +.b06-cross-card__zoom-btn:first-child { border-left: none; } -.b06-design__rockb-btn:hover { +.b06-cross-card__zoom-btn:hover { color: var(--color-text); - background: var(--color-surface-raised); -} - -.b06-design__rockb-btn.is-reset { - color: var(--color-warning); -} - -.b06-design__rockb-readout { - padding: 0 var(--spacing-8); - font-size: 0.72rem; - font-family: var(--font-mono); - color: var(--color-warning); - align-self: center; -} - -/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */ -.b06-design__paved-badge { - font-size: 0.72rem; - color: var(--color-warning); - cursor: help; + background: var(--color-surface); } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross_Areas.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross_Areas.css new file mode 100644 index 00000000..829a3749 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross_Areas.css @@ -0,0 +1,205 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_Style_Cross_Areas.css + * 횡단 카드의 **면적 오버레이 계열** 스타일 — 절·성토 면적 표, 면적 강조 밴드, + * 설계선·암 경계선·포장층, 암 경계선 제어 버튼. + * + * `_UI_Style_Cross.css`가 700줄을 넘겨, 도면 뼈대(섹션·카드·차트·줌/팬)와 + * 그 위에 얹는 면적 표기 계열을 갈랐다. 색은 모두 theme.css 토큰만 쓴다. + * ========================================================================== */ + +/* 절·성토 면적 오버레이(E-4) — 그래프 중상단, 배경색으로 그래프 선과 겹쳐도 가독. + 절토/성토 × EA·RR·BR·계 표라서 칩을 늘어놓을 때보다 가로 폭을 덜 먹는다. */ +.b06-cross-card__areas { + position: absolute; + top: var(--spacing-8); + left: 50%; + transform: translateX(-50%); + padding: 2px 6px; + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface) 88%, transparent); + font-size: 0.72rem; + font-family: var(--font-mono); + white-space: nowrap; + border-collapse: collapse; + pointer-events: none; +} + +.b06-cross-card__areas th, +.b06-cross-card__areas td { + padding: 0 5px; + text-align: center; + font-weight: var(--font-weight-regular); +} + +/* 열 제목은 가운데 맞춤 — 값은 우측 정렬이라 자릿수가 흔들려도 머리글은 열 중앙에 선다. */ +.b06-cross-card__areas thead th { + text-align: center; + color: var(--color-text-muted); +} + +/* 행 이름(절토/성토)은 왼쪽 정렬 — 숫자 열과 구분된다. */ +.b06-cross-card__areas tbody th { + text-align: left; +} + +/* 면적 표기 색은 **테마 토큰만** 쓴다. 고정 RGB를 박으면 다크 테마에서 글자가 배경에 + 묻힌다(발파암에 쓰던 검정이 그랬다). 차트 팔레트는 밝기가 두 테마 모두에 맞춰져 있다. */ +.b06-design__area--cut, +.b06-design__area--cut_total { + color: var(--color-danger); +} + +/* 절토 내역 — 토사(EA), 리핑암(RR), 발파암(BR)을 색으로 갈라 둔다. */ +.b06-design__area--cut_soil { + color: var(--color-chart-2); +} + +/* RR은 `--color-slate`를 쓰면 안 된다 — 그 토큰이 곧 `--color-text-secondary`라 + 라이트 테마에서 회색 글자가 "비활성"으로 읽히고, 28% 밴드는 흰 배경에 묻힌다. + `--color-warning`도 못 쓴다(암 경계선 점선이 이미 그 색이라 밴드가 제 경계선과 섞인다). */ +.b06-design__area--cut_rr { + color: var(--color-success); +} + +.b06-design__area--cut_br { + color: var(--color-chart-3); +} + +.b06-design__area--fill { + color: var(--color-chart-0); +} + +.b06-design__area--unset { + color: var(--color-text-muted); +} + +/* 선택된 카드에서만 값 칸이 버튼이 된다 — 부모가 pointer-events: none이라 여기서 되살린다. */ +.b06-cross-card__areas button { + padding: 0 2px; + border: none; + border-radius: var(--radius-inputs); + background: none; + color: inherit; + font-family: inherit; + font-size: inherit; + cursor: pointer; + pointer-events: auto; +} + +/* 켠 칸은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */ +.b06-cross-card__areas button.is-active { + background: color-mix(in srgb, currentcolor 18%, transparent); + box-shadow: inset 0 0 0 1px currentcolor; + font-weight: var(--font-weight-medium); +} + +/* 면적 강조 밴드 — 평소엔 클릭 대상으로만 남고, 켜질 때만 채워진다. */ +.b06-chart__area > polygon { + fill: transparent; + stroke: none; + cursor: pointer; +} + +/* 밴드 색은 위 면적표 글자색과 짝을 맞춘다 — 어느 숫자를 켰는지 색으로 바로 읽히게. */ +.b06-chart__area--cut_soil.is-active > polygon { + fill: color-mix(in srgb, var(--color-chart-2) 28%, transparent); + stroke: var(--color-chart-2); + stroke-width: 1.2; +} + +/* 암반 밴드는 하나지만 색은 암종을 따라간다 — 표의 RR/BR 글자색과 같아야 짝이 읽힌다. */ +.b06-chart__area--cut_rr.is-active > polygon { + fill: color-mix(in srgb, var(--color-success) 28%, transparent); + stroke: var(--color-success); + stroke-width: 1.2; +} + +.b06-chart__area--cut_br.is-active > polygon { + fill: color-mix(in srgb, var(--color-chart-3) 28%, transparent); + stroke: var(--color-chart-3); + stroke-width: 1.2; +} + +.b06-chart__area--fill.is-active > polygon { + fill: color-mix(in srgb, var(--color-chart-0) 28%, transparent); + stroke: var(--color-chart-0); + stroke-width: 1.2; +} + +/* 횡단 표준단면 설계선 오버레이. 기본은 실선. */ +.b06-chart__design-cross { + fill: none; + stroke: var(--color-royal-amethyst); + stroke-width: 1.8; + stroke-linejoin: round; +} + +/* 지면선과 겹치는 구간만 점선(dash:gap = 1:1)으로 그려 뒤 지표선이 빈 칸으로 비쳐 보이게 한다. */ +.b06-chart__design-cross--overlap { + stroke-dasharray: 4 4; +} + +/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ +.b06-chart__carriageway-tick { + stroke: var(--color-royal-amethyst); + stroke-width: 1.2; +} + +/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */ +.b06-chart__rock-boundary { + fill: none; + stroke: var(--color-warning); + stroke-width: 1.6; + stroke-dasharray: 6 4; + stroke-linejoin: round; + opacity: 0.9; +} + +/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */ +.b06-chart__pavement { + fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent); + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */ +.b06-design__rockb-btn { + width: 22px; + height: 22px; + padding: 0; + font-size: 0.7rem; + line-height: 1; + color: var(--color-text-secondary); + background: var(--color-surface); + border: none; + border-left: 1px solid var(--color-border); + cursor: pointer; +} + +.b06-design__rockb-btn:first-child { + border-left: none; +} + +.b06-design__rockb-btn:hover { + color: var(--color-text); + background: var(--color-surface-raised); +} + +.b06-design__rockb-btn.is-reset { + color: var(--color-warning); +} + +.b06-design__rockb-readout { + padding: 0 var(--spacing-8); + font-size: 0.72rem; + font-family: var(--font-mono); + color: var(--color-warning); + align-self: center; +} + +/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */ +.b06-design__paved-badge { + font-size: 0.72rem; + color: var(--color-warning); + cursor: help; +} 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 e2e40ada..88a1f5c9 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -292,16 +292,24 @@ stroke-width: 1.4; } -.b06-balance__residual--spoil .b06-balance__step-arrow, -.b06-balance__residual--spoil .b06-balance__residual-text { +/* 색은 **선에는 stroke, 글자에는 fill**로만 준다 — 글자의 stroke는 아래 가독성 테두리 + 전용이라, 여기서 함께 칠하면 글자가 제 색으로 굵게 번진다. */ +.b06-balance__residual--spoil .b06-balance__step-arrow { stroke: var(--color-danger); +} + +.b06-balance__residual--spoil .b06-balance__residual-text { fill: var(--color-danger); } -.b06-balance__residual--borrow .b06-balance__step-arrow, +/* 토취는 **주황**이다(2026-08-02 사용자 확정: D안). 초록은 "정상"으로 읽혀, + 흙이 모자라 사와야 한다는 경고 성격과 맞지 않았다. */ +.b06-balance__residual--borrow .b06-balance__step-arrow { + stroke: var(--color-chart-2); +} + .b06-balance__residual--borrow .b06-balance__residual-text { - stroke: var(--color-success); - fill: var(--color-success); + fill: var(--color-chart-2); } .b06-balance__residual--spoil .b06-balance__step-head { @@ -310,12 +318,21 @@ } .b06-balance__residual--borrow .b06-balance__step-head { - fill: var(--color-success); + fill: var(--color-chart-2); stroke: none; } +/* 사토·토취 라벨도 balloon과 같이 끌어 옮길 수 있다(도형은 두르지 않고 언더바만). + 도형 배경이 없으므로 글자에 표면색 테두리를 둘러 곡선 위에서도 읽히게 한다. */ +.b06-balance__residual.b06-balance__balloon { + cursor: move; +} + .b06-balance__residual-text { - stroke: none; + paint-order: stroke; + stroke: var(--color-surface-raised); + stroke-width: 3; + stroke-linejoin: round; font-size: 9px; font-variant-numeric: tabular-nums; } @@ -327,7 +344,7 @@ } .b06-balance__residual--borrow .b06-balance__residual-underline { - stroke: var(--color-success); + stroke: var(--color-chart-2); stroke-width: 1.2; } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index c116b96c..9da4d9b3 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -254,6 +254,10 @@ 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_Profile_View_ZoomIn: ["확대", "Zoom in"], + B06_Profile_View_ZoomOut: ["축소", "Zoom out"], + B06_Profile_View_ZoomFit: ["원래 크기", "Fit"], B06_MassHaul_Balloon_Reset_Tip: [ "끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. 횡단을 확정하면 저장된 위치도 함께 지워집니다.", "Restores dragged mass-haul labels to automatic placement. Confirming the sections also clears the stored positions.",