Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
This commit is contained in:
@@ -69,17 +69,9 @@ const notify = (projectId: string): void => {
|
||||
busyListeners.get(projectId)?.forEach((fn) => fn(now));
|
||||
};
|
||||
|
||||
const launchedBy = new Map<string, Event>();
|
||||
|
||||
/** 이 누름이 묶음 차례를 띄웠나 — 그 누름 안 폼 다시 실음은 좌측 패널 안 폄(`design_user_pick` · 398-1 · 505-2). */
|
||||
export const pageLaunchedBy = (projectId: string, event: Event): boolean =>
|
||||
launchedBy.get(projectId) === event;
|
||||
|
||||
/** 묶음 차례 하나가 뜸 — 띄운 누름을 적음 · 끝나면(밀림 · 실패 포함) 내림. */
|
||||
/** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */
|
||||
export function trackPageBusy(projectId: string, run: Promise<unknown> | null): void {
|
||||
if (!run) return;
|
||||
const event = (globalThis as { event?: Event }).event;
|
||||
if (event) launchedBy.set(projectId, event);
|
||||
busy.set(projectId, (busy.get(projectId) ?? 0) + 1);
|
||||
notify(projectId);
|
||||
const done = (): void => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Chart.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
|
||||
|
||||
export function createProfileFrame(view: string) {
|
||||
|
||||
@@ -41,7 +41,6 @@ import type { DesignView } from "./design_dock";
|
||||
import type { FacilityLane } from "./design_facility_lane";
|
||||
import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page";
|
||||
import { plantedBase } from "./design_pipeline_base";
|
||||
import { isUserPick } from "./design_user_pick";
|
||||
import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
@@ -331,10 +330,8 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
onPipeUpdate: (from, to, attributes) => on().onPipeUpdate?.(from, to, attributes),
|
||||
onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM),
|
||||
onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM),
|
||||
// 고름은 내용만 — 좌측 패널 · 「구조물 배치」 카드 열림 · 닫힘은 사용자가 둔 그대로(513-2)
|
||||
onListRelease: () => on().onListRelease?.(),
|
||||
// 사용자 고름 = 「구조물 배치」 카드 폄 — 좌측 패널 열림 · 닫힘은 그대로(398-1 · 499-1)
|
||||
onReveal: () =>
|
||||
isUserPick(shared.projectId) && shared.structures?.root.classList.remove("is-collapsed"),
|
||||
},
|
||||
{ includeDetail: () => shared.view() === "cross", projectId: shared.projectId },
|
||||
);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_user_pick.ts
|
||||
* 「사용자 고름」 표시 한 칸(PLAN 398-1) — 좌측 패널 저절로 펴기(09-04 「바깥에서 고르면 좌측 패널도」)는
|
||||
* 사용자가 직접 누른 고름에서만.
|
||||
*
|
||||
* 사용자 고름 = 지금 브라우저가 진짜 누름(포인터 · 마우스 · 우클릭)을 처리하는 중 — 눈금 · 마름모 · 3D ·
|
||||
* 종단 알약 · 배수유역도 · 횡단 카드 · 우클릭 추가가 다 그 누름 안에서 폼을 엶.
|
||||
* 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음을 띄운 그 누름
|
||||
* (교점 끌기 · 곡선 지우기 · 되돌리기가 띄운 차례의 관 · 구조물 따라가기 · 다시 칠하기).
|
||||
* 「계산 중」 동안 새로 누른 고름은 폄(505-2).
|
||||
* 폼이 고른 것을 다시 실을 때마다 부르는 쪽을 막지 않고 펴기 한 곳(`design_shared` onReveal)이 여기만 봄.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pageLaunchedBy } from "./design_page_data";
|
||||
|
||||
const PICK_EVENTS = new Set([
|
||||
"pointerdown",
|
||||
"pointerup",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
"click",
|
||||
"dblclick",
|
||||
"contextmenu",
|
||||
]);
|
||||
|
||||
/** 지금 사용자 누름 안이고 그 누름이 묶음을 안 띄웠나 — 좌측 패널을 펴도 되는가. */
|
||||
export function isUserPick(projectId: string | null): boolean {
|
||||
const event = (globalThis as { event?: Event }).event; // 처리 중인 이벤트(그 마이크로태스크까지)
|
||||
if (!event?.isTrusted || !PICK_EVENTS.has(event.type)) return false;
|
||||
return !projectId || !pageLaunchedBy(projectId, event);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Structures_Selection";
|
||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Chart.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
|
||||
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
|
||||
@@ -49,6 +49,7 @@ import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
|
||||
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
|
||||
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
|
||||
import "./B01_Dashboard_UI_Style.css";
|
||||
import "./B01_Dashboard_UI_Style_Company.css";
|
||||
|
||||
export interface DashboardState {
|
||||
user: DashboardUser;
|
||||
|
||||
@@ -385,428 +385,3 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13).
|
||||
라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라
|
||||
그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */
|
||||
.b01-dashboard__address {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-field {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__label {
|
||||
flex: 0 0 64px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-zip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8, 8px);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:hover,
|
||||
.b01-dashboard__address-item:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item small {
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-zoom {
|
||||
display: flex;
|
||||
gap: var(--spacing-8, 8px);
|
||||
margin-bottom: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
|
||||
.b01-dashboard__map-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 384px;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 256px);
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.b01-dashboard__map-marker {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px 0 0 -6px;
|
||||
border: 2px solid var(--color-surface, #fff);
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger, #d33);
|
||||
}
|
||||
|
||||
/* 프로젝트 참여자 고르기 (2026-09-06). */
|
||||
.b01-dashboard__members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle,
|
||||
.b01-knowledge-chat__form button {
|
||||
border: 0;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 16px #0004;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
height: min(680px, calc(100dvh - 104px));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
box-shadow: 0 8px 28px #0003;
|
||||
color: var(--color-text-body);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__close {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__messages {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user,
|
||||
.b01-knowledge-chat__reply {
|
||||
max-width: 88%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user {
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom-left-radius: 4px;
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply details {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply summary {
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply pre {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__sources {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__sources > summary {
|
||||
margin-bottom: 3px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__source {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__source span {
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.8em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal.ui-modal {
|
||||
width: min(900px, 94vw);
|
||||
--modal-max: 88vh;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content {
|
||||
max-height: calc(88vh - 100px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 8px 16px;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content h2,
|
||||
.b01-knowledge-source-modal__content h3,
|
||||
.b01-knowledge-source-modal__content h4,
|
||||
.b01-knowledge-source-modal__content h5 {
|
||||
margin: 1.4em 0 0.5em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content p,
|
||||
.b01-knowledge-source-modal__content ul,
|
||||
.b01-knowledge-source-modal__content ol {
|
||||
margin: 0.6em 0 1em;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content .ui-markdown-table {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th,
|
||||
.b01-knowledge-source-modal__content td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form textarea {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
max-height: 140px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form textarea::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form button {
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat button:focus-visible,
|
||||
.b01-knowledge-chat textarea:focus-visible {
|
||||
outline: 2px solid var(--color-royal-amethyst, #6843a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 계산 자리 창(504-2) — 묶음 · 까닭 글은 줄바꿈 · 체크 칸은 가운데 */
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td {
|
||||
white-space: normal;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(2),
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(3) {
|
||||
text-align: center;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group td {
|
||||
background: var(--color-surface);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-name {
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group .b01-dashboard__calc-name {
|
||||
padding-left: 0;
|
||||
}
|
||||
.b01-dashboard__calc-count,
|
||||
.b01-dashboard__calc-note {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13).
|
||||
라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라
|
||||
그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */
|
||||
.b01-dashboard__address {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-field {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__label {
|
||||
flex: 0 0 64px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-zip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8, 8px);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:hover,
|
||||
.b01-dashboard__address-item:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item small {
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-zoom {
|
||||
display: flex;
|
||||
gap: var(--spacing-8, 8px);
|
||||
margin-bottom: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
|
||||
.b01-dashboard__map-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 384px;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 256px);
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.b01-dashboard__map-marker {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px 0 0 -6px;
|
||||
border: 2px solid var(--color-surface, #fff);
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger, #d33);
|
||||
}
|
||||
|
||||
/* 프로젝트 참여자 고르기 (2026-09-06). */
|
||||
.b01-dashboard__members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle,
|
||||
.b01-knowledge-chat__form button {
|
||||
border: 0;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 16px #0004;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
height: min(680px, calc(100dvh - 104px));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
box-shadow: 0 8px 28px #0003;
|
||||
color: var(--color-text-body);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__close {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__messages {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user,
|
||||
.b01-knowledge-chat__reply {
|
||||
max-width: 88%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user {
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom-left-radius: 4px;
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply details {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply summary {
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply pre {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__sources {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__sources > summary {
|
||||
margin-bottom: 3px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__source {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__source span {
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.8em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal.ui-modal {
|
||||
width: min(900px, 94vw);
|
||||
--modal-max: 88vh;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content {
|
||||
max-height: calc(88vh - 100px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 8px 16px;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content h2,
|
||||
.b01-knowledge-source-modal__content h3,
|
||||
.b01-knowledge-source-modal__content h4,
|
||||
.b01-knowledge-source-modal__content h5 {
|
||||
margin: 1.4em 0 0.5em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content p,
|
||||
.b01-knowledge-source-modal__content ul,
|
||||
.b01-knowledge-source-modal__content ol {
|
||||
margin: 0.6em 0 1em;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content .ui-markdown-table {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th,
|
||||
.b01-knowledge-source-modal__content td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form textarea {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
max-height: 140px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form textarea::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form button {
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat button:focus-visible,
|
||||
.b01-knowledge-chat textarea:focus-visible {
|
||||
outline: 2px solid var(--color-royal-amethyst, #6843a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 계산 자리 창(504-2) — 묶음 · 까닭 글은 줄바꿈 · 체크 칸은 가운데 */
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td {
|
||||
white-space: normal;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(2),
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(3) {
|
||||
text-align: center;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group td {
|
||||
background: var(--color-surface);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-name {
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group .b01-dashboard__calc-name {
|
||||
padding-left: 0;
|
||||
}
|
||||
.b01-dashboard__calc-count,
|
||||
.b01-dashboard__calc-note {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type UploadStatus,
|
||||
} from "./B03_FileInput_UI_Support";
|
||||
import "./B03_FileInput_UI_Style.css";
|
||||
import "./B03_FileInput_UI_Style_Card.css";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
|
||||
@@ -348,367 +348,3 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
/* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */
|
||||
.b03-file--busy {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b03-file--busy .b03-file__card-select,
|
||||
.b03-file--busy .b03-file__card-remove,
|
||||
.b03-file--busy .b03-file__dropzone {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 글자 크기에 맞춰 줄인다. */
|
||||
.b03-file__upload-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
border-color: rgb(255 255 255 / 40%);
|
||||
border-top-color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
/* Wiza 8px radius 카드 */
|
||||
/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) —
|
||||
파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */
|
||||
.b03-file__card {
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
transition:
|
||||
transform var(--transition-base, 0.2s),
|
||||
border-color var(--transition-base, 0.2s),
|
||||
box-shadow var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
}
|
||||
|
||||
.b03-file__card--selected {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-paper, #f6f7fa);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
box-shadow: var(--shadow-lg-2);
|
||||
}
|
||||
|
||||
.b03-file__card--completed {
|
||||
border-color: #10b981; /* Success Green */
|
||||
}
|
||||
|
||||
.b03-file__card--error {
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이
|
||||
글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */
|
||||
/* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로
|
||||
내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */
|
||||
/* ⚠ 좁은 폭에서 배지·[선택]·[×] 가 밀려 나가지 않게 **줄 자체가 넘치지 않도록** 막는다
|
||||
(2026-09-08). 제목은 이미 말줄임이라, 넘치는 것은 늘 오른쪽 붙박이들이었다. */
|
||||
.b03-file__card-header {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.b03-file__card-header > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__card-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-icons, 8px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.b03-file__card-label {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
/* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.b03-file__card-ext,
|
||||
.b03-file__file-size,
|
||||
.b03-file__progress-info {
|
||||
color: var(--color-slate, #615e6e);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__card-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b03-file__card-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card-remove:hover {
|
||||
color: var(--color-danger, #dc2626);
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.b03-file__card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 빈 칸은 제목 줄만 남긴다 — 파일이 붙으면 이름·크기가, 올라가는 중에는 진행바가 선다. */
|
||||
.b03-file__card--empty .b03-file__card-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */
|
||||
.b03-file__card-select {
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
font-size: var(--text-caption, 12px);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-base, 0.2s),
|
||||
border-color var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__file-name {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 진행바·속도·예상완료는 **올라가는 동안만** 보인다(2026-09-03) — 끝난 카드에 남겨 두면
|
||||
읽을 일 없는 세 줄이 카드 높이를 그대로 차지했다. */
|
||||
.b03-file__progress-section {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading .b03-file__progress-section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.b03-file__progress-bar-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-mist, #e6e2e3);
|
||||
}
|
||||
|
||||
.b03-file__progress-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-royal-amethyst, #3e0079);
|
||||
transition: width var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b03-file__error-message {
|
||||
display: none;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
font-size: var(--text-caption, 12px);
|
||||
border: 1px solid rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
|
||||
.b03-file__card--empty .b03-file__file-info,
|
||||
.b03-file__card--empty .b03-file__preview,
|
||||
.b03-file__card--empty .b03-file__progress-section,
|
||||
.b03-file__card--empty .b03-file__error-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형.
|
||||
값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */
|
||||
.b03-file__preview {
|
||||
display: none;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가
|
||||
파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */
|
||||
.b03-file__preview.is-visible {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border, #dcdce6);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.b03-file__preview svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */
|
||||
.b03-file__preview-cloud {
|
||||
fill: none;
|
||||
stroke: var(--color-text-muted, #5a5a72);
|
||||
stroke-linecap: round;
|
||||
stroke-opacity: 0.55;
|
||||
stroke-width: 0.7;
|
||||
}
|
||||
|
||||
/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라
|
||||
다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다
|
||||
(2026-09-04 사용자 지적). */
|
||||
.b03-file__preview-thumb {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px dashed var(--color-border, #9aa);
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */
|
||||
.b03-file__preview-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b03-file__preview-fact {
|
||||
color: var(--color-text, #1c1c28);
|
||||
font-size: var(--text-body, 15px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b03-file__preview-fact-sub {
|
||||
color: var(--color-text-muted, #5a5a72);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__preview-empty {
|
||||
color: var(--color-text-muted, #8a8aa0);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__preview-extent {
|
||||
fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent);
|
||||
stroke: var(--color-border, #9aa);
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b03-file__preview-route {
|
||||
fill: none;
|
||||
/* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서
|
||||
붉은색으로 덮어써 경고로 쓴다. */
|
||||
stroke: var(--color-success, #16a34a);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */
|
||||
.b03-file__preview.is-warning .b03-file__preview-route {
|
||||
stroke: var(--color-danger, #d33);
|
||||
}
|
||||
|
||||
.b03-file__preview.is-warning .b03-file__preview-extent {
|
||||
stroke: var(--color-danger, #d33);
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
|
||||
.b03-file__card--selected .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아
|
||||
끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */
|
||||
.b03-file__card--uploading .b03-file__card-select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__error-message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */
|
||||
.b03-file--busy {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b03-file--busy .b03-file__card-select,
|
||||
.b03-file--busy .b03-file__card-remove,
|
||||
.b03-file--busy .b03-file__dropzone {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 글자 크기에 맞춰 줄인다. */
|
||||
.b03-file__upload-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
border-color: rgb(255 255 255 / 40%);
|
||||
border-top-color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
/* Wiza 8px radius 카드 */
|
||||
/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) —
|
||||
파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */
|
||||
.b03-file__card {
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
transition:
|
||||
transform var(--transition-base, 0.2s),
|
||||
border-color var(--transition-base, 0.2s),
|
||||
box-shadow var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
}
|
||||
|
||||
.b03-file__card--selected {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-paper, #f6f7fa);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
box-shadow: var(--shadow-lg-2);
|
||||
}
|
||||
|
||||
.b03-file__card--completed {
|
||||
border-color: #10b981; /* Success Green */
|
||||
}
|
||||
|
||||
.b03-file__card--error {
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이
|
||||
글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */
|
||||
/* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로
|
||||
내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */
|
||||
/* ⚠ 좁은 폭에서 배지·[선택]·[×] 가 밀려 나가지 않게 **줄 자체가 넘치지 않도록** 막는다
|
||||
(2026-09-08). 제목은 이미 말줄임이라, 넘치는 것은 늘 오른쪽 붙박이들이었다. */
|
||||
.b03-file__card-header {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.b03-file__card-header > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__card-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-icons, 8px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.b03-file__card-label {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
/* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.b03-file__card-ext,
|
||||
.b03-file__file-size,
|
||||
.b03-file__progress-info {
|
||||
color: var(--color-slate, #615e6e);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__card-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b03-file__card-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card-remove:hover {
|
||||
color: var(--color-danger, #dc2626);
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.b03-file__card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 빈 칸은 제목 줄만 남긴다 — 파일이 붙으면 이름·크기가, 올라가는 중에는 진행바가 선다. */
|
||||
.b03-file__card--empty .b03-file__card-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */
|
||||
.b03-file__card-select {
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
font-size: var(--text-caption, 12px);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-base, 0.2s),
|
||||
border-color var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__file-name {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 진행바·속도·예상완료는 **올라가는 동안만** 보인다(2026-09-03) — 끝난 카드에 남겨 두면
|
||||
읽을 일 없는 세 줄이 카드 높이를 그대로 차지했다. */
|
||||
.b03-file__progress-section {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading .b03-file__progress-section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.b03-file__progress-bar-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-mist, #e6e2e3);
|
||||
}
|
||||
|
||||
.b03-file__progress-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-royal-amethyst, #3e0079);
|
||||
transition: width var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b03-file__error-message {
|
||||
display: none;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
font-size: var(--text-caption, 12px);
|
||||
border: 1px solid rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
|
||||
.b03-file__card--empty .b03-file__file-info,
|
||||
.b03-file__card--empty .b03-file__preview,
|
||||
.b03-file__card--empty .b03-file__progress-section,
|
||||
.b03-file__card--empty .b03-file__error-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형.
|
||||
값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */
|
||||
.b03-file__preview {
|
||||
display: none;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가
|
||||
파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */
|
||||
.b03-file__preview.is-visible {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border, #dcdce6);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.b03-file__preview svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */
|
||||
.b03-file__preview-cloud {
|
||||
fill: none;
|
||||
stroke: var(--color-text-muted, #5a5a72);
|
||||
stroke-linecap: round;
|
||||
stroke-opacity: 0.55;
|
||||
stroke-width: 0.7;
|
||||
}
|
||||
|
||||
/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라
|
||||
다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다
|
||||
(2026-09-04 사용자 지적). */
|
||||
.b03-file__preview-thumb {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px dashed var(--color-border, #9aa);
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */
|
||||
.b03-file__preview-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b03-file__preview-fact {
|
||||
color: var(--color-text, #1c1c28);
|
||||
font-size: var(--text-body, 15px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b03-file__preview-fact-sub {
|
||||
color: var(--color-text-muted, #5a5a72);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__preview-empty {
|
||||
color: var(--color-text-muted, #8a8aa0);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__preview-extent {
|
||||
fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent);
|
||||
stroke: var(--color-border, #9aa);
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b03-file__preview-route {
|
||||
fill: none;
|
||||
/* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서
|
||||
붉은색으로 덮어써 경고로 쓴다. */
|
||||
stroke: var(--color-success, #16a34a);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */
|
||||
.b03-file__preview.is-warning .b03-file__preview-route {
|
||||
stroke: var(--color-danger, #d33);
|
||||
}
|
||||
|
||||
.b03-file__preview.is-warning .b03-file__preview-extent {
|
||||
stroke: var(--color-danger, #d33);
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
|
||||
.b03-file__card--selected .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아
|
||||
끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */
|
||||
.b03-file__card--uploading .b03-file__card-select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__error-message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
@@ -18,8 +18,13 @@ import type {
|
||||
AlignmentSegment,
|
||||
ProfileAlignment,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { crowdedThickness } from "../B06_Section/logic/B06_Section_Label_Tiers";
|
||||
import {
|
||||
buildSelectedColumn,
|
||||
displayStationLabel,
|
||||
element,
|
||||
isStraightCurve,
|
||||
} from "./B05_Profile_UI_Profile_Table_Selected";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
@@ -90,20 +95,6 @@ const CURVE_ROTATED_WIDTH_PX = 16;
|
||||
*/
|
||||
const SAME_STATION_TOLERANCE_M = 0.1;
|
||||
|
||||
/**
|
||||
* 대수차가 이 값보다 작으면 **직선**으로 본다(%).
|
||||
*
|
||||
* 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를
|
||||
* 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다
|
||||
* (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다.
|
||||
*/
|
||||
const STRAIGHT_DELTA_PCT = 1e-4;
|
||||
|
||||
/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */
|
||||
function isStraightCurve(curve: AlignmentCurve): boolean {
|
||||
return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT;
|
||||
}
|
||||
|
||||
/** 구조물(비정규) 측점 누가거리 목록에 이 측점이 들어 있는가 — 허용 오차로 본다. */
|
||||
function isNearChainage(chainageM: number, list: ReadonlyArray<number>): boolean {
|
||||
return list.some((entry) => Math.abs(entry - chainageM) < SAME_STATION_TOLERANCE_M);
|
||||
@@ -130,14 +121,6 @@ function fitFontSize(rowHeight: number, cellWidth: number): number {
|
||||
function segmentTextFits(text: string, widthPx: number, fontPx: number): boolean {
|
||||
return widthPx >= text.length * fontPx * CHAR_WIDTH_EM + CELL_PADDING_PX * 2;
|
||||
}
|
||||
|
||||
function element(tag: string, className: string, text?: string): HTMLElement {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 절대 배치 셀: 측점 x를 중심으로 좌우 대칭 배치한다. */
|
||||
function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void {
|
||||
node.style.left = `${centerX}px`;
|
||||
@@ -184,15 +167,6 @@ function createRow(className: string, label: string, unit: string): HTMLElement
|
||||
const SEGMENT_ROWS: SegmentRowSpec[] = [
|
||||
{ label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) },
|
||||
];
|
||||
|
||||
/** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */
|
||||
function displayStationLabel(chainageM: number, interval: number, stationOffset: number): string {
|
||||
const base = stationLabel(chainageM, interval); // "X+YY.Y"
|
||||
if (!stationOffset) return base;
|
||||
const [number, remainder] = base.split("+");
|
||||
return `${Number(number) + stationOffset}+${remainder}`;
|
||||
}
|
||||
|
||||
function buildStationRows(
|
||||
alignment: ProfileAlignment,
|
||||
interval: number,
|
||||
@@ -423,134 +397,6 @@ function buildCurveRows(
|
||||
return [lengthRow, radiusRow];
|
||||
}
|
||||
|
||||
/** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */
|
||||
function interpolateSample(
|
||||
samples: ProfileAlignment["samples"],
|
||||
chainageM: number,
|
||||
key: "elevation_m" | "ground_elevation_m",
|
||||
): number | null {
|
||||
if (!samples.length) return null;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0][key];
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last[key];
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const current = samples[index];
|
||||
if (current.chainage_m < chainageM) continue;
|
||||
const previous = samples[index - 1];
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 1e-9) return current[key];
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous[key] + (current[key] - previous[key]) * ratio;
|
||||
}
|
||||
return last[key];
|
||||
}
|
||||
|
||||
/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */
|
||||
type CellValue = { text: string } | { left: string; right: string };
|
||||
|
||||
/**
|
||||
* 선택된 측점(규칙·비정규 공용)의 **값 열 오버레이**. 12행 순서 그대로 값(계획고·지반고·누가거리·
|
||||
* 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로
|
||||
* 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된
|
||||
* 중심을, 비정규 측점은 `x(chainage)`를 넘긴다.
|
||||
*/
|
||||
function buildSelectedColumn(
|
||||
chainage: number,
|
||||
alignment: ProfileAlignment,
|
||||
centerX: number,
|
||||
cellWidth: number,
|
||||
interval: number,
|
||||
display: { station: number; cumulative: number },
|
||||
onAdjustStation?: (chainageM: number, deltaM: number) => void,
|
||||
isIrregular = false,
|
||||
mc = false,
|
||||
): HTMLElement {
|
||||
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
|
||||
const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m");
|
||||
const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null;
|
||||
const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null;
|
||||
|
||||
// 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고,
|
||||
// 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다.
|
||||
const inside = alignment.segments.find(
|
||||
(segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6,
|
||||
);
|
||||
const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01);
|
||||
const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01);
|
||||
const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => {
|
||||
if (inside) return { text: pick(inside).toFixed(digits) };
|
||||
const left = leftSeg ? pick(leftSeg).toFixed(digits) : "";
|
||||
const right = rightSeg ? pick(rightSeg).toFixed(digits) : "";
|
||||
return left && right ? { left, right } : { text: left || right || "" };
|
||||
};
|
||||
const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
|
||||
// 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다.
|
||||
const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined;
|
||||
// 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
|
||||
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변).
|
||||
// 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
|
||||
const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [
|
||||
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
|
||||
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
|
||||
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
|
||||
{
|
||||
value: {
|
||||
text: displayStationLabel(chainage, interval, display.station) + (mc ? " (MC)" : ""),
|
||||
},
|
||||
modifier: mc ? "mc" : "",
|
||||
}, // 측점
|
||||
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
|
||||
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
|
||||
];
|
||||
const column = element(
|
||||
"div",
|
||||
`b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`,
|
||||
);
|
||||
column.style.left = `${centerX}px`;
|
||||
column.style.width = `${cellWidth}px`;
|
||||
rows.forEach((row) => {
|
||||
const cell = element(
|
||||
"div",
|
||||
`b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`,
|
||||
);
|
||||
if (row.plan && onAdjustStation && plan !== null) {
|
||||
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 station_offset 반영(규칙·비정규 공용).
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = "any";
|
||||
input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin";
|
||||
input.value = plan.toFixed(2);
|
||||
input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`;
|
||||
input.addEventListener("change", () => {
|
||||
const target = Number.parseFloat(input.value);
|
||||
if (Number.isFinite(target)) onAdjustStation(chainage, target - plan);
|
||||
});
|
||||
cell.append(input);
|
||||
} else if ("left" in row.value) {
|
||||
// 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다).
|
||||
cell.classList.add("is-split");
|
||||
cell.append(
|
||||
element("span", "b05-profile-table__irregular-col-half", row.value.left),
|
||||
element("span", "b05-profile-table__irregular-col-half", row.value.right),
|
||||
);
|
||||
} else {
|
||||
cell.textContent = row.value.text;
|
||||
}
|
||||
column.append(cell);
|
||||
});
|
||||
// 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
|
||||
const cutFillText =
|
||||
cut !== null
|
||||
? ` · 절토 ${cut.toFixed(2)}m`
|
||||
: fill !== null
|
||||
? ` · 성토 ${fill.toFixed(2)}m`
|
||||
: "";
|
||||
column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${(
|
||||
chainage + display.cumulative
|
||||
).toFixed(2)}m${cutFillText}`;
|
||||
return column;
|
||||
}
|
||||
|
||||
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||||
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
|
||||
const display = options.stationDisplay ?? { station: 0, cumulative: 0 };
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Table_Selected.ts
|
||||
* 종단면도 하단 표 — 고른 측점의 **값 열 오버레이**(`buildSelectedColumn`)와 표 본체가 같이 쓰는 조각
|
||||
* (`element` · `displayStationLabel` · `isStraightCurve`). 표 본체는 `B05_Profile_UI_Profile_Table`.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentCurve,
|
||||
AlignmentSegment,
|
||||
ProfileAlignment,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
|
||||
/**
|
||||
* 대수차가 이 값보다 작으면 **직선**으로 본다(%).
|
||||
*
|
||||
* 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를
|
||||
* 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다
|
||||
* (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다.
|
||||
*/
|
||||
const STRAIGHT_DELTA_PCT = 1e-4;
|
||||
|
||||
/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */
|
||||
export function isStraightCurve(curve: AlignmentCurve): boolean {
|
||||
return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT;
|
||||
}
|
||||
|
||||
export function element(tag: string, className: string, text?: string): HTMLElement {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */
|
||||
export function displayStationLabel(
|
||||
chainageM: number,
|
||||
interval: number,
|
||||
stationOffset: number,
|
||||
): string {
|
||||
const base = stationLabel(chainageM, interval); // "X+YY.Y"
|
||||
if (!stationOffset) return base;
|
||||
const [number, remainder] = base.split("+");
|
||||
return `${Number(number) + stationOffset}+${remainder}`;
|
||||
}
|
||||
|
||||
/** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */
|
||||
function interpolateSample(
|
||||
samples: ProfileAlignment["samples"],
|
||||
chainageM: number,
|
||||
key: "elevation_m" | "ground_elevation_m",
|
||||
): number | null {
|
||||
if (!samples.length) return null;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0][key];
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last[key];
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const current = samples[index];
|
||||
if (current.chainage_m < chainageM) continue;
|
||||
const previous = samples[index - 1];
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 1e-9) return current[key];
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous[key] + (current[key] - previous[key]) * ratio;
|
||||
}
|
||||
return last[key];
|
||||
}
|
||||
|
||||
/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */
|
||||
type CellValue = { text: string } | { left: string; right: string };
|
||||
|
||||
/**
|
||||
* 선택된 측점(규칙·비정규 공용)의 **값 열 오버레이**. 12행 순서 그대로 값(계획고·지반고·누가거리·
|
||||
* 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로
|
||||
* 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된
|
||||
* 중심을, 비정규 측점은 `x(chainage)`를 넘긴다.
|
||||
*/
|
||||
export function buildSelectedColumn(
|
||||
chainage: number,
|
||||
alignment: ProfileAlignment,
|
||||
centerX: number,
|
||||
cellWidth: number,
|
||||
interval: number,
|
||||
display: { station: number; cumulative: number },
|
||||
onAdjustStation?: (chainageM: number, deltaM: number) => void,
|
||||
isIrregular = false,
|
||||
mc = false,
|
||||
): HTMLElement {
|
||||
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
|
||||
const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m");
|
||||
const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null;
|
||||
const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null;
|
||||
|
||||
// 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고,
|
||||
// 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다.
|
||||
const inside = alignment.segments.find(
|
||||
(segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6,
|
||||
);
|
||||
const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01);
|
||||
const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01);
|
||||
const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => {
|
||||
if (inside) return { text: pick(inside).toFixed(digits) };
|
||||
const left = leftSeg ? pick(leftSeg).toFixed(digits) : "";
|
||||
const right = rightSeg ? pick(rightSeg).toFixed(digits) : "";
|
||||
return left && right ? { left, right } : { text: left || right || "" };
|
||||
};
|
||||
const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
|
||||
// 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다.
|
||||
const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined;
|
||||
// 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
|
||||
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변).
|
||||
// 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
|
||||
const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [
|
||||
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
|
||||
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
|
||||
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
|
||||
{
|
||||
value: {
|
||||
text: displayStationLabel(chainage, interval, display.station) + (mc ? " (MC)" : ""),
|
||||
},
|
||||
modifier: mc ? "mc" : "",
|
||||
}, // 측점
|
||||
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
|
||||
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
|
||||
];
|
||||
const column = element(
|
||||
"div",
|
||||
`b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`,
|
||||
);
|
||||
column.style.left = `${centerX}px`;
|
||||
column.style.width = `${cellWidth}px`;
|
||||
rows.forEach((row) => {
|
||||
const cell = element(
|
||||
"div",
|
||||
`b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`,
|
||||
);
|
||||
if (row.plan && onAdjustStation && plan !== null) {
|
||||
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 station_offset 반영(규칙·비정규 공용).
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = "any";
|
||||
input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin";
|
||||
input.value = plan.toFixed(2);
|
||||
input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`;
|
||||
input.addEventListener("change", () => {
|
||||
const target = Number.parseFloat(input.value);
|
||||
if (Number.isFinite(target)) onAdjustStation(chainage, target - plan);
|
||||
});
|
||||
cell.append(input);
|
||||
} else if ("left" in row.value) {
|
||||
// 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다).
|
||||
cell.classList.add("is-split");
|
||||
cell.append(
|
||||
element("span", "b05-profile-table__irregular-col-half", row.value.left),
|
||||
element("span", "b05-profile-table__irregular-col-half", row.value.right),
|
||||
);
|
||||
} else {
|
||||
cell.textContent = row.value.text;
|
||||
}
|
||||
column.append(cell);
|
||||
});
|
||||
// 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
|
||||
const cutFillText =
|
||||
cut !== null
|
||||
? ` · 절토 ${cut.toFixed(2)}m`
|
||||
: fill !== null
|
||||
? ` · 성토 ${fill.toFixed(2)}m`
|
||||
: "";
|
||||
column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${(
|
||||
chainage + display.cumulative
|
||||
).toFixed(2)}m${cutFillText}`;
|
||||
return column;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createSidePanels, type SidePanels } from "@ui/ui_template_side_panels";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks";
|
||||
import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift";
|
||||
@@ -65,9 +65,6 @@ export interface RouteEditChrome {
|
||||
/** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */
|
||||
const SIDE_HANDLE_PX = 32;
|
||||
|
||||
/** 횡단 판 몸 → 패널 묶음 — 고른 측점 횡단을 띄울 때 접힌 판을 폄(382-2 · `_Cross`). */
|
||||
export const sidePanelsOf = new WeakMap<HTMLElement, SidePanels>();
|
||||
|
||||
// 글판 접기 기억(345-5 덧 3) — 페이지 동안만(모듈 값) · 다시 열어도 그대로.
|
||||
let hintOpen = false;
|
||||
let infoCollapsed = false;
|
||||
@@ -155,7 +152,7 @@ export function createRouteEditChrome(
|
||||
overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools);
|
||||
|
||||
// 횡단 미리보기 = 패널 양식(382-1 · 손잡이 · 머리 · 폭 끌기 · 열림 · 폭 기억) · 자리는 지도 위 덮개 그대로 ·
|
||||
// 기본 접힘(393-1 · 키 -v2 = 옛 열림 기억 한 번 버림) · 손잡이로 연 것만 기억 · 잰 값 창이 판을 비킴(--b05-side-open).
|
||||
// 기본 접힘(393-1 · 키 -v2 = 옛 열림 기억 한 번 버림) · 열림 · 닫힘은 손잡이만(513-2) · 잰 값 창이 판을 비킴(--b05-side-open).
|
||||
const side = document.createElement("div");
|
||||
side.className = "b05-routeedit__side";
|
||||
const setSideOpen = (): void =>
|
||||
@@ -172,7 +169,6 @@ export function createRouteEditChrome(
|
||||
onChange: setSideOpen,
|
||||
onResize: setSideOpen,
|
||||
});
|
||||
sidePanelsOf.set(side, panels);
|
||||
overlay.querySelector(".b05-routeedit__side-wrap")!.append(panels.root);
|
||||
setSideOpen();
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
import { readStateRaw } from "../A00_Common/b_page_state";
|
||||
import { pageRouteId } from "../A00_Common/design/design_page_route";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint";
|
||||
import {
|
||||
routeCrossCard,
|
||||
@@ -320,13 +319,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
stopPick();
|
||||
};
|
||||
|
||||
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄(`open`). */
|
||||
function showStation(
|
||||
key: string | null,
|
||||
chainageM: number,
|
||||
structure = false,
|
||||
open = true,
|
||||
): void {
|
||||
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 접힌 채 내용만(513-2). */
|
||||
function showStation(key: string | null, chainageM: number, structure = false): void {
|
||||
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
|
||||
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
|
||||
previous.show(null, interval());
|
||||
@@ -334,12 +328,10 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
drawnRoute = pipe?.input("route") ?? null;
|
||||
shown = null;
|
||||
redraw();
|
||||
const panels = sidePanelsOf.get(params.side);
|
||||
if (open && panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
|
||||
}
|
||||
|
||||
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드 · 평면 마커) 고름 → 그 측점 횡단(구조물 · 관도 · 411-A). */
|
||||
function follow(selection: DesignSelection, open = true): void {
|
||||
function follow(selection: DesignSelection): void {
|
||||
attach();
|
||||
const at = selection.chainageM;
|
||||
if (at === null) {
|
||||
@@ -352,16 +344,16 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
}
|
||||
const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM);
|
||||
const structure = !!selection.structureId || (!!station && !isGridStation(station));
|
||||
showStation(station?.key ?? null, station?.chainageM ?? at, structure, open);
|
||||
showStation(station?.key ?? null, station?.chainageM ?? at, structure);
|
||||
}
|
||||
const stopPick = subscribeSelection(params.projectId, (selection, from) => {
|
||||
// 평면 이름 = 눈금 누름(제가 이미 그림)만 거름 · 마커 고름 풂(값 없음)은 따라 비움(500-1)
|
||||
if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection);
|
||||
});
|
||||
attach();
|
||||
// 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄
|
||||
// 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림)
|
||||
const picked = readSelection(params.projectId);
|
||||
if (picked) follow(picked, false);
|
||||
if (picked) follow(picked);
|
||||
|
||||
/** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */
|
||||
const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => {
|
||||
|
||||
@@ -597,7 +597,6 @@ export function createStructuresSection(
|
||||
if (moved) callbacks.onPipeSelect(moved.chainage_m); // 고름(알약 · 측점선)도 따라감
|
||||
},
|
||||
selectPipeByChainage(chainageM) {
|
||||
if (chainageM !== null) callbacks.onReveal?.();
|
||||
if (chainageM === null) {
|
||||
// 외부 해제(빈 공간 클릭 등) — 임시 배치는 취소, 선택 중이던 시설도 구조물군·
|
||||
// 종류까지 완전 리셋(2026-08-18 사용자 지시). 아무것도 안 골랐으면 무시.
|
||||
@@ -621,7 +620,6 @@ export function createStructuresSection(
|
||||
return hit;
|
||||
},
|
||||
selectById(structureId) {
|
||||
if (structureId !== null) callbacks.onReveal?.();
|
||||
if (structureId === null) {
|
||||
// 그래프·3D 빈 공간 클릭 등 외부 해제 — 선택 중이었으면 구조물군·종류까지
|
||||
// 완전 리셋(2026-08-18 사용자 지시). 선택 없는 신규 작성 중에는 두지 않는다.
|
||||
@@ -637,11 +635,9 @@ export function createStructuresSection(
|
||||
const type = typeMap().get(typeId);
|
||||
if (!type) return;
|
||||
// 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서
|
||||
// 시작해 옛 고름 · 집계 항목 · 그룹 잇기를 비우고 「구조물 배치」를 폄(좌측 패널은 안 엶 · 499-1).
|
||||
// 시작해 옛 고름 · 집계 항목 · 그룹 잇기를 비우고 고름만 바꿈(좌측 패널 · 「구조물 배치」 카드 열림 · 닫힘은 그대로 · 513-2).
|
||||
// 등록부 기본값 뒤 필수 빈칸이 없으면 바로 추가 + 새 항목 고름 · 남으면 폼만(A군은 임시 배치까지).
|
||||
startNew(type.group, typeId);
|
||||
callbacks.onReveal?.();
|
||||
root.classList.remove("is-collapsed");
|
||||
positionConfirmed = true; // 우클릭은 측점이 확정돼 들어온다 — 옵션도 바로 연다.
|
||||
const placement = placementOf(typeId);
|
||||
const step = interval();
|
||||
|
||||
@@ -204,12 +204,6 @@ export interface StructuresCallbacks {
|
||||
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫).
|
||||
* null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */
|
||||
onPipeSelect: (chainageM: number | null) => void;
|
||||
/**
|
||||
* 바깥(3D·종단 그래프·배수유역도·횡단도)에서 고른 것이 폼에 실릴 때 — 좌측 패널이
|
||||
* 접혀 있으면 펼친다(2026-09-04 사용자: 선택하면 좌측 패널도 함께 활성화돼야 함).
|
||||
* 목록에서 직접 고른 경우엔 이미 펼쳐져 있어 아무 일도 안 한다.
|
||||
*/
|
||||
onReveal?: () => void;
|
||||
/** 목록에서 고른 줄을 다시 눌러 풂 — 다른 화면의 고름(횡단 카드)도 함께 끔(54-8). */
|
||||
onListRelease?: () => void;
|
||||
}
|
||||
|
||||
@@ -367,377 +367,3 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* SVG 차트 색상은 테마 토큰만 사용한다. */
|
||||
.b06-chart__bg {
|
||||
fill: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은
|
||||
그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */
|
||||
.b06-chart__ywindow[transform] * {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.b06-chart__grid {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-chart__axis {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-chart__tick,
|
||||
.b06-chart__station-label,
|
||||
.b06-chart__axis-label {
|
||||
fill: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.b06-chart__tick,
|
||||
.b06-chart__station-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* MC(곡선 가운데) 측점 = 사용자가 고칠 자리(507-3) — 한 단계 크게 · 굵게 · 경고색. 고름 빨강은 아래가 이김. */
|
||||
.b06-chart__station-label--mc {
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
fill: var(--color-warning);
|
||||
}
|
||||
|
||||
.b06-station--mc {
|
||||
font-size: 1.15em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 축선 앞에서 멈춤 ·
|
||||
바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) · 색 = 원지반선 · 설계선(379-3 · `--b06-tag` = _Cross_Areas.css) */
|
||||
.b06-chart__center-box {
|
||||
fill: color-mix(in srgb, var(--b06-tag) 14%, var(--color-surface-raised));
|
||||
stroke: var(--b06-tag);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-chart__center-text {
|
||||
fill: var(--b06-tag);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* 겹쳐 위 단으로 올린 측점 이름(407-4) — 선 · 지반선 위라 바탕색 테두리로 읽히게. */
|
||||
.b06-chart__station-label--lifted {
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-surface-raised);
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */
|
||||
.b06-chart__structure-label {
|
||||
font-size: 9px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
fill: rgb(180 83 9);
|
||||
}
|
||||
|
||||
.b06-chart__axis-label {
|
||||
fill: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b06-chart__profile {
|
||||
fill: none;
|
||||
stroke: var(--color-chart-0);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* 지반선 · 계획선은 누름을 통과 — 겹친 측점선 누름이 측점 고름 · 임시 횡단으로 감(505-3) */
|
||||
.b06-chart__profile,
|
||||
.b06-chart__design-profile {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b06-chart__cross-profile {
|
||||
fill: none;
|
||||
stroke: var(--color-chart-1);
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */
|
||||
.b06-chart__design-profile {
|
||||
fill: none;
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 2.2;
|
||||
stroke-dasharray: 8 4;
|
||||
}
|
||||
|
||||
/* 절토(계획고가 지반고보다 낮음) / 성토 구간 음영 */
|
||||
.b06-chart__band {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.b06-chart__band--cut {
|
||||
fill: rgb(220 38 38 / 14%);
|
||||
}
|
||||
|
||||
.b06-chart__band--fill {
|
||||
fill: rgb(37 99 235 / 14%);
|
||||
}
|
||||
|
||||
.b06-chart__balance-boundary {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 3 3;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b06-chart__station {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 구조물 측점선은 끌어 옮길 수 있다 — 커서로 그 사실을 알린다(2026-08-01 사용자 지시). */
|
||||
.b06-chart__station--draggable {
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b06-chart__station-line {
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b06-chart__station-hit {
|
||||
stroke: transparent;
|
||||
stroke-width: 14;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
|
||||
.b06-chart__station-line--bp,
|
||||
.b06-chart__station-line--regular,
|
||||
.b06-chart__station-line--curve {
|
||||
stroke: var(--color-warning);
|
||||
}
|
||||
|
||||
.b06-chart__station-line--ep {
|
||||
stroke: var(--color-accent);
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
/* 비정규 측점(구조물)은 종단면도에서도 자수정색 파선으로 구분한다(B05 종단 규칙과 정합).
|
||||
미선택 시 stroke 미정의로 세로선이 안 그려지던 문제 해소. */
|
||||
.b06-chart__station-line--irregular {
|
||||
stroke: var(--color-royal-amethyst, rgb(139 92 246));
|
||||
stroke-width: 1.4;
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
|
||||
/* 추가 측점(368-3) = 일반측점 색 점선 */
|
||||
.b06-chart__station-line--plus {
|
||||
stroke: var(--color-warning);
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
|
||||
.b06-chart__station-line--selected,
|
||||
.b06-chart__center-marker {
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* 십자선이 계획 노선(계획고) 위치를 가리킬 때: 종단 계획선과 동일 색으로 대응시킨다 */
|
||||
.b06-chart__center-marker--design {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.b06-chart__station--selected .b06-chart__station-label,
|
||||
.b06-chart__station-label--selected {
|
||||
fill: var(--color-danger);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번).
|
||||
한 행 고정(nowrap): 넘치는 컨트롤은 다음 행으로 밀지 않고 "⋯" 드롭다운으로 이동(N-2-2). */
|
||||
.b06-design {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
padding: var(--spacing-8) 0 var(--spacing-8) var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 오버플로 드롭다운(N-2-2): 넘친 컨트롤을 담는 우측 맞춤 "⋯" 메뉴. 세로 배치. */
|
||||
.b06-design__more {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.b06-design__more-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.b06-design__more-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-design__more[open] .b06-design__more-summary {
|
||||
color: var(--color-surface);
|
||||
background: var(--color-royal-amethyst);
|
||||
border-color: var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.b06-design__more-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.b06-design__seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-design__seg-legend {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 선행 조건 미충족 옵션 — 숨김(179-2 · 옛 E-6 「흐리게 늘 보임」 폐기 · 쪽은 단면유형 알약에). */
|
||||
.b06-design__seg--disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-design__seg-buttons {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b06-design__btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-design__btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.b06-design__btn--active {
|
||||
color: var(--color-surface);
|
||||
background: var(--color-royal-amethyst);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b06-design__areas {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 암 경계선 제어(E-7) — 하단 정보 행 **왼쪽 맞춤**(318-2 · 중심고는 그래프 Y축 라벨 상자로 옮김). */
|
||||
.b06-cross-card__rockb {
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--radius-inputs);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* 선 공용 칸(370-3) — 고른 선 제목 · 버튼 · 값 한 줄(넘치면 말줄임) · 빈 칸도 자리(띠 높이) 그대로. */
|
||||
.b06-cross-card__linecell {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell.is-empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__seg-buttons {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__rockb-readout {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__rockb-btn {
|
||||
flex: 0 0 auto;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell
|
||||
.b06-design__rockb-btn:not([hidden])
|
||||
~ .b06-design__rockb-btn:not([hidden]) {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */
|
||||
.b06-cross-card__cutslope {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--radius-inputs);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.b06-design__cutslope-input {
|
||||
width: 44px;
|
||||
padding: 0 2px;
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-mono);
|
||||
text-align: right;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
}
|
||||
|
||||
/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */
|
||||
.b06-design__cutslope-input::-webkit-outer-spin-button,
|
||||
.b06-design__cutslope-input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.b06-design__cutslope-unit {
|
||||
padding: 0 2px 0 1px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/* SVG 차트 색상은 테마 토큰만 사용한다. */
|
||||
.b06-chart__bg {
|
||||
fill: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은
|
||||
그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */
|
||||
.b06-chart__ywindow[transform] * {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.b06-chart__grid {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-chart__axis {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-chart__tick,
|
||||
.b06-chart__station-label,
|
||||
.b06-chart__axis-label {
|
||||
fill: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.b06-chart__tick,
|
||||
.b06-chart__station-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* MC(곡선 가운데) 측점 = 사용자가 고칠 자리(507-3) — 한 단계 크게 · 굵게 · 경고색. 고름 빨강은 아래가 이김. */
|
||||
.b06-chart__station-label--mc {
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
fill: var(--color-warning);
|
||||
}
|
||||
|
||||
.b06-station--mc {
|
||||
font-size: 1.15em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 축선 앞에서 멈춤 ·
|
||||
바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) · 색 = 원지반선 · 설계선(379-3 · `--b06-tag` = _Cross_Areas.css) */
|
||||
.b06-chart__center-box {
|
||||
fill: color-mix(in srgb, var(--b06-tag) 14%, var(--color-surface-raised));
|
||||
stroke: var(--b06-tag);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-chart__center-text {
|
||||
fill: var(--b06-tag);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* 겹쳐 위 단으로 올린 측점 이름(407-4) — 선 · 지반선 위라 바탕색 테두리로 읽히게. */
|
||||
.b06-chart__station-label--lifted {
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-surface-raised);
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */
|
||||
.b06-chart__structure-label {
|
||||
font-size: 9px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
fill: rgb(180 83 9);
|
||||
}
|
||||
|
||||
.b06-chart__axis-label {
|
||||
fill: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b06-chart__profile {
|
||||
fill: none;
|
||||
stroke: var(--color-chart-0);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* 지반선 · 계획선은 누름을 통과 — 겹친 측점선 누름이 측점 고름 · 임시 횡단으로 감(505-3) */
|
||||
.b06-chart__profile,
|
||||
.b06-chart__design-profile {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b06-chart__cross-profile {
|
||||
fill: none;
|
||||
stroke: var(--color-chart-1);
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */
|
||||
.b06-chart__design-profile {
|
||||
fill: none;
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 2.2;
|
||||
stroke-dasharray: 8 4;
|
||||
}
|
||||
|
||||
/* 절토(계획고가 지반고보다 낮음) / 성토 구간 음영 */
|
||||
.b06-chart__band {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.b06-chart__band--cut {
|
||||
fill: rgb(220 38 38 / 14%);
|
||||
}
|
||||
|
||||
.b06-chart__band--fill {
|
||||
fill: rgb(37 99 235 / 14%);
|
||||
}
|
||||
|
||||
.b06-chart__balance-boundary {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 3 3;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b06-chart__station {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 구조물 측점선은 끌어 옮길 수 있다 — 커서로 그 사실을 알린다(2026-08-01 사용자 지시). */
|
||||
.b06-chart__station--draggable {
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b06-chart__station-line {
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b06-chart__station-hit {
|
||||
stroke: transparent;
|
||||
stroke-width: 14;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
|
||||
.b06-chart__station-line--bp,
|
||||
.b06-chart__station-line--regular,
|
||||
.b06-chart__station-line--curve {
|
||||
stroke: var(--color-warning);
|
||||
}
|
||||
|
||||
.b06-chart__station-line--ep {
|
||||
stroke: var(--color-accent);
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
/* 비정규 측점(구조물)은 종단면도에서도 자수정색 파선으로 구분한다(B05 종단 규칙과 정합).
|
||||
미선택 시 stroke 미정의로 세로선이 안 그려지던 문제 해소. */
|
||||
.b06-chart__station-line--irregular {
|
||||
stroke: var(--color-royal-amethyst, rgb(139 92 246));
|
||||
stroke-width: 1.4;
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
|
||||
/* 추가 측점(368-3) = 일반측점 색 점선 */
|
||||
.b06-chart__station-line--plus {
|
||||
stroke: var(--color-warning);
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
|
||||
.b06-chart__station-line--selected,
|
||||
.b06-chart__center-marker {
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* 십자선이 계획 노선(계획고) 위치를 가리킬 때: 종단 계획선과 동일 색으로 대응시킨다 */
|
||||
.b06-chart__center-marker--design {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.b06-chart__station--selected .b06-chart__station-label,
|
||||
.b06-chart__station-label--selected {
|
||||
fill: var(--color-danger);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번).
|
||||
한 행 고정(nowrap): 넘치는 컨트롤은 다음 행으로 밀지 않고 "⋯" 드롭다운으로 이동(N-2-2). */
|
||||
.b06-design {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
padding: var(--spacing-8) 0 var(--spacing-8) var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 오버플로 드롭다운(N-2-2): 넘친 컨트롤을 담는 우측 맞춤 "⋯" 메뉴. 세로 배치. */
|
||||
.b06-design__more {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.b06-design__more-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.b06-design__more-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-design__more[open] .b06-design__more-summary {
|
||||
color: var(--color-surface);
|
||||
background: var(--color-royal-amethyst);
|
||||
border-color: var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.b06-design__more-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.b06-design__seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-design__seg-legend {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 선행 조건 미충족 옵션 — 숨김(179-2 · 옛 E-6 「흐리게 늘 보임」 폐기 · 쪽은 단면유형 알약에). */
|
||||
.b06-design__seg--disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-design__seg-buttons {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b06-design__btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-design__btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.b06-design__btn--active {
|
||||
color: var(--color-surface);
|
||||
background: var(--color-royal-amethyst);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b06-design__areas {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 암 경계선 제어(E-7) — 하단 정보 행 **왼쪽 맞춤**(318-2 · 중심고는 그래프 Y축 라벨 상자로 옮김). */
|
||||
.b06-cross-card__rockb {
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--radius-inputs);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* 선 공용 칸(370-3) — 고른 선 제목 · 버튼 · 값 한 줄(넘치면 말줄임) · 빈 칸도 자리(띠 높이) 그대로. */
|
||||
.b06-cross-card__linecell {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell.is-empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__seg-buttons {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__rockb-readout {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__rockb-btn {
|
||||
flex: 0 0 auto;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell
|
||||
.b06-design__rockb-btn:not([hidden])
|
||||
~ .b06-design__rockb-btn:not([hidden]) {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */
|
||||
.b06-cross-card__cutslope {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--radius-inputs);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.b06-design__cutslope-input {
|
||||
width: 44px;
|
||||
padding: 0 2px;
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-mono);
|
||||
text-align: right;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
}
|
||||
|
||||
/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */
|
||||
.b06-design__cutslope-input::-webkit-outer-spin-button,
|
||||
.b06-design__cutslope-input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.b06-design__cutslope-unit {
|
||||
padding: 0 2px 0 1px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
align-self: center;
|
||||
}
|
||||
@@ -431,480 +431,3 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
/* 도면층 행: 이름은 줄여 담고 아이콘은 줄이지 않는다. 이름이 안 줄면 오른쪽 아이콘이
|
||||
행 밖으로 밀려 잘린다 (2026-09-01 실측: 내용 237~286px / 보이는 폭 229px). */
|
||||
.cad-layer-manager .layer > button > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-layer-manager .layer > button > div {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 행 안의 아이콘 버튼은 여백을 줄여 이름 몫을 남긴다 — 아이콘 6개가 한 행을 다 먹었다. */
|
||||
.cad-layer-manager .layer > button [data-size="small"] {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */
|
||||
.cad-layer-manager button[data-type="regular"] {
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-layer-manager button[data-type="transparent"] {
|
||||
background: transparent;
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-layer-manager button:hover {
|
||||
background: var(--cad-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cad-layer-manager button[data-active="true"] {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.cad-view-controls {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 14px;
|
||||
bottom: calc(var(--cad-command-height) + var(--cad-status-height) + 14px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: var(--cad-chrome-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
/* 오토캐드 파일 끌어다 놓기 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */
|
||||
.cad-drop-hint {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 8px);
|
||||
right: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cad-drop-hint:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.cad-drop-hint__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
/* 파일을 끄는 동안 — 화면 가장자리 흐린 테두리(`dxf-clip-paste.ts`) */
|
||||
body.cad-file-dragging::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 6px;
|
||||
border: 2px dashed var(--cad-accent);
|
||||
border-radius: 6px;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cad-view-controls button {
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--cad-line);
|
||||
background: transparent;
|
||||
font-size: 17px;
|
||||
}
|
||||
.cad-view-controls button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-view-controls__fit {
|
||||
color: var(--cad-accent);
|
||||
font-size: 15px;
|
||||
}
|
||||
.cad-view-controls span {
|
||||
min-width: 48px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 하단 명령어 입력창은 숨김 처리 (마우스 커서 단축키 입력으로 대체) */
|
||||
.cad-command-area {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 0;
|
||||
bottom: var(--cad-status-height);
|
||||
left: var(--cad-panel-width);
|
||||
height: var(--cad-command-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--cad-chrome);
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-command-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text-muted);
|
||||
font: 11px Consolas, monospace;
|
||||
}
|
||||
.cad-command-suggestions {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
bottom: 100%;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
padding: 4px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-command-suggestions button {
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
font: 11px Consolas, monospace;
|
||||
text-align: left;
|
||||
}
|
||||
.cad-command-suggestions button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-prop--wide select {
|
||||
min-width: 132px;
|
||||
}
|
||||
.cad-command-prompt {
|
||||
max-width: 42%;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-command-area form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 28px;
|
||||
}
|
||||
.cad-command-area label {
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cad-command-area input {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-text);
|
||||
font: 11px Consolas, monospace;
|
||||
}
|
||||
.cad-command-area input:focus {
|
||||
border-color: var(--color-focus-ring);
|
||||
box-shadow: 0 0 0 1px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.cad-statusbar {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: var(--cad-status-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
background: var(--cad-chrome);
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-statusbar button {
|
||||
height: 21px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
.cad-statusbar button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-statusbar button[data-active="true"] {
|
||||
border-color: var(--cad-accent);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
.cad-statusbar__hint {
|
||||
margin-left: auto;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:root {
|
||||
--cad-panel-width: 200px;
|
||||
}
|
||||
.cad-statusbar__hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 특성 팔레트 — 값을 바로 고치는 입력들 */
|
||||
.cad-properties-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.cad-properties-editor__title {
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cad-properties-editor label {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--cad-text-dim);
|
||||
}
|
||||
.cad-properties-editor select,
|
||||
.cad-properties-editor input {
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 3px;
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-properties-editor__readout {
|
||||
margin: 6px 0 0;
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-properties-editor__readout div {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-properties-editor__readout dt {
|
||||
color: var(--cad-text-dim);
|
||||
}
|
||||
.cad-properties-editor__readout dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-properties__empty {
|
||||
margin: 0;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 빠른 특성 — 화면 위에 뜨는 작은 상자 */
|
||||
.cad-quick-properties {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
|
||||
right: 14px;
|
||||
width: 240px;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-quick-properties__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-quick-properties__header button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */
|
||||
.cad-block-library {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
|
||||
right: 14px;
|
||||
width: 240px;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-block-library__empty {
|
||||
margin: 0;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cad-block-library__list {
|
||||
max-height: 220px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
.cad-block-library__list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.cad-block-library__list li button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cad-block-library__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 3px 4px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cad-block-library__name:hover {
|
||||
background: var(--cad-chrome-sunken);
|
||||
}
|
||||
.cad-block-library__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.cad-block-library__actions button {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로
|
||||
부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */
|
||||
.cad-frame-tokens {
|
||||
position: fixed;
|
||||
/* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */
|
||||
right: 12px;
|
||||
bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px);
|
||||
z-index: 3;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 236px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 6px;
|
||||
background: var(--cad-chrome-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--cad-text);
|
||||
}
|
||||
|
||||
.cad-frame-tokens__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__fold {
|
||||
flex: none;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--cad-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__title {
|
||||
width: 100%;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__button {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: var(--cad-chrome);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__button:hover {
|
||||
background: var(--cad-accent-soft, var(--cad-chrome-raised));
|
||||
}
|
||||
|
||||
/* 자리표 칸 크기 입력 (2026-09-06) */
|
||||
.cad-frame-tokens__size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__size input {
|
||||
width: 64px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: var(--cad-chrome);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './App.css';
|
||||
import './App_Layers.css';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { FramePlaceholderPanel } from './components/FramePlaceholderPanel.tsx';
|
||||
import { Toolbar } from './components/Toolbar.tsx';
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/* 도면층 행: 이름은 줄여 담고 아이콘은 줄이지 않는다. 이름이 안 줄면 오른쪽 아이콘이
|
||||
행 밖으로 밀려 잘린다 (2026-09-01 실측: 내용 237~286px / 보이는 폭 229px). */
|
||||
.cad-layer-manager .layer > button > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-layer-manager .layer > button > div {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 행 안의 아이콘 버튼은 여백을 줄여 이름 몫을 남긴다 — 아이콘 6개가 한 행을 다 먹었다. */
|
||||
.cad-layer-manager .layer > button [data-size="small"] {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */
|
||||
.cad-layer-manager button[data-type="regular"] {
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-layer-manager button[data-type="transparent"] {
|
||||
background: transparent;
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-layer-manager button:hover {
|
||||
background: var(--cad-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cad-layer-manager button[data-active="true"] {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.cad-view-controls {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 14px;
|
||||
bottom: calc(var(--cad-command-height) + var(--cad-status-height) + 14px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: var(--cad-chrome-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
/* 오토캐드 파일 끌어다 놓기 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */
|
||||
.cad-drop-hint {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 8px);
|
||||
right: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cad-drop-hint:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.cad-drop-hint__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
/* 파일을 끄는 동안 — 화면 가장자리 흐린 테두리(`dxf-clip-paste.ts`) */
|
||||
body.cad-file-dragging::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 6px;
|
||||
border: 2px dashed var(--cad-accent);
|
||||
border-radius: 6px;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cad-view-controls button {
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--cad-line);
|
||||
background: transparent;
|
||||
font-size: 17px;
|
||||
}
|
||||
.cad-view-controls button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-view-controls__fit {
|
||||
color: var(--cad-accent);
|
||||
font-size: 15px;
|
||||
}
|
||||
.cad-view-controls span {
|
||||
min-width: 48px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 하단 명령어 입력창은 숨김 처리 (마우스 커서 단축키 입력으로 대체) */
|
||||
.cad-command-area {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 0;
|
||||
bottom: var(--cad-status-height);
|
||||
left: var(--cad-panel-width);
|
||||
height: var(--cad-command-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--cad-chrome);
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-command-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text-muted);
|
||||
font: 11px Consolas, monospace;
|
||||
}
|
||||
.cad-command-suggestions {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
bottom: 100%;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
padding: 4px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-command-suggestions button {
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
font: 11px Consolas, monospace;
|
||||
text-align: left;
|
||||
}
|
||||
.cad-command-suggestions button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-prop--wide select {
|
||||
min-width: 132px;
|
||||
}
|
||||
.cad-command-prompt {
|
||||
max-width: 42%;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-command-area form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 28px;
|
||||
}
|
||||
.cad-command-area label {
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cad-command-area input {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-text);
|
||||
font: 11px Consolas, monospace;
|
||||
}
|
||||
.cad-command-area input:focus {
|
||||
border-color: var(--color-focus-ring);
|
||||
box-shadow: 0 0 0 1px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.cad-statusbar {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: var(--cad-status-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
background: var(--cad-chrome);
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-statusbar button {
|
||||
height: 21px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
.cad-statusbar button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-statusbar button[data-active="true"] {
|
||||
border-color: var(--cad-accent);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
.cad-statusbar__hint {
|
||||
margin-left: auto;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:root {
|
||||
--cad-panel-width: 200px;
|
||||
}
|
||||
.cad-statusbar__hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 특성 팔레트 — 값을 바로 고치는 입력들 */
|
||||
.cad-properties-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.cad-properties-editor__title {
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cad-properties-editor label {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--cad-text-dim);
|
||||
}
|
||||
.cad-properties-editor select,
|
||||
.cad-properties-editor input {
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 3px;
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-properties-editor__readout {
|
||||
margin: 6px 0 0;
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-properties-editor__readout div {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-properties-editor__readout dt {
|
||||
color: var(--cad-text-dim);
|
||||
}
|
||||
.cad-properties-editor__readout dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-properties__empty {
|
||||
margin: 0;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 빠른 특성 — 화면 위에 뜨는 작은 상자 */
|
||||
.cad-quick-properties {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
|
||||
right: 14px;
|
||||
width: 240px;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-quick-properties__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-quick-properties__header button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */
|
||||
.cad-block-library {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
|
||||
right: 14px;
|
||||
width: 240px;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-block-library__empty {
|
||||
margin: 0;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cad-block-library__list {
|
||||
max-height: 220px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
.cad-block-library__list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.cad-block-library__list li button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cad-block-library__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 3px 4px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cad-block-library__name:hover {
|
||||
background: var(--cad-chrome-sunken);
|
||||
}
|
||||
.cad-block-library__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.cad-block-library__actions button {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로
|
||||
부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */
|
||||
.cad-frame-tokens {
|
||||
position: fixed;
|
||||
/* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */
|
||||
right: 12px;
|
||||
bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px);
|
||||
z-index: 3;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 236px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 6px;
|
||||
background: var(--cad-chrome-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--cad-text);
|
||||
}
|
||||
|
||||
.cad-frame-tokens__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__fold {
|
||||
flex: none;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--cad-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__title {
|
||||
width: 100%;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__button {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: var(--cad-chrome);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__button:hover {
|
||||
background: var(--cad-accent-soft, var(--cad-chrome-raised));
|
||||
}
|
||||
|
||||
/* 자리표 칸 크기 입력 (2026-09-06) */
|
||||
.cad-frame-tokens__size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__size input {
|
||||
width: 64px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: var(--cad-chrome);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
|
||||
import "../B01_Dashboard/B01_Dashboard_UI_Style_Company.css";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
|
||||
@@ -66,158 +66,32 @@ import {
|
||||
} from "./common_util_mass_haul_settle";
|
||||
|
||||
export { haulPlanPayload } from "./common_util_mass_haul_settle";
|
||||
import type {
|
||||
BalanceStep,
|
||||
CurvePoint,
|
||||
HaulBand,
|
||||
HaulBlock,
|
||||
HaulDirection,
|
||||
HaulEquipmentLimit,
|
||||
HaulPlan,
|
||||
HaulResidual,
|
||||
} from "./common_util_mass_haul_balance_types";
|
||||
import {
|
||||
applyCollectedStoneDeduction,
|
||||
applyStructureSpoil,
|
||||
} from "./common_util_mass_haul_balance_apply";
|
||||
|
||||
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
|
||||
export interface HaulEquipmentLimit {
|
||||
key: string;
|
||||
max_distance_m: number | null;
|
||||
}
|
||||
|
||||
/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */
|
||||
export type HaulDirection = "forward" | "backward";
|
||||
|
||||
/** 곡선 위 한 점(누가거리 m, 누가토량 ㎥). 렌더러가 그대로 좌표로 옮긴다. */
|
||||
export interface CurvePoint {
|
||||
m: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
/** 블록 하나를 장비 경계현으로 가른 띠. 띠 두께가 곧 그 장비의 운반토량이다. */
|
||||
export interface HaulBand {
|
||||
/** 전체 통번호(1부터). 도면의 ①②③에 해당한다. */
|
||||
index: number;
|
||||
/** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */
|
||||
equipment: string | null;
|
||||
/** 띠의 평형선 쪽 / 정점 쪽 경계 높이(누가토량 ㎥). */
|
||||
level_base_m3: number;
|
||||
level_apex_m3: number;
|
||||
/** 이 띠의 운반토량(㎥, 다짐상태) = 두 경계의 차. */
|
||||
volume_m3: number;
|
||||
/** 평형선 쪽 경계현(= 이 장비의 경계거리와 길이가 같은 수평선)의 양 끝. */
|
||||
boundary_from_m: number;
|
||||
boundary_to_m: number;
|
||||
/** 평균운반거리 `L`(m) = 띠 중간 높이 현의 길이. */
|
||||
haul_distance_m: number;
|
||||
haul_from_m: number;
|
||||
haul_to_m: number;
|
||||
/** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥). 합 = `volume_m3`. */
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
/** 띠를 감싸는 다각형(곡선 두 변 + 위아래 현). 클릭 대상이자 해칭 면이다. */
|
||||
outline: CurvePoint[];
|
||||
}
|
||||
|
||||
export interface HaulBlock {
|
||||
/** 1부터. */
|
||||
index: number;
|
||||
/** 이 블록의 평형선 높이(누가토량 축, ㎥). */
|
||||
base_m3: number;
|
||||
/** 평형선이 곡선을 자르는 두 점(누가거리 m). */
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
/** 정점(극대/극소) 위치와 값. */
|
||||
apex_m: number;
|
||||
apex_volume_m3: number;
|
||||
/** 블록 전체 운반토량 `Q`(㎥) = 종거. 띠 물량의 합과 같다. */
|
||||
volume_m3: number;
|
||||
direction: HaulDirection;
|
||||
/** 장비 경계현으로 가른 띠. 정점 쪽(가까운 장비)부터 평형선 쪽 순으로 담긴다. */
|
||||
bands: HaulBand[];
|
||||
}
|
||||
|
||||
/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */
|
||||
export interface HaulResidual {
|
||||
index: number;
|
||||
kind: "spoil" | "borrow";
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
volume_m3: number;
|
||||
/** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */
|
||||
level_from_m3: number;
|
||||
level_to_m3: number;
|
||||
/**
|
||||
* 사토의 지반유형 안분(㎥). 사토는 절토에서 남은 흙이라 구성비를 물을 수 있다.
|
||||
* **토취는 0이다** — 밖에서 사 오는 흙이라 이 노선의 지반유형이 없다.
|
||||
*/
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
/**
|
||||
* 사토 중 **자연방토**로 처리되는 몫(㎥) — 성토사면으로 흘려보내 운반비를 세지 않는다.
|
||||
* 나머지(`volume_m3 − natural_m3`)는 사토장으로 실어 내야 한다.
|
||||
*
|
||||
* 참고 도면은 사토 balloon에 `L=`(운반거리) 없이 `M.N=측점`만 적는다. 그 노선의 사토가
|
||||
* 이미 운반비 없이 처리됐다는 뜻이라, 프로그램도 **새 항목을 만들지 않고 사토 안에서**
|
||||
* 가른다(2026-08-02 사용자 확정). 토취는 밖에서 사 오는 흙이라 해당 없음(0).
|
||||
*/
|
||||
natural_m3: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 떨어진 잉여 구간 → 부족 구간 **장거리 운반**. 참고 도면의 덤프 운반 중 L이 900~1000m대인
|
||||
* 것들이 이것으로, 한 산의 현으로는 설명되지 않는다(노선 연장보다 긴 현은 없다).
|
||||
*
|
||||
* 배분 3원칙 ③ "토량을 모아 한 가지 방법으로 운반"이 바로 이 항목이다. 이것이 없으면
|
||||
* 절토가 우세한 노선인데도 국지 부족이 그대로 토취로 계상돼, 사지 않아도 될 흙을 사게 된다.
|
||||
*/
|
||||
export interface HaulTransfer {
|
||||
index: number;
|
||||
/** 퍼오는 자리 / 채우는 자리(누가거리 m, 각 잔량 구간의 중앙). */
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
volume_m3: number;
|
||||
/** 두 자리 사이 거리 = 이 운반의 `L`. */
|
||||
haul_distance_m: number;
|
||||
/** 연결 수평선을 그릴 높이(누가토량 ㎥). */
|
||||
level_m3: number;
|
||||
equipment: string | null;
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
}
|
||||
|
||||
/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */
|
||||
export interface BalanceStep {
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
level_m3: number;
|
||||
}
|
||||
|
||||
export interface HaulPlan {
|
||||
blocks: HaulBlock[];
|
||||
residuals: HaulResidual[];
|
||||
transfers: HaulTransfer[];
|
||||
steps: BalanceStep[];
|
||||
spoil_m3: number;
|
||||
borrow_m3: number;
|
||||
/** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */
|
||||
natural_spoil_m3: number;
|
||||
/**
|
||||
* 채집석 공제로 받은 값(㎥, 양수). **`null` 은 「아직 안 옴」**이고 `0` 은 「공제 없음」이다 —
|
||||
* 둘을 같게 보면 값이 안 온 것을 공제 0 으로 읽어 조용히 넘어간다(2026-09-09).
|
||||
*/
|
||||
collected_stone_deduction_m3: number | null;
|
||||
/** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */
|
||||
collected_stone_deducted_m3: number;
|
||||
/**
|
||||
* 구조물 터파기가 남긴 잔토(㎥, 양수) — **사토에 더한다**. `null` 은 「아직 안 옴」이고
|
||||
* `0` 은 「없음」이다(공제와 같은 태도).
|
||||
*/
|
||||
structure_spoil_m3: number | null;
|
||||
/** 실제로 사토에 더한 양(㎥). 받을 잔량이 없으면 받은 값보다 작을 수 있다. */
|
||||
structure_spoil_added_m3: number;
|
||||
/** 블록 안에서 옮기는 양(㎥). */
|
||||
hauled_m3: number;
|
||||
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
|
||||
transferred_m3: number;
|
||||
/**
|
||||
* 총 성토량(㎥) — **검산 기준값**. 성토는 전부 운반으로 채워지므로
|
||||
* `hauled_m3 + transferred_m3`가 이 값과 같아야 한다.
|
||||
* 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다.
|
||||
*/
|
||||
fill_total_m3: number;
|
||||
}
|
||||
export type {
|
||||
BalanceStep,
|
||||
CurvePoint,
|
||||
HaulBand,
|
||||
HaulBlock,
|
||||
HaulDirection,
|
||||
HaulEquipmentLimit,
|
||||
HaulPlan,
|
||||
HaulResidual,
|
||||
HaulTransfer,
|
||||
} from "./common_util_mass_haul_balance_types";
|
||||
|
||||
/**
|
||||
* **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면
|
||||
@@ -333,253 +207,6 @@ function bandOutline(
|
||||
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다.
|
||||
* 블록도 잔량도 안 나오면(평탄한 곡선) null.
|
||||
*/
|
||||
/**
|
||||
* 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구).
|
||||
*
|
||||
* 채집석 공제는 사토에서 한 번만 뺀다.
|
||||
* B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
|
||||
* 빼는 자리는 유토곡선의 사토뿐이다 —
|
||||
* 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
|
||||
*
|
||||
* ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫)
|
||||
* 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.**
|
||||
* ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다.
|
||||
* ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가
|
||||
* 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다.
|
||||
* ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다.
|
||||
*
|
||||
* 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다.
|
||||
*/
|
||||
function applyCollectedStoneDeduction(
|
||||
residuals: HaulResidual[],
|
||||
deduction: number | null,
|
||||
byGround?: Record<string, number> | null,
|
||||
groundUnknownM3?: number | null,
|
||||
conversion?: EarthworkConversion | null,
|
||||
): number {
|
||||
// ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라
|
||||
// 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다.
|
||||
// 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다).
|
||||
// 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다.
|
||||
const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion);
|
||||
const amountToTake = converted === null ? deduction : converted;
|
||||
if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) return 0;
|
||||
const spoils = residuals.filter((residual) => residual.kind === "spoil");
|
||||
let left = amountToTake;
|
||||
const take = (residual: HaulResidual, amount: number): void => {
|
||||
if (amount <= 0) return;
|
||||
const before = residual.volume_m3;
|
||||
const ratio = before > 0 ? (before - amount) / before : 0;
|
||||
residual.volume_m3 = before - amount;
|
||||
// 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
|
||||
residual.ea_m3 *= ratio;
|
||||
residual.rr_m3 *= ratio;
|
||||
residual.br_m3 *= ratio;
|
||||
left -= amount;
|
||||
};
|
||||
// ① 실어 내는 몫부터
|
||||
for (const residual of spoils) {
|
||||
if (left <= EPSILON) break;
|
||||
take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left));
|
||||
}
|
||||
// ② 모자라면 자연방토에서
|
||||
for (const residual of spoils) {
|
||||
if (left <= EPSILON) break;
|
||||
const amount = Math.min(residual.natural_m3, left);
|
||||
residual.natural_m3 -= amount;
|
||||
take(residual, amount);
|
||||
}
|
||||
return amountToTake - Math.max(left, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의).
|
||||
*
|
||||
* 구조물 잔토는 사토에 한 번만 더한다.
|
||||
* B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
|
||||
* 더하는 자리는 유토곡선의 사토뿐이다.
|
||||
*
|
||||
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리).
|
||||
* ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로
|
||||
* **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고,
|
||||
* 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다).
|
||||
* ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다.
|
||||
* ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다.
|
||||
* 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다.
|
||||
*
|
||||
* 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다.
|
||||
*/
|
||||
/**
|
||||
* 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다).
|
||||
* 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다.
|
||||
*/
|
||||
function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "br_m3" | null {
|
||||
const text = (ground ?? "").trim();
|
||||
if (!text) return null;
|
||||
if (text === "토사" || text === "soil" || text === "ea") return "ea_m3";
|
||||
if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") {
|
||||
return "rr_m3";
|
||||
}
|
||||
if (text === "발파암" || text === "blasting_rock" || text === "br") return "br_m3";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */
|
||||
const GROUND_OF_BUCKET = {
|
||||
ea_m3: "soil",
|
||||
rr_m3: "ripping_rock",
|
||||
br_m3: "blasting_rock",
|
||||
} as const;
|
||||
|
||||
/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */
|
||||
const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"] as const;
|
||||
|
||||
/**
|
||||
* 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로.
|
||||
* 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다.
|
||||
*/
|
||||
function compactedStoneDeduction(
|
||||
byGround: Record<string, number> | null | undefined,
|
||||
groundUnknownM3: number | null | undefined,
|
||||
conversion: EarthworkConversion | null | undefined,
|
||||
): number | null {
|
||||
if (!byGround) return null;
|
||||
const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)] as const).filter(
|
||||
([, value]) => Number.isFinite(value) && value > 0,
|
||||
);
|
||||
const unknown = Number(groundUnknownM3 ?? 0);
|
||||
const hasUnknown = Number.isFinite(unknown) && unknown > 0;
|
||||
if (!entries.length && !hasUnknown) return null;
|
||||
let total = hasUnknown ? unknown : 0;
|
||||
for (const [ground, value] of entries) {
|
||||
const factor = conversion?.[ground]?.compacted;
|
||||
total += typeof factor === "number" && factor > 0 ? value * factor : value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다.
|
||||
*
|
||||
* ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에
|
||||
* ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라
|
||||
* 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다.
|
||||
* ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C).
|
||||
* 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.**
|
||||
* ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로
|
||||
* 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다.
|
||||
*/
|
||||
function toCompacted(
|
||||
naturalM3: number,
|
||||
bucket: keyof typeof GROUND_OF_BUCKET | null,
|
||||
conversion: EarthworkConversion | null | undefined,
|
||||
): number {
|
||||
if (!bucket || !conversion) return naturalM3;
|
||||
const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted;
|
||||
return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3;
|
||||
}
|
||||
|
||||
function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual {
|
||||
// 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰).
|
||||
// ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다.
|
||||
return {
|
||||
index: 0,
|
||||
kind: "spoil",
|
||||
from_m: fromM,
|
||||
to_m: toM,
|
||||
volume_m3: volumeM3,
|
||||
level_from_m3: 0,
|
||||
level_to_m3: 0,
|
||||
ea_m3: 0,
|
||||
rr_m3: 0,
|
||||
br_m3: 0,
|
||||
natural_m3: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의).
|
||||
*
|
||||
* 구조물 잔토는 사토에 한 번만 더한다.
|
||||
* B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
|
||||
* 더하는 자리는 유토곡선의 사토뿐이다.
|
||||
*
|
||||
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**.
|
||||
* ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로
|
||||
* **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는
|
||||
* 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 —
|
||||
* 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다.
|
||||
* ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다.
|
||||
* ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다.
|
||||
* ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`,
|
||||
* 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted`
|
||||
* 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다.
|
||||
* 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다).
|
||||
*/
|
||||
function applyStructureSpoil(
|
||||
residuals: HaulResidual[],
|
||||
amount: number | null,
|
||||
points: Array<{
|
||||
chainage_m: number;
|
||||
spoil_m3: number;
|
||||
/** B08 이 보내는 이름은 `ground_type`(soil·ripping_rock·blasting_rock). 옛 이름·라벨도 받는다. */
|
||||
ground_type?: string | null;
|
||||
ground_label?: string | null;
|
||||
ground?: string | null;
|
||||
}> | null,
|
||||
conversion?: EarthworkConversion | null,
|
||||
): number {
|
||||
const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil");
|
||||
|
||||
// ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다.
|
||||
if (points && points.length) {
|
||||
let added = 0;
|
||||
for (const point of points) {
|
||||
const value = Number(point?.spoil_m3);
|
||||
const chainage = Number(point?.chainage_m);
|
||||
if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) continue;
|
||||
const spoils = spoilsOf();
|
||||
const covering = spoils.find(
|
||||
(residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9,
|
||||
);
|
||||
// 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라
|
||||
// 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다.
|
||||
const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label);
|
||||
const target = covering ?? newSpoilResidual(chainage, chainage, 0);
|
||||
if (!covering) residuals.push(target);
|
||||
// ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09).
|
||||
// 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다.
|
||||
const placed = toCompacted(value, bucket, conversion);
|
||||
target.volume_m3 += placed;
|
||||
if (bucket) target[bucket] += placed;
|
||||
added += placed;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
// ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선).
|
||||
if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0;
|
||||
const spoils = spoilsOf();
|
||||
const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0);
|
||||
if (!spoils.length || total <= EPSILON) {
|
||||
// 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로
|
||||
// 구간을 넓게 잡고, **물량은 보존**한다.
|
||||
const froms = residuals.map((residual) => residual.from_m);
|
||||
const tos = residuals.map((residual) => residual.to_m);
|
||||
const fromM = froms.length ? Math.min(...froms) : 0;
|
||||
const toM = tos.length ? Math.max(...tos) : 0;
|
||||
residuals.push(newSpoilResidual(fromM, toM, amount));
|
||||
return amount;
|
||||
}
|
||||
let added = 0;
|
||||
spoils.forEach((residual, index) => {
|
||||
const share =
|
||||
index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total);
|
||||
residual.volume_m3 += share;
|
||||
added += share;
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
export function computeHaulPlan(
|
||||
result: MassHaulResult,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/* =============================================================================
|
||||
* common_util_mass_haul_balance_apply.ts
|
||||
* 토량 분배 뒤에 사토에 얹는 몫 — 구조물 잔토(더함) · 채집석 공제(뺌).
|
||||
* 순서 = 잔토 먼저 · 공제 나중(`computeHaulPlan` 이 부른다).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EarthworkConversion } from "./common_util_mass_haul_types";
|
||||
import type { HaulResidual } from "./common_util_mass_haul_balance_types";
|
||||
import { EPSILON } from "./common_util_mass_haul_curve";
|
||||
|
||||
/**
|
||||
* 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구).
|
||||
*
|
||||
* 채집석 공제는 사토에서 한 번만 뺀다.
|
||||
* B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
|
||||
* 빼는 자리는 유토곡선의 사토뿐이다 —
|
||||
* 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
|
||||
*
|
||||
* ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫)
|
||||
* 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.**
|
||||
* ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다.
|
||||
* ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가
|
||||
* 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다.
|
||||
* ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다.
|
||||
*
|
||||
* 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다.
|
||||
*/
|
||||
export function applyCollectedStoneDeduction(
|
||||
residuals: HaulResidual[],
|
||||
deduction: number | null,
|
||||
byGround?: Record<string, number> | null,
|
||||
groundUnknownM3?: number | null,
|
||||
conversion?: EarthworkConversion | null,
|
||||
): number {
|
||||
// ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라
|
||||
// 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다.
|
||||
// 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다).
|
||||
// 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다.
|
||||
const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion);
|
||||
const amountToTake = converted === null ? deduction : converted;
|
||||
if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) return 0;
|
||||
const spoils = residuals.filter((residual) => residual.kind === "spoil");
|
||||
let left = amountToTake;
|
||||
const take = (residual: HaulResidual, amount: number): void => {
|
||||
if (amount <= 0) return;
|
||||
const before = residual.volume_m3;
|
||||
const ratio = before > 0 ? (before - amount) / before : 0;
|
||||
residual.volume_m3 = before - amount;
|
||||
// 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
|
||||
residual.ea_m3 *= ratio;
|
||||
residual.rr_m3 *= ratio;
|
||||
residual.br_m3 *= ratio;
|
||||
left -= amount;
|
||||
};
|
||||
// ① 실어 내는 몫부터
|
||||
for (const residual of spoils) {
|
||||
if (left <= EPSILON) break;
|
||||
take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left));
|
||||
}
|
||||
// ② 모자라면 자연방토에서
|
||||
for (const residual of spoils) {
|
||||
if (left <= EPSILON) break;
|
||||
const amount = Math.min(residual.natural_m3, left);
|
||||
residual.natural_m3 -= amount;
|
||||
take(residual, amount);
|
||||
}
|
||||
return amountToTake - Math.max(left, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의).
|
||||
*
|
||||
* 구조물 잔토는 사토에 한 번만 더한다.
|
||||
* B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
|
||||
* 더하는 자리는 유토곡선의 사토뿐이다.
|
||||
*
|
||||
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리).
|
||||
* ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로
|
||||
* **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고,
|
||||
* 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다).
|
||||
* ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다.
|
||||
* ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다.
|
||||
* 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다.
|
||||
*
|
||||
* 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다.
|
||||
*/
|
||||
/**
|
||||
* 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다).
|
||||
* 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다.
|
||||
*/
|
||||
function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "br_m3" | null {
|
||||
const text = (ground ?? "").trim();
|
||||
if (!text) return null;
|
||||
if (text === "토사" || text === "soil" || text === "ea") return "ea_m3";
|
||||
if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") {
|
||||
return "rr_m3";
|
||||
}
|
||||
if (text === "발파암" || text === "blasting_rock" || text === "br") return "br_m3";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */
|
||||
const GROUND_OF_BUCKET = {
|
||||
ea_m3: "soil",
|
||||
rr_m3: "ripping_rock",
|
||||
br_m3: "blasting_rock",
|
||||
} as const;
|
||||
|
||||
/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */
|
||||
const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"] as const;
|
||||
|
||||
/**
|
||||
* 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로.
|
||||
* 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다.
|
||||
*/
|
||||
function compactedStoneDeduction(
|
||||
byGround: Record<string, number> | null | undefined,
|
||||
groundUnknownM3: number | null | undefined,
|
||||
conversion: EarthworkConversion | null | undefined,
|
||||
): number | null {
|
||||
if (!byGround) return null;
|
||||
const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)] as const).filter(
|
||||
([, value]) => Number.isFinite(value) && value > 0,
|
||||
);
|
||||
const unknown = Number(groundUnknownM3 ?? 0);
|
||||
const hasUnknown = Number.isFinite(unknown) && unknown > 0;
|
||||
if (!entries.length && !hasUnknown) return null;
|
||||
let total = hasUnknown ? unknown : 0;
|
||||
for (const [ground, value] of entries) {
|
||||
const factor = conversion?.[ground]?.compacted;
|
||||
total += typeof factor === "number" && factor > 0 ? value * factor : value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다.
|
||||
*
|
||||
* ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에
|
||||
* ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라
|
||||
* 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다.
|
||||
* ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C).
|
||||
* 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.**
|
||||
* ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로
|
||||
* 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다.
|
||||
*/
|
||||
function toCompacted(
|
||||
naturalM3: number,
|
||||
bucket: keyof typeof GROUND_OF_BUCKET | null,
|
||||
conversion: EarthworkConversion | null | undefined,
|
||||
): number {
|
||||
if (!bucket || !conversion) return naturalM3;
|
||||
const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted;
|
||||
return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3;
|
||||
}
|
||||
|
||||
function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual {
|
||||
// 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰).
|
||||
// ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다.
|
||||
return {
|
||||
index: 0,
|
||||
kind: "spoil",
|
||||
from_m: fromM,
|
||||
to_m: toM,
|
||||
volume_m3: volumeM3,
|
||||
level_from_m3: 0,
|
||||
level_to_m3: 0,
|
||||
ea_m3: 0,
|
||||
rr_m3: 0,
|
||||
br_m3: 0,
|
||||
natural_m3: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의).
|
||||
*
|
||||
* 구조물 잔토는 사토에 한 번만 더한다.
|
||||
* B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
|
||||
* 더하는 자리는 유토곡선의 사토뿐이다.
|
||||
*
|
||||
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**.
|
||||
* ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로
|
||||
* **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는
|
||||
* 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 —
|
||||
* 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다.
|
||||
* ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다.
|
||||
* ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다.
|
||||
* ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`,
|
||||
* 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted`
|
||||
* 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다.
|
||||
* 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다).
|
||||
*/
|
||||
export function applyStructureSpoil(
|
||||
residuals: HaulResidual[],
|
||||
amount: number | null,
|
||||
points: Array<{
|
||||
chainage_m: number;
|
||||
spoil_m3: number;
|
||||
/** B08 이 보내는 이름은 `ground_type`(soil·ripping_rock·blasting_rock). 옛 이름·라벨도 받는다. */
|
||||
ground_type?: string | null;
|
||||
ground_label?: string | null;
|
||||
ground?: string | null;
|
||||
}> | null,
|
||||
conversion?: EarthworkConversion | null,
|
||||
): number {
|
||||
const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil");
|
||||
|
||||
// ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다.
|
||||
if (points && points.length) {
|
||||
let added = 0;
|
||||
for (const point of points) {
|
||||
const value = Number(point?.spoil_m3);
|
||||
const chainage = Number(point?.chainage_m);
|
||||
if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) continue;
|
||||
const spoils = spoilsOf();
|
||||
const covering = spoils.find(
|
||||
(residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9,
|
||||
);
|
||||
// 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라
|
||||
// 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다.
|
||||
const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label);
|
||||
const target = covering ?? newSpoilResidual(chainage, chainage, 0);
|
||||
if (!covering) residuals.push(target);
|
||||
// ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09).
|
||||
// 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다.
|
||||
const placed = toCompacted(value, bucket, conversion);
|
||||
target.volume_m3 += placed;
|
||||
if (bucket) target[bucket] += placed;
|
||||
added += placed;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
// ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선).
|
||||
if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0;
|
||||
const spoils = spoilsOf();
|
||||
const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0);
|
||||
if (!spoils.length || total <= EPSILON) {
|
||||
// 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로
|
||||
// 구간을 넓게 잡고, **물량은 보존**한다.
|
||||
const froms = residuals.map((residual) => residual.from_m);
|
||||
const tos = residuals.map((residual) => residual.to_m);
|
||||
const fromM = froms.length ? Math.min(...froms) : 0;
|
||||
const toM = tos.length ? Math.max(...tos) : 0;
|
||||
residuals.push(newSpoilResidual(fromM, toM, amount));
|
||||
return amount;
|
||||
}
|
||||
let added = 0;
|
||||
spoils.forEach((residual, index) => {
|
||||
const share =
|
||||
index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total);
|
||||
residual.volume_m3 += share;
|
||||
added += share;
|
||||
});
|
||||
return added;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* =============================================================================
|
||||
* common_util_mass_haul_balance_types.ts
|
||||
* 토량 분배(평형선 · 운반 블록 · 장비 띠) 결과 타입 — 계산은 `common_util_mass_haul_balance`.
|
||||
* 밖에서는 계속 `common_util_mass_haul_balance` 에서 가져온다(거기서 다시 내보낸다).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
|
||||
export interface HaulEquipmentLimit {
|
||||
key: string;
|
||||
max_distance_m: number | null;
|
||||
}
|
||||
|
||||
/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */
|
||||
export type HaulDirection = "forward" | "backward";
|
||||
|
||||
/** 곡선 위 한 점(누가거리 m, 누가토량 ㎥). 렌더러가 그대로 좌표로 옮긴다. */
|
||||
export interface CurvePoint {
|
||||
m: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
/** 블록 하나를 장비 경계현으로 가른 띠. 띠 두께가 곧 그 장비의 운반토량이다. */
|
||||
export interface HaulBand {
|
||||
/** 전체 통번호(1부터). 도면의 ①②③에 해당한다. */
|
||||
index: number;
|
||||
/** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */
|
||||
equipment: string | null;
|
||||
/** 띠의 평형선 쪽 / 정점 쪽 경계 높이(누가토량 ㎥). */
|
||||
level_base_m3: number;
|
||||
level_apex_m3: number;
|
||||
/** 이 띠의 운반토량(㎥, 다짐상태) = 두 경계의 차. */
|
||||
volume_m3: number;
|
||||
/** 평형선 쪽 경계현(= 이 장비의 경계거리와 길이가 같은 수평선)의 양 끝. */
|
||||
boundary_from_m: number;
|
||||
boundary_to_m: number;
|
||||
/** 평균운반거리 `L`(m) = 띠 중간 높이 현의 길이. */
|
||||
haul_distance_m: number;
|
||||
haul_from_m: number;
|
||||
haul_to_m: number;
|
||||
/** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥). 합 = `volume_m3`. */
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
/** 띠를 감싸는 다각형(곡선 두 변 + 위아래 현). 클릭 대상이자 해칭 면이다. */
|
||||
outline: CurvePoint[];
|
||||
}
|
||||
|
||||
export interface HaulBlock {
|
||||
/** 1부터. */
|
||||
index: number;
|
||||
/** 이 블록의 평형선 높이(누가토량 축, ㎥). */
|
||||
base_m3: number;
|
||||
/** 평형선이 곡선을 자르는 두 점(누가거리 m). */
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
/** 정점(극대/극소) 위치와 값. */
|
||||
apex_m: number;
|
||||
apex_volume_m3: number;
|
||||
/** 블록 전체 운반토량 `Q`(㎥) = 종거. 띠 물량의 합과 같다. */
|
||||
volume_m3: number;
|
||||
direction: HaulDirection;
|
||||
/** 장비 경계현으로 가른 띠. 정점 쪽(가까운 장비)부터 평형선 쪽 순으로 담긴다. */
|
||||
bands: HaulBand[];
|
||||
}
|
||||
|
||||
/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */
|
||||
export interface HaulResidual {
|
||||
index: number;
|
||||
kind: "spoil" | "borrow";
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
volume_m3: number;
|
||||
/** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */
|
||||
level_from_m3: number;
|
||||
level_to_m3: number;
|
||||
/**
|
||||
* 사토의 지반유형 안분(㎥). 사토는 절토에서 남은 흙이라 구성비를 물을 수 있다.
|
||||
* **토취는 0이다** — 밖에서 사 오는 흙이라 이 노선의 지반유형이 없다.
|
||||
*/
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
/**
|
||||
* 사토 중 **자연방토**로 처리되는 몫(㎥) — 성토사면으로 흘려보내 운반비를 세지 않는다.
|
||||
* 나머지(`volume_m3 − natural_m3`)는 사토장으로 실어 내야 한다.
|
||||
*
|
||||
* 참고 도면은 사토 balloon에 `L=`(운반거리) 없이 `M.N=측점`만 적는다. 그 노선의 사토가
|
||||
* 이미 운반비 없이 처리됐다는 뜻이라, 프로그램도 **새 항목을 만들지 않고 사토 안에서**
|
||||
* 가른다(2026-08-02 사용자 확정). 토취는 밖에서 사 오는 흙이라 해당 없음(0).
|
||||
*/
|
||||
natural_m3: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 떨어진 잉여 구간 → 부족 구간 **장거리 운반**. 참고 도면의 덤프 운반 중 L이 900~1000m대인
|
||||
* 것들이 이것으로, 한 산의 현으로는 설명되지 않는다(노선 연장보다 긴 현은 없다).
|
||||
*
|
||||
* 배분 3원칙 ③ "토량을 모아 한 가지 방법으로 운반"이 바로 이 항목이다. 이것이 없으면
|
||||
* 절토가 우세한 노선인데도 국지 부족이 그대로 토취로 계상돼, 사지 않아도 될 흙을 사게 된다.
|
||||
*/
|
||||
export interface HaulTransfer {
|
||||
index: number;
|
||||
/** 퍼오는 자리 / 채우는 자리(누가거리 m, 각 잔량 구간의 중앙). */
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
volume_m3: number;
|
||||
/** 두 자리 사이 거리 = 이 운반의 `L`. */
|
||||
haul_distance_m: number;
|
||||
/** 연결 수평선을 그릴 높이(누가토량 ㎥). */
|
||||
level_m3: number;
|
||||
equipment: string | null;
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
}
|
||||
|
||||
/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */
|
||||
export interface BalanceStep {
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
level_m3: number;
|
||||
}
|
||||
|
||||
export interface HaulPlan {
|
||||
blocks: HaulBlock[];
|
||||
residuals: HaulResidual[];
|
||||
transfers: HaulTransfer[];
|
||||
steps: BalanceStep[];
|
||||
spoil_m3: number;
|
||||
borrow_m3: number;
|
||||
/** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */
|
||||
natural_spoil_m3: number;
|
||||
/**
|
||||
* 채집석 공제로 받은 값(㎥, 양수). **`null` 은 「아직 안 옴」**이고 `0` 은 「공제 없음」이다 —
|
||||
* 둘을 같게 보면 값이 안 온 것을 공제 0 으로 읽어 조용히 넘어간다(2026-09-09).
|
||||
*/
|
||||
collected_stone_deduction_m3: number | null;
|
||||
/** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */
|
||||
collected_stone_deducted_m3: number;
|
||||
/**
|
||||
* 구조물 터파기가 남긴 잔토(㎥, 양수) — **사토에 더한다**. `null` 은 「아직 안 옴」이고
|
||||
* `0` 은 「없음」이다(공제와 같은 태도).
|
||||
*/
|
||||
structure_spoil_m3: number | null;
|
||||
/** 실제로 사토에 더한 양(㎥). 받을 잔량이 없으면 받은 값보다 작을 수 있다. */
|
||||
structure_spoil_added_m3: number;
|
||||
/** 블록 안에서 옮기는 양(㎥). */
|
||||
hauled_m3: number;
|
||||
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
|
||||
transferred_m3: number;
|
||||
/**
|
||||
* 총 성토량(㎥) — **검산 기준값**. 성토는 전부 운반으로 채워지므로
|
||||
* `hauled_m3 + transferred_m3`가 이 값과 같아야 한다.
|
||||
* 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다.
|
||||
*/
|
||||
fill_total_m3: number;
|
||||
}
|
||||
@@ -67,8 +67,7 @@ let stations = [
|
||||
];
|
||||
let footprint = null;
|
||||
let route = { v: 1 };
|
||||
// 접힌 판 펴기 — 몸(side)에 묶인 패널 묶음 흉내
|
||||
// 393-1 — 측점 눈금이 연 판은 기억 안 함(remember 거짓)
|
||||
// 패널 묶음 흉내 — 513-2 고름은 판을 안 엶(now 그대로 null)
|
||||
const panels = {
|
||||
now: null,
|
||||
current: () => panels.now,
|
||||
@@ -186,7 +185,7 @@ async function run() {
|
||||
out.afterEdit = cross.watched();
|
||||
out.cardsAfterEdit = cards.slice();
|
||||
|
||||
// 평면 눈금 클릭 = 공용 고름에 적음 · 판이 접혀 있으면 폄
|
||||
// 평면 눈금 클릭 = 공용 고름에 적음 · 접힌 판은 그대로(513-2)
|
||||
const heard = [];
|
||||
pick.subscribeSelection("p-1", (selection, from) =>
|
||||
heard.push({ at: selection.chainageM, from }),
|
||||
@@ -201,7 +200,7 @@ async function run() {
|
||||
watched: cross.watched(),
|
||||
};
|
||||
|
||||
// 다른 보기 고름 — 일반측점 = 그 측점(판 폄) · 관 측점 · 구조물 id = 마름모 · 풂 = 없음
|
||||
// 다른 보기 고름 — 일반측점 = 그 측점(판 그대로) · 관 측점 · 구조물 id = 마름모 · 풂 = 없음
|
||||
panels.now = null;
|
||||
pick.selectDesign("p-1", { chainageM: 60, structureId: null }, { from: "b06-card" });
|
||||
out.otherGrid = { watched: cross.watched(), panel: panels.now };
|
||||
|
||||
@@ -81,12 +81,11 @@ def _add_at_body() -> str:
|
||||
return src[start : src.index(" moveById(", start)]
|
||||
|
||||
|
||||
def test_addAt_은_새로_놓기_패널_폄_후_판정():
|
||||
def test_addAt_은_새로_놓기_후_판정_카드_열림_그대로():
|
||||
body = _add_at_body()
|
||||
assert "is-collapsed" not in body # 「구조물 배치」 카드 열림 · 닫힘 안 건드림(513-2)
|
||||
order = [
|
||||
body.index("startNew(type.group, typeId)"),
|
||||
body.index("callbacks.onReveal?.()"),
|
||||
body.index('root.classList.remove("is-collapsed")'),
|
||||
body.index("addsAtOnce(optionInputs)"),
|
||||
body.index("finishAdd("),
|
||||
body.index("commitTempPipe(true)"), # 폼만 갈래 — A군 임시 배치 그대로(우클릭만 · 291-1)
|
||||
|
||||
@@ -12,7 +12,7 @@ def _src(rel: str) -> str:
|
||||
|
||||
|
||||
def test_막힌_묶음은_숨김():
|
||||
css = _src("B06_Section/B06_Section_UI_Style_Cross.css")
|
||||
css = _src("B06_Section/B06_Section_UI_Style_Cross.css") + _src("B06_Section/B06_Section_UI_Style_Cross_Chart.css")
|
||||
body = re.search(r"\.b06-design__seg--disabled\s*\{([^}]*)\}", css).group(1)
|
||||
assert "display: none;" in body
|
||||
assert "opacity" not in body
|
||||
|
||||
@@ -182,7 +182,9 @@ def _rule(css: str, selector: str) -> str:
|
||||
|
||||
|
||||
def test_css_layout() -> None:
|
||||
css = _src("B06_Section", "B06_Section_UI_Style_Cross.css")
|
||||
css = _src("B06_Section", "B06_Section_UI_Style_Cross.css") + _src(
|
||||
"B06_Section", "B06_Section_UI_Style_Cross_Chart.css"
|
||||
)
|
||||
assert "align-items: start" in _rule(css, ".b06-section__grid") # 카드 = 내용 높이
|
||||
rock = _rule(css, ".b06-cross-card__rockb")
|
||||
assert "position: absolute" not in rock and "translateX" not in rock # 왼쪽 맞춤
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_추가_측점_선_점선_끌기_없음() -> None:
|
||||
graph = _read("B06_Section/B06_Section_UI_Longitudinal.ts")
|
||||
assert 'station.kind === "irregular" && !station.plus' in graph
|
||||
assert 'station.plus ? "plus" : station.kind' in graph
|
||||
css = _read("B06_Section/B06_Section_UI_Style_Cross.css")
|
||||
css = _read("B06_Section/B06_Section_UI_Style_Cross.css") + _read("B06_Section/B06_Section_UI_Style_Cross_Chart.css")
|
||||
rule = css[css.index(".b06-chart__station-line--plus") :].split("}")[0]
|
||||
assert "var(--color-warning)" in rule and "stroke-dasharray: 5 3" in rule
|
||||
assert "PLUS_OWNER_PREFIX" in _read("B05_Profile/B05_Profile_UI_Profile_Layout.ts")
|
||||
|
||||
@@ -91,5 +91,7 @@ def test_배선_떠_있는_선_조정창_걷음() -> None:
|
||||
assert "buildRockBoundaryControl" not in chrome # 479 — 그림 없는 암 카드도 빈 칸
|
||||
detail = (B06 / "B06_Section_UI_Cross_Detail_Pick.ts").read_text(encoding="utf-8")
|
||||
assert ".b06-chart__design-hit:not(.b06-chart__rock-hit)" in detail # 상세 표 강조에 안 섞임
|
||||
css = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8")
|
||||
css = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8") + (
|
||||
B06 / "B06_Section_UI_Style_Cross_Chart.css"
|
||||
).read_text(encoding="utf-8")
|
||||
assert ".b06-cross-card__linecell.is-empty {" in css and "text-overflow: ellipsis;" in css
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
"""PLAN 398-1 sub7 — 교점 옮기면 좌측 패널 열림(요구 248).
|
||||
"""PLAN 398-1 sub7 — 교점 옮기면 좌측 패널 열림(요구 248) · 513-2 로 뒤집음.
|
||||
|
||||
못박는 것
|
||||
- 좌측 패널 저절로 펴기는 사용자 고름만 — 진짜 누름(click · pointerup · contextmenu) 안 = 1.
|
||||
- 다시 칠하기 · 누가거리 따라가기 · 되돌리기 · 묶음 다시 돎 = 0 — 누름 밖(배수유역 다시 나눔 응답) ·
|
||||
방향키 · 끌기 도중 · 가짜 누름 · 묶음 차례를 띄운 그 누름(교점 놓음 · 곡선 지우기 · 되돌리기) ·
|
||||
「계산 중」 동안 새 누름 = 1(505-2).
|
||||
- 뿌리 한 곳 — 폼(`selectPipeByChainage` · `selectById` · `addAt`)은 그대로 펴기를 부르고 `design_shared`
|
||||
onReveal 하나가 `isUserPick` 으로 거름.
|
||||
- 499-1 뒤집음 — 펴기 = 「구조물 배치」 카드만 · 좌측 패널은 안 엶(`revealLeft` · `setOptionsOpen(` 없음) ·
|
||||
- 513-2 — 고름은 내용만 · 좌측 패널 · 「구조물 배치」 카드 열림 · 닫힘은 사용자가 둔 그대로
|
||||
(옛 onReveal · `isUserPick` 펴기 걷음 · 우클릭 추가도 카드 안 폄).
|
||||
- 499-1 그대로 — 좌측 패널은 안 엶(`revealLeft` · `setOptionsOpen(` 없음) ·
|
||||
패널을 열 때 고른 줄 · 강조 상자로 한 번 밂 · B06 평면 겹침 기준 = main 전체.
|
||||
|
||||
짝: `helper_398_1_user_pick.cjs`(진짜 `design_user_pick` · `design_page_data`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
|
||||
@@ -29,54 +19,15 @@ def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_398_1_user_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("way", ["click", "pointerup", "contextmenu", "afterRun", "busyPick"])
|
||||
def test_사용자_고름은_폄(r, way):
|
||||
assert r[way] == 1 # busyPick = 「계산 중」 동안 새 누름(505-2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("way", ["outside", "arrow", "drag", "synthetic", "pipelineRepaint"])
|
||||
def test_다시_칠하기는_안_폄(r, way):
|
||||
assert r[way] == 0
|
||||
|
||||
|
||||
def test_뿌리_한_곳():
|
||||
def test_513_2_고름은_카드_열림_안_건드림():
|
||||
shared = _read("A00_Common/design/design_shared.ts")
|
||||
assert (
|
||||
'isUserPick(shared.projectId) && shared.structures?.root.classList.remove("is-collapsed")'
|
||||
in shared
|
||||
)
|
||||
assert "onReveal" not in shared and "isUserPick" not in shared
|
||||
for rel in ("A00_Common/design/design_shared.ts", "A00_Common/design/design_page.ts"):
|
||||
assert "revealLeft" not in _read(rel) and "setOptionsOpen(" not in _read(rel) # 499-1
|
||||
panel = _read("B05_Profile/B05_Profile_UI_Structures_Panel.ts")
|
||||
assert panel.count("callbacks.onReveal?.()") == 3 # 고름 둘 · 우클릭 추가 — 부르는 쪽은 안 막음
|
||||
found = subprocess.run( # noqa: S603, S607 — 고정 실행 파일
|
||||
["git", "grep", "-l", "isUserPick(", "--", "*.ts"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
users = set(found.stdout.split()) | {
|
||||
"A00_Common/design/design_user_pick.ts",
|
||||
"A00_Common/design/design_shared.ts",
|
||||
} # 새 파일(추적 전)
|
||||
assert sorted(users) == [
|
||||
"A00_Common/design/design_shared.ts",
|
||||
"A00_Common/design/design_user_pick.ts",
|
||||
]
|
||||
assert "onReveal" not in panel
|
||||
assert 'root.classList.remove("is-collapsed")' not in panel
|
||||
assert not (ROOT / "A00_Common/design/design_user_pick.ts").exists()
|
||||
|
||||
|
||||
def test_열_때_고른_줄로_밂():
|
||||
|
||||
@@ -124,9 +124,6 @@ def test_눈금_클릭은_폼_그대로(got2) -> None:
|
||||
def test_폼_닫기는_패널_안_폄() -> None:
|
||||
panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
|
||||
head = panel[panel.index(" selectById(structureId) {") :]
|
||||
assert head.index("if (structureId !== null) callbacks.onReveal?.();") < head.index(
|
||||
"resetForm();"
|
||||
)
|
||||
assert "is-collapsed" not in head[: head.index(" addAt(")] # 접힘 · 펼침 안 건드림
|
||||
pick = _read("A00_Common", "design", "design_pick_profile.ts")
|
||||
assert "from === PLAN_PICK_FROM && selection.chainageM === null" in pick
|
||||
|
||||
@@ -71,7 +71,9 @@ def test_곡선_판_가둠_도구줄_횡단판_종단판() -> None:
|
||||
|
||||
|
||||
def test_지반선_계획선_누름_통과() -> None:
|
||||
css = _read("B06_Section", "B06_Section_UI_Style_Cross.css")
|
||||
css = _read("B06_Section", "B06_Section_UI_Style_Cross.css") + _read(
|
||||
"B06_Section", "B06_Section_UI_Style_Cross_Chart.css"
|
||||
)
|
||||
assert ".b06-chart__profile,\n.b06-chart__design-profile {\n pointer-events: none;" in css
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ def test_b08_b09_earthwork_station_mc() -> None:
|
||||
|
||||
|
||||
def test_b05_b06_screen_mc_emphasis() -> None:
|
||||
css = _src("B06_Section/B06_Section_UI_Style_Cross.css")
|
||||
css = _src("B06_Section/B06_Section_UI_Style_Cross.css") + _src("B06_Section/B06_Section_UI_Style_Cross_Chart.css")
|
||||
assert "header strong.b06-station--mc {" in css # 카드 머리 strong 규칙을 이김
|
||||
for rule in ("\n.b06-chart__station-label--mc {", "\n.b06-station--mc {"):
|
||||
body = css[css.index(rule) : css.index("}", css.index(rule))]
|
||||
@@ -108,7 +108,8 @@ def test_b05_profile_table_station_mc() -> None:
|
||||
# 칸 글 · 굵게 · 경고색 = B06 공용 규칙 · 칸 폭 판정(`longest`)은 (MC) 없는 글 그대로
|
||||
assert 'spec.cell(stationIndex) + (mc ? " (MC)" : "")' in table
|
||||
assert '${mc ? " b06-station--mc" : ""}' in table
|
||||
assert 'display.station) + (mc ? " (MC)" : "")' in table # 고른 측점 값 열
|
||||
selected = _src("B05_Profile/B05_Profile_UI_Profile_Table_Selected.ts")
|
||||
assert 'display.station) + (mc ? " (MC)" : "")' in selected # 고른 측점 값 열
|
||||
render = _src("B05_Profile/B05_Profile_UI_Profile_Render.ts")
|
||||
assert 'station.kind === "curve"' in render
|
||||
css = _src("B05_Profile/B05_Profile_UI_Style_Table.css")
|
||||
|
||||
@@ -72,18 +72,18 @@ def test_노선_고친_뒤_같은_열쇠_측점의_새_발자국(results):
|
||||
}
|
||||
|
||||
|
||||
def test_평면_눈금_클릭은_공용_고름에_적고_판을_편다(results):
|
||||
def test_평면_눈금_클릭은_공용_고름에_적고_판은_그대로(results):
|
||||
click = results["planClick"]
|
||||
assert click["selection"] == {"chainageM": 40, "structureId": None}
|
||||
assert click["heard"] == [{"at": 40, "from": "b05-plan"}]
|
||||
assert click["panel"] == "cross"
|
||||
assert click["panel"] is None # 접힌 판은 접힌 채 내용만(513-2)
|
||||
assert click["watched"]["chainageM"] == 40
|
||||
|
||||
|
||||
def test_다른_보기_일반측점_고름은_평면_판(results):
|
||||
assert results["otherGrid"]["watched"]["chainageM"] == 60
|
||||
assert "structure" not in results["otherGrid"]["watched"]
|
||||
assert results["otherGrid"]["panel"] == "cross"
|
||||
assert results["otherGrid"]["panel"] is None # 513-2
|
||||
|
||||
|
||||
def test_관_구조물_고름은_마름모만(results):
|
||||
@@ -101,9 +101,10 @@ def test_393_판_그림은_B06_카드_사본_노선_고치면_전후(results):
|
||||
assert results["cardsAfterEdit"] == ["b05-prev-s100", "b05-cur-s102"]
|
||||
|
||||
|
||||
def test_393_1_눈금이_연_판은_기억_안_함(results):
|
||||
assert results["planClick"]["panel"] == "cross"
|
||||
assert results["planClick"]["remember"] is False
|
||||
def test_513_2_고름은_판_열림_안_건드림(results):
|
||||
# 393-1 「눈금이 판을 엶(기억 안 함)」 뒤집음 — 열림 · 닫힘은 손잡이만
|
||||
assert results["planClick"]["panel"] is None
|
||||
assert "remember" not in results["planClick"]
|
||||
|
||||
|
||||
def test_393_3_판_칸은_그래프만():
|
||||
|
||||
@@ -225,7 +225,7 @@ def test_CAD_머리줄_없음_탭줄_빈자리_변수():
|
||||
src = ROOT / "B07_DesignDetail/openwebcad/src"
|
||||
ribbon = (src / "ribbon/Ribbon.tsx").read_text("utf-8")
|
||||
quick = (src / "components/QuickAccessBar.tsx").read_text("utf-8")
|
||||
css = (src / "App.css").read_text("utf-8")
|
||||
css = (src / "App.css").read_text("utf-8") + (src / "App_Layers.css").read_text("utf-8")
|
||||
assert '<span ref={slotRef} className="cad-ribbon-tabs__slot" />' in ribbon
|
||||
assert ribbon.index("cad-ribbon-tabs__slot") < ribbon.index("<QuickAccessBar />")
|
||||
flat = " ".join(ribbon.split()) # 줄바꿈 · 들여쓰기 무관
|
||||
|
||||
@@ -53,7 +53,9 @@ def test_따로_둔_모달도_위_고정():
|
||||
m01 = _text("M01_MasterData/M01_MasterData_UI_Style.css")
|
||||
assert "align-items: flex-start;" in _block(m01, ".m01-procure")
|
||||
assert "margin-top: 10vh;" in _block(m01, ".m01-procure__box")
|
||||
b01 = _text("B01_Dashboard/B01_Dashboard_UI_Style.css")
|
||||
b01 = _text("B01_Dashboard/B01_Dashboard_UI_Style.css") + _text(
|
||||
"B01_Dashboard/B01_Dashboard_UI_Style_Company.css"
|
||||
)
|
||||
assert "align-items: flex-start;" in _block(b01, ".b01-dashboard__modal")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user