Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -15,13 +15,10 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drain
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
||||
import type {
|
||||
StructureInstance,
|
||||
StructureType,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
|
||||
import {
|
||||
hasLegacyAlignment,
|
||||
readAlignment,
|
||||
@@ -56,11 +53,7 @@ import {
|
||||
structureIdKeeping,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
createRouteMassHaulPanel,
|
||||
type MassHaulSummaryValues,
|
||||
type RouteMassHaulContext,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
|
||||
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
|
||||
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
|
||||
@@ -83,9 +76,11 @@ import {
|
||||
snapToMasterStations,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
|
||||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
||||
import { noticeDroppedEdits } from "./design_save";
|
||||
import { createProfileOverlays } from "./design_profile_overlays";
|
||||
import { bindProfileViewport } from "./design_profile_viewport";
|
||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||
@@ -161,40 +156,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
if (drag.isSubDragging()) scheduleLightSync();
|
||||
else draw();
|
||||
};
|
||||
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
||||
* 유토곡선을 접어도 이 값은 남아야 한다. */
|
||||
const massBadge = createMassHaulBadge();
|
||||
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
||||
bodyWrap.append(massBadge.root);
|
||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||||
const haulPrefetch = createHaulPlanPrefetch(
|
||||
// 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
|
||||
const { massHaul, tableOverlay } = createProfileOverlays({
|
||||
projectId,
|
||||
() => routeId ?? undefined,
|
||||
subPanelChanged,
|
||||
);
|
||||
const massHaul = createRouteMassHaulPanel(
|
||||
subPanelChanged,
|
||||
(summary) => {
|
||||
const before = massHaulSummary;
|
||||
massHaulSummary = summary;
|
||||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||||
},
|
||||
haulPrefetch,
|
||||
);
|
||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(subPanelChanged);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
tableOverlay.attachScrollSync(body);
|
||||
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
|
||||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
routeId: () => routeId,
|
||||
bodyWrap,
|
||||
body,
|
||||
onChange: subPanelChanged,
|
||||
});
|
||||
const content = document.createElement("div");
|
||||
content.className = "b05-route-profile__content";
|
||||
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
|
||||
@@ -453,7 +422,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
zoom: profileZoom.state,
|
||||
holdElevationRange: elevationHold.hold,
|
||||
toolActive: () => tools.mode() !== "none",
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
tools,
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
setElevationWindowUpdater: (update) => {
|
||||
@@ -462,44 +431,17 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||||
body.addEventListener("scroll", () => {
|
||||
if (elevationHold.editing()) return;
|
||||
const fitted = updateElevationWindow?.() ?? null;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = body.scrollLeft;
|
||||
draw();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
// 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
|
||||
const unbindViewport = bindProfileViewport({
|
||||
body,
|
||||
editing: () => elevationHold.editing(),
|
||||
fitElevation: () => updateElevationWindow?.() ?? null,
|
||||
draw: () => draw(),
|
||||
lastSize: () => ({ width: lastWidth, height: lastHeight }),
|
||||
scheduleLightSync,
|
||||
scheduleDraw: () => drag.scheduleDraw(),
|
||||
});
|
||||
|
||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||||
attachWheelHorizontalScroll(body);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (
|
||||
body.clientWidth <= 0 ||
|
||||
body.clientHeight <= 0 ||
|
||||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
|
||||
)
|
||||
return;
|
||||
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
|
||||
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
|
||||
scheduleLightSync();
|
||||
drag.scheduleDraw();
|
||||
});
|
||||
resizeObserver.observe(body);
|
||||
|
||||
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
|
||||
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw));
|
||||
|
||||
@@ -650,6 +592,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
async save(): Promise<void> {
|
||||
if (!routeId || !store.dirty()) return;
|
||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||
noticeDroppedEdits(saved);
|
||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||
if (next?.base_pvi?.length) {
|
||||
base = toAlignmentBase(next);
|
||||
@@ -689,7 +632,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
setListening(false);
|
||||
drag.dispose();
|
||||
crossPreview.dispose();
|
||||
resizeObserver.disconnect();
|
||||
unbindViewport();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_free.ts
|
||||
* 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1).
|
||||
*
|
||||
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
||||
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
|
||||
*
|
||||
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
||||
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
|
||||
* (R = L / |대수차|). 양 끝 세로선의 라운드는 직선화처럼 그대로 둔다.
|
||||
*
|
||||
* 화면(`design_profile_free_view.ts`)은 클릭 · 마우스 · 키만 넘기고 여기 표시 자료를 그린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
ProfileAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
controlElevationAt,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
baseElevationAt,
|
||||
defaultCurveLength,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Straighten";
|
||||
|
||||
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
|
||||
const SAME_M = 1e-6;
|
||||
/** 클릭 높이를 이 단위(m)로 끊는다. */
|
||||
const ELEVATION_STEP_M = 0.01;
|
||||
|
||||
export interface FreeVertex {
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
}
|
||||
|
||||
/** range = 범위 고르는 중 · armed = 시작 세로선을 기다림 · drawing = 점 찍는 중. */
|
||||
export type FreePhase = "range" | "armed" | "drawing";
|
||||
|
||||
/** 그래프 세로 창 — 그릴 때 기준(`data-yw-*`)과 지금 걸린 변환. */
|
||||
export interface ChartWindow {
|
||||
center: number;
|
||||
pxPerM: number;
|
||||
zero: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** 세로 창 변환 글(`translate(0 a) scale(1 b)`)을 읽는다 — 없으면 그릴 때 그대로. */
|
||||
export function parseWindowTransform(text: string | null): { scale: number; offset: number } {
|
||||
const num = "([-+0-9.eE]+)";
|
||||
const match = new RegExp(`translate\\(\\s*0[ ,]+${num}\\s*\\)\\s*scale\\(\\s*1[ ,]+${num}`).exec(
|
||||
text ?? "",
|
||||
);
|
||||
if (!match) return { scale: 1, offset: 0 };
|
||||
const offset = Number(match[1]);
|
||||
const scale = Number(match[2]);
|
||||
return Number.isFinite(offset) && Number.isFinite(scale) && scale > 0
|
||||
? { scale, offset }
|
||||
: { scale: 1, offset: 0 };
|
||||
}
|
||||
|
||||
/** 그래프 y(px) → 표고(m). 과장 배율은 `pxPerM` 에 들어 있다. */
|
||||
export function elevationAtChartY(view: ChartWindow, y: number): number {
|
||||
const baked = (y - view.offset) / view.scale;
|
||||
return view.center + (view.zero - baked) / view.pxPerM;
|
||||
}
|
||||
|
||||
/** 표고(m) → 그래프 y(px). `elevationAtChartY` 의 역. */
|
||||
export function chartYAtElevation(view: ChartWindow, elevationM: number): number {
|
||||
return view.scale * (view.zero - (elevationM - view.center) * view.pxPerM) + view.offset;
|
||||
}
|
||||
|
||||
/** 가장 가까운 측점 세로선. 범위를 주면 그 안(양 끝 포함)에서만 고른다. */
|
||||
export function snapStation(
|
||||
stations: number[],
|
||||
chainageM: number,
|
||||
range: [number, number] | null = null,
|
||||
): number | null {
|
||||
let best: number | null = null;
|
||||
for (const station of stations) {
|
||||
if (range && (station < range[0] - SAME_M || station > range[1] + SAME_M)) continue;
|
||||
if (best === null || Math.abs(station - chainageM) < Math.abs(best - chainageM)) {
|
||||
best = station;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 두 점 사이 기울기(%). */
|
||||
export function gradePct(from: FreeVertex, to: FreeVertex): number {
|
||||
const span = to.chainage_m - from.chainage_m;
|
||||
return Math.abs(span) > 1e-12 ? ((to.elevation_m - from.elevation_m) / span) * 100 : 0;
|
||||
}
|
||||
|
||||
/** 임도 기준 최대 기울기를 넘는가 — 판정은 선형 계산의 위반 판정(`violations`)과 같은 식. */
|
||||
export function overGrade(pct: number, maxPct: number): boolean {
|
||||
return Math.abs(pct) > maxPct + 1e-6;
|
||||
}
|
||||
|
||||
/** 점들을 잇는 꺾은선 위 표고(chainage 오름차순 꺾은선). */
|
||||
function elevationOnPath(path: FreeVertex[], chainageM: number): number {
|
||||
for (let index = 1; index < path.length; index += 1) {
|
||||
const left = path[index - 1];
|
||||
const right = path[index];
|
||||
if (chainageM > right.chainage_m + SAME_M) continue;
|
||||
const span = right.chainage_m - left.chainage_m;
|
||||
const ratio = span > 1e-12 ? (chainageM - left.chainage_m) / span : 0;
|
||||
return left.elevation_m + (right.elevation_m - left.elevation_m) * ratio;
|
||||
}
|
||||
return path[path.length - 1].elevation_m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
|
||||
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
|
||||
*/
|
||||
export function buildFreeEdits(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
vertices: FreeVertex[],
|
||||
): AlignmentEdits {
|
||||
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (path.length < 2) return edits;
|
||||
const fromM = path[0].chainage_m;
|
||||
const toM = path[path.length - 1].chainage_m;
|
||||
if (toM - fromM <= SAME_M) return edits;
|
||||
const inner = new Map<string, number>();
|
||||
buildAlignment(base, edits).pvi.forEach((node) => {
|
||||
if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) {
|
||||
inner.set(chainageKey(node.chainage_m), node.chainage_m);
|
||||
}
|
||||
});
|
||||
path
|
||||
.slice(1, -1)
|
||||
.forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m));
|
||||
const offsets = { ...edits.station_offsets };
|
||||
const radii = { ...edits.curve_radii };
|
||||
inner.forEach((chainage, key) => {
|
||||
const target = elevationOnPath(path, chainage);
|
||||
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
|
||||
delete radii[key];
|
||||
});
|
||||
for (let index = 1; index < path.length - 1; index += 1) {
|
||||
const gradeIn = gradePct(path[index - 1], path[index]) / 100;
|
||||
const gradeOut = gradePct(path[index], path[index + 1]) / 100;
|
||||
const deltaGrade = Math.abs(gradeOut - gradeIn);
|
||||
if (deltaGrade < 1e-9) continue;
|
||||
radii[chainageKey(path[index].chainage_m)] = Number(
|
||||
(defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6),
|
||||
);
|
||||
}
|
||||
return { ...edits, station_offsets: offsets, curve_radii: radii };
|
||||
}
|
||||
|
||||
export interface FreeSegment {
|
||||
from: FreeVertex;
|
||||
to: FreeVertex;
|
||||
grade_pct: number;
|
||||
over: boolean;
|
||||
/** 마지막 점 → 마우스 자리(아직 안 찍음). */
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
/** 오버레이 표시 자료 — 화면은 이것만 그린다. */
|
||||
export interface FreeMarks {
|
||||
/** 고른 범위(강조 띠) — 둘 다 고르기 전엔 null. */
|
||||
band: [number, number] | null;
|
||||
/** 세로 강조선 chainage(범위 첫 측점 또는 범위 양 끝). */
|
||||
edges: number[];
|
||||
segments: FreeSegment[];
|
||||
/** 찍힌 점(시작 점 포함). */
|
||||
dots: FreeVertex[];
|
||||
}
|
||||
|
||||
export interface FreeDrawDeps {
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
|
||||
apply: (next: AlignmentEdits) => void;
|
||||
/** 모드를 끈다(적용 뒤 · Esc). */
|
||||
close: () => void;
|
||||
/** 단계 · 안내가 바뀜. */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
export interface FreeDraw {
|
||||
phase: () => FreePhase;
|
||||
reset: () => void;
|
||||
/** 그래프 클릭 — 날 chainage · 표고와 그래프 측점선 목록. 먹었으면 true. */
|
||||
click: (chainageM: number, elevationM: number, stations: number[]) => boolean;
|
||||
/** 마우스 자리 — 미리 보기 대상. 그래프 밖이면 null. */
|
||||
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
|
||||
/** Enter · Escape · Backspace — 먹었으면 true. */
|
||||
key: (key: string) => boolean;
|
||||
marks: () => FreeMarks;
|
||||
hint: () => string;
|
||||
}
|
||||
|
||||
export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
let phase: FreePhase = "range";
|
||||
let pending: number | null = null;
|
||||
let range: [number, number] | null = null;
|
||||
let anchor: number | null = null;
|
||||
let points: FreeVertex[] = [];
|
||||
let target: FreeVertex | null = null;
|
||||
|
||||
function reset(): void {
|
||||
phase = "range";
|
||||
pending = null;
|
||||
range = null;
|
||||
anchor = null;
|
||||
points = [];
|
||||
target = null;
|
||||
}
|
||||
|
||||
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
|
||||
function heightAt(chainageM: number): number | null {
|
||||
const alignment = deps.alignment();
|
||||
return alignment ? controlElevationAt(alignment, chainageM) : null;
|
||||
}
|
||||
|
||||
const finishM = (): number | null =>
|
||||
range && anchor !== null ? (anchor === range[0] ? range[1] : range[0]) : null;
|
||||
const same = (a: number | null, b: number | null): boolean =>
|
||||
a !== null && b !== null && Math.abs(a - b) < SAME_M;
|
||||
|
||||
/** 지나온 점(시작 포함) — 마지막 것이 다음 선의 출발점. */
|
||||
function path(): FreeVertex[] {
|
||||
if (anchor === null) return [];
|
||||
const z = heightAt(anchor);
|
||||
return z === null ? [] : [{ chainage_m: anchor, elevation_m: z }, ...points];
|
||||
}
|
||||
|
||||
/** 클릭 · 마우스 자리 → 다음 점 후보. 끝 세로선이면 그 계획고, 뒤로 가면 null. */
|
||||
function candidate(chainageM: number, elevationM: number, stations: number[]): FreeVertex | null {
|
||||
if (phase !== "drawing" || !range) return null;
|
||||
const station = snapStation(stations, chainageM, range);
|
||||
const end = finishM();
|
||||
if (station === null || end === null) return null;
|
||||
if (same(station, end)) {
|
||||
const z = heightAt(end);
|
||||
return z === null ? null : { chainage_m: end, elevation_m: z };
|
||||
}
|
||||
const walked = path();
|
||||
const last = walked[walked.length - 1];
|
||||
if (!last || (station - last.chainage_m) * Math.sign(end - last.chainage_m) <= SAME_M) {
|
||||
return null;
|
||||
}
|
||||
const z = Math.round(elevationM / ELEVATION_STEP_M) * ELEVATION_STEP_M;
|
||||
return { chainage_m: station, elevation_m: Number(z.toFixed(3)) };
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
const base = deps.base();
|
||||
const end = finishM();
|
||||
const z = end === null ? null : heightAt(end);
|
||||
if (!base || end === null || z === null) return;
|
||||
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices);
|
||||
reset();
|
||||
deps.apply(next);
|
||||
deps.close();
|
||||
}
|
||||
|
||||
return {
|
||||
phase: () => phase,
|
||||
reset,
|
||||
click(chainageM, elevationM, stations) {
|
||||
if (phase === "range") {
|
||||
const station = snapStation(stations, chainageM);
|
||||
if (station === null) return true;
|
||||
if (pending === null || same(pending, station)) pending = station;
|
||||
else {
|
||||
range = [Math.min(pending, station), Math.max(pending, station)];
|
||||
pending = null;
|
||||
phase = "armed";
|
||||
}
|
||||
} else if (phase === "armed" && range) {
|
||||
const station = snapStation(stations, chainageM, range);
|
||||
if (same(station, range[0]) || same(station, range[1])) {
|
||||
anchor = station;
|
||||
points = [];
|
||||
phase = "drawing";
|
||||
}
|
||||
} else if (phase === "drawing") {
|
||||
const next = candidate(chainageM, elevationM, stations);
|
||||
if (!next) return true;
|
||||
if (same(next.chainage_m, finishM())) {
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
points.push(next);
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
},
|
||||
hover(pointer) {
|
||||
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
|
||||
},
|
||||
key(key) {
|
||||
if (key === "Escape") {
|
||||
reset();
|
||||
deps.close();
|
||||
return true;
|
||||
}
|
||||
if (key === "Enter") {
|
||||
if (phase !== "drawing") return false;
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
if (key === "Backspace") {
|
||||
if (phase !== "drawing") return false;
|
||||
if (points.length) points.pop();
|
||||
else {
|
||||
anchor = null;
|
||||
phase = "armed";
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
marks() {
|
||||
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
|
||||
const dots = path();
|
||||
const line = target ? [...dots, target] : dots;
|
||||
const segments: FreeSegment[] = [];
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const grade = gradePct(line[index - 1], line[index]);
|
||||
segments.push({
|
||||
from: line[index - 1],
|
||||
to: line[index],
|
||||
grade_pct: grade,
|
||||
over: overGrade(grade, maxPct),
|
||||
preview: target !== null && index === line.length - 1,
|
||||
});
|
||||
}
|
||||
return {
|
||||
band: range,
|
||||
edges: range ? [...range] : pending !== null ? [pending] : [],
|
||||
segments,
|
||||
dots,
|
||||
};
|
||||
},
|
||||
hint() {
|
||||
if (phase === "range") {
|
||||
return pending === null
|
||||
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
||||
}
|
||||
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_free_view.ts
|
||||
* 종단 [자유지정] 화면 — 그래프 클릭 · 마우스 · 오버레이(범위 강조 · 점선 · 기울기 글)(215-1).
|
||||
*
|
||||
* 계산 · 상태는 `design_profile_free.ts` 몫. 여기서는 마우스 자리를 (chainage, 표고)로 바꿔
|
||||
* 넘기고 돌려받은 표시 자료를 그린다. 표고 변환은 종단 그래프 세로 창(`data-yw-*` + 지금 걸린
|
||||
* 변환)을 그대로 되짚는다 — 스크롤로 창이 바뀌어도 같은 식이다.
|
||||
*
|
||||
* 그래프는 그릴 때마다 새로 만들어지므로 그릴 때마다 `mount` 를 다시 부른다.
|
||||
* 자유지정 중엔 그래프 클릭 · 누르기를 여기서 먹는다(측점 고르기 · 구조물 끌기로 새지 않게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import {
|
||||
chartYAtElevation,
|
||||
elevationAtChartY,
|
||||
parseWindowTransform,
|
||||
type ChartWindow,
|
||||
type FreeDraw,
|
||||
} from "./design_profile_free";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 종단 그래프 한 벌의 가로 매핑 — 렌더러가 그릴 때마다 넘긴다. */
|
||||
export interface FreeChartGeom {
|
||||
/** 그래프 래퍼(`b05-profile__chart`). */
|
||||
chart: HTMLElement;
|
||||
/** 가로 스크롤러 — 스크롤하면 세로 창이 바뀌어 다시 그린다. */
|
||||
scroller: HTMLElement;
|
||||
x: (chainageM: number) => number;
|
||||
toChainage: (px: number) => number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
/** 그래프 세로 측점선 chainage(정규 + 구조물). */
|
||||
stations: number[];
|
||||
}
|
||||
|
||||
export interface FreeView {
|
||||
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
|
||||
mount: (geom: FreeChartGeom | null, active: boolean) => void;
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
|
||||
const element = document.createElementNS(SVG_NS, tag);
|
||||
Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value)));
|
||||
return element;
|
||||
}
|
||||
|
||||
/** 지금 그래프의 세로 창 — 자동 창 SVG 가 없으면 null. */
|
||||
function readWindow(chart: HTMLElement): { view: ChartWindow; top: number } | null {
|
||||
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
|
||||
if (!svg) return null;
|
||||
const read = (name: string): number => Number(svg.getAttribute(name));
|
||||
const layer = svg.querySelector(`.${Y_WINDOW_CLASS}`);
|
||||
const view: ChartWindow = {
|
||||
center: read(Y_WINDOW_ATTRS.center),
|
||||
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
|
||||
zero: read(Y_WINDOW_ATTRS.zero),
|
||||
...parseWindowTransform(layer?.getAttribute("transform") ?? null),
|
||||
};
|
||||
if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) {
|
||||
return null;
|
||||
}
|
||||
// 그래프 SVG 가 래퍼 위쪽에서 떨어져 있으면 그만큼 오버레이 y 를 맞춘다.
|
||||
const top = svg.getBoundingClientRect().top - chart.getBoundingClientRect().top;
|
||||
return { view, top };
|
||||
}
|
||||
|
||||
export function createFreeView(free: FreeDraw): FreeView {
|
||||
let geom: FreeChartGeom | null = null;
|
||||
let active = false;
|
||||
let overlay: SVGSVGElement | null = null;
|
||||
let listeners: AbortController | null = null;
|
||||
|
||||
/** 마우스 자리 → (chainage, 표고). 그래프 밖이면 null. */
|
||||
function pointerAt(event: MouseEvent) {
|
||||
if (!geom) return null;
|
||||
const win = readWindow(geom.chart);
|
||||
if (!win) return null;
|
||||
const rect = geom.chart.getBoundingClientRect();
|
||||
const chainageM = geom.toChainage(event.clientX - rect.left);
|
||||
if (!Number.isFinite(chainageM)) return null;
|
||||
const elevationM = elevationAtChartY(win.view, event.clientY - rect.top - win.top);
|
||||
return { chainageM, elevationM, stations: geom.stations };
|
||||
}
|
||||
|
||||
function redraw(): void {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
if (!geom || !active) return;
|
||||
const win = readWindow(geom.chart);
|
||||
if (!win) return;
|
||||
const { x, widthPx, heightPx } = geom;
|
||||
const y = (elevationM: number): number => chartYAtElevation(win.view, elevationM) + win.top;
|
||||
const marks = free.marks();
|
||||
const svg = svgEl("svg", {
|
||||
class: "b05-profile-freemark",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
}) as SVGSVGElement;
|
||||
if (marks.band) {
|
||||
const [left, right] = marks.band.map(x);
|
||||
svg.append(
|
||||
svgEl("rect", {
|
||||
class: "b05-profile-freemark__band",
|
||||
x: left,
|
||||
y: 0,
|
||||
width: Math.max(0, right - left),
|
||||
height: heightPx,
|
||||
}),
|
||||
);
|
||||
}
|
||||
marks.edges.forEach((chainage) =>
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: "b05-profile-freemark__edge",
|
||||
x1: x(chainage),
|
||||
x2: x(chainage),
|
||||
y1: 0,
|
||||
y2: heightPx,
|
||||
}),
|
||||
),
|
||||
);
|
||||
marks.segments.forEach((segment) => {
|
||||
const x1 = x(segment.from.chainage_m);
|
||||
const x2 = x(segment.to.chainage_m);
|
||||
const y1 = y(segment.from.elevation_m);
|
||||
const y2 = y(segment.to.elevation_m);
|
||||
const over = segment.over ? " is-over" : "";
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: `b05-profile-freemark__line${over}${segment.preview ? " is-preview" : ""}`,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
}),
|
||||
);
|
||||
const label = svgEl("text", {
|
||||
class: `b05-profile-freemark__grade${over}`,
|
||||
x: (x1 + x2) / 2,
|
||||
y: (y1 + y2) / 2 - 6,
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
label.textContent = `${segment.grade_pct.toFixed(1)}%`;
|
||||
svg.append(label);
|
||||
});
|
||||
marks.dots.forEach((dot) =>
|
||||
svg.append(
|
||||
svgEl("circle", {
|
||||
class: "b05-profile-freemark__dot",
|
||||
cx: x(dot.chainage_m),
|
||||
cy: y(dot.elevation_m),
|
||||
r: 3.5,
|
||||
}),
|
||||
),
|
||||
);
|
||||
overlay = svg;
|
||||
geom.chart.append(svg);
|
||||
}
|
||||
|
||||
function mount(next: FreeChartGeom | null, on: boolean): void {
|
||||
listeners?.abort();
|
||||
listeners = null;
|
||||
if (next) geom = next;
|
||||
active = on;
|
||||
if (!on) free.reset();
|
||||
if (!geom || !on || !geom.chart.isConnected) {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
return;
|
||||
}
|
||||
listeners = new AbortController();
|
||||
const { signal } = listeners;
|
||||
const chart = geom.chart;
|
||||
const swallow = (event: Event): void => {
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
// 누르기는 삼킴 — 구조물 측점선 끌기 · 계획고 버튼 길게 누르기가 시작되지 않게.
|
||||
chart.addEventListener("pointerdown", swallow, { capture: true, signal });
|
||||
chart.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
swallow(event);
|
||||
event.preventDefault();
|
||||
const pointer = pointerAt(event);
|
||||
if (pointer) free.click(pointer.chainageM, pointer.elevationM, pointer.stations);
|
||||
free.hover(pointerAt(event));
|
||||
redraw();
|
||||
},
|
||||
{ capture: true, signal },
|
||||
);
|
||||
chart.addEventListener(
|
||||
"mousemove",
|
||||
(event) => {
|
||||
free.hover(pointerAt(event));
|
||||
redraw();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
chart.addEventListener(
|
||||
"mouseleave",
|
||||
() => {
|
||||
free.hover(null);
|
||||
redraw();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
geom.scroller.addEventListener("scroll", redraw, { signal, passive: true });
|
||||
redraw();
|
||||
}
|
||||
|
||||
return { mount, redraw };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* =============================================================================
|
||||
* design_profile_overlays.ts
|
||||
* 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌).
|
||||
* 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
||||
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
|
||||
import {
|
||||
createRouteMassHaulPanel,
|
||||
type MassHaulSummaryValues,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
|
||||
export interface ProfileOverlaysDeps {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
/** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */
|
||||
bodyWrap: HTMLElement;
|
||||
/** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */
|
||||
body: HTMLElement;
|
||||
/** 서브패널이 바뀜(펼침 · 범례 · 리사이저 · 유토 배분 도착). */
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
const { bodyWrap, body, onChange } = deps;
|
||||
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
||||
* 유토곡선을 접어도 이 값은 남아야 한다. */
|
||||
const massBadge = createMassHaulBadge();
|
||||
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
||||
bodyWrap.append(massBadge.root);
|
||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||||
const haulPrefetch = createHaulPlanPrefetch(
|
||||
deps.projectId,
|
||||
() => deps.routeId() ?? undefined,
|
||||
onChange,
|
||||
);
|
||||
const massHaul = createRouteMassHaulPanel(
|
||||
onChange,
|
||||
(summary) => {
|
||||
const before = massHaulSummary;
|
||||
massHaulSummary = summary;
|
||||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||||
},
|
||||
haulPrefetch,
|
||||
);
|
||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(onChange);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
tableOverlay.attachScrollSync(body);
|
||||
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
|
||||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
return { massHaul, tableOverlay };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/* =============================================================================
|
||||
* design_profile_viewport.ts
|
||||
* 종단 스크롤러의 가로 스크롤 · 휠 · 크기 바뀜 듣개(213-3 · 700줄 한계로 뗌).
|
||||
* 다시 그리기 · 높이 동기화는 종단도가 넘긴 함수를 부른다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
|
||||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
|
||||
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
|
||||
export interface ProfileViewportDeps {
|
||||
body: HTMLElement;
|
||||
/** ▲▼ 계획고 편집으로 Y 축을 잡고 있는 중. */
|
||||
editing: () => boolean;
|
||||
/** 세로 창 갱신기 — 그릴 때마다 렌더러가 새로 준다(없으면 null). */
|
||||
fitElevation: () => ElevationWindowResult | null;
|
||||
draw: () => void;
|
||||
/** 마지막으로 그린 본문 크기 — 같으면 크기 바뀜을 무시한다. */
|
||||
lastSize: () => { width: number; height: number };
|
||||
scheduleLightSync: () => void;
|
||||
scheduleDraw: () => void;
|
||||
}
|
||||
|
||||
/** 듣개를 붙이고 떼는 함수를 돌려준다. */
|
||||
export function bindProfileViewport(deps: ProfileViewportDeps): () => void {
|
||||
const { body } = deps;
|
||||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||||
body.addEventListener("scroll", () => {
|
||||
if (deps.editing()) return;
|
||||
const fitted = deps.fitElevation();
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = body.scrollLeft;
|
||||
deps.draw();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
});
|
||||
|
||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||||
attachWheelHorizontalScroll(body);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const last = deps.lastSize();
|
||||
if (
|
||||
body.clientWidth <= 0 ||
|
||||
body.clientHeight <= 0 ||
|
||||
(Math.abs(body.clientWidth - last.width) < 1 && Math.abs(body.clientHeight - last.height) < 1)
|
||||
)
|
||||
return;
|
||||
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
|
||||
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
|
||||
deps.scheduleLightSync();
|
||||
deps.scheduleDraw();
|
||||
});
|
||||
resizeObserver.observe(body);
|
||||
return () => resizeObserver.disconnect();
|
||||
}
|
||||
@@ -65,11 +65,26 @@ function reportOnly(label: string): (error: unknown) => void {
|
||||
};
|
||||
}
|
||||
|
||||
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
|
||||
export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void {
|
||||
const dropped = saved.dropped_edit_chainages ?? [];
|
||||
if (!dropped.length) return;
|
||||
const where = dropped
|
||||
.slice(0, 5)
|
||||
.map((value) => `${value.toFixed(2)}m`)
|
||||
.join(" · ");
|
||||
showToast(
|
||||
`계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` +
|
||||
(dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
||||
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
||||
const draft = pendingAlignment(routeId);
|
||||
if (!draft) return;
|
||||
await saveProfileAlignment(projectId, routeId, draft);
|
||||
noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
|
||||
markAlignmentSaved(routeId);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ export function resolveSelection<T extends SelectableStructure>(
|
||||
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
|
||||
}
|
||||
|
||||
/** 측점 하나 고름(횡단 카드 · 종단 측점선)이 덮어 열어도 되는 군 — 노면공(G군)은 뺌(152-5 · B05 = B06 216-1). */
|
||||
export const coversAtStation = (group: string | undefined): boolean => group !== "G";
|
||||
|
||||
/* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface Stored {
|
||||
|
||||
@@ -31,7 +31,13 @@ import {
|
||||
restoreStructurePick,
|
||||
wireStructurePick,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, nearestWithin, readSelection, selectDesign } from "./design_selection";
|
||||
import {
|
||||
coversAtStation,
|
||||
NO_SELECTION,
|
||||
nearestWithin,
|
||||
readSelection,
|
||||
selectDesign,
|
||||
} from "./design_selection";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import {
|
||||
bindDesignView,
|
||||
@@ -223,6 +229,11 @@ export async function mountProfileView(
|
||||
selectMarker: (id) => viewer.markers.selectStation(id),
|
||||
selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null),
|
||||
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
|
||||
// 측점선 · 3D 띠 고름 = 횡단 카드 고름(B06 showAtChainage)과 같은 규칙 · 같은 함수(216-1)
|
||||
openAtStation: (chainageM) =>
|
||||
panel.structures.selectAt({ chainageM, structureId: null }, (entry) =>
|
||||
coversAtStation(bridge.typeGroup(entry.type_id)),
|
||||
),
|
||||
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
|
||||
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
|
||||
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* =============================================================================
|
||||
* key_shortcut.ts — Ctrl(⌘)+글자 단축키 판정 공용(PLAN 214-2)
|
||||
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞음 — 영문 글자·숫자면 그 글자 ·
|
||||
* 아니면 자판 자리(`code`: KeyZ · Digit1)로 판정. Shift 의 대문자도 소문자로 맞춤.
|
||||
* 쓰는 곳 — 페이지(B05 · M02 · 시트 키) + CAD(`B07_DesignDetail/openwebcad` 의 `input-shortcut.ts` 가 다시 내보냄).
|
||||
* 시험 = `resources/tester/cad/ctrl-shortcut-hangul.test.ts`
|
||||
* ========================================================================== */
|
||||
|
||||
type KeyLike = Pick<KeyboardEvent, "code"> & Partial<Pick<KeyboardEvent, "key">>;
|
||||
|
||||
/** 누른 글자·숫자(소문자) — 글자·숫자 자리가 아니면 빈 글 */
|
||||
export function keyLetter(evt: KeyLike): string {
|
||||
const key = evt.key ?? "";
|
||||
if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase();
|
||||
const m = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(evt.code ?? "");
|
||||
return m ? (m[1] ?? m[2]).toLowerCase() : "";
|
||||
}
|
||||
|
||||
/** Ctrl(Mac ⌘)과 함께 누른 `letter`(소문자 글자·숫자)인가 — Shift · Alt 는 보지 않음 */
|
||||
export function isCtrlKey(
|
||||
evt: KeyLike & Pick<KeyboardEvent, "ctrlKey" | "metaKey">,
|
||||
letter: string,
|
||||
): boolean {
|
||||
return (evt.ctrlKey || evt.metaKey) && keyLetter(evt) === letter;
|
||||
}
|
||||
|
||||
/** Ctrl 단축키 이름 */
|
||||
export type CtrlShortcut = "undo" | "redo" | "save" | "selectAll";
|
||||
|
||||
/** 공용 Ctrl 단축키 — Z · Shift+Z · Y · S · A (Alt 함께는 아님 · Shift 는 Z 만) */
|
||||
export function ctrlShortcut(
|
||||
evt: KeyLike &
|
||||
Pick<KeyboardEvent, "ctrlKey" | "altKey" | "shiftKey"> &
|
||||
Partial<Pick<KeyboardEvent, "metaKey">>,
|
||||
): CtrlShortcut | null {
|
||||
if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null;
|
||||
switch (keyLetter(evt)) {
|
||||
case "z":
|
||||
return evt.shiftKey ? "redo" : "undo";
|
||||
case "y":
|
||||
return evt.shiftKey ? null : "redo";
|
||||
case "s":
|
||||
return evt.shiftKey ? null : "save";
|
||||
case "a":
|
||||
return evt.shiftKey ? null : "selectAll";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import "./random_uuid_polyfill";
|
||||
import "@ui/ui_template_theme.css";
|
||||
import { injectBaseStyles } from "@ui/ui_template_elements";
|
||||
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
|
||||
import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe";
|
||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
|
||||
import {
|
||||
@@ -54,6 +55,8 @@ function bootstrap(): void {
|
||||
|
||||
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
|
||||
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
|
||||
// B05~B09 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1)
|
||||
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
|
||||
|
||||
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
|
||||
let shown = "";
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { keyLetter } from "../key_shortcut";
|
||||
import { cycleAbs } from "./spreadsheet_editor";
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
@@ -127,7 +128,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
|
||||
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
|
||||
return true;
|
||||
}
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
const k = keyLetter(e) || (e.key.length === 1 ? e.key.toLowerCase() : e.key);
|
||||
if (ctrl && !e.altKey) {
|
||||
switch (k) {
|
||||
case "z":
|
||||
@@ -233,7 +234,8 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫
|
||||
// 한글 조합 중 — 브라우저 몫 (Ctrl+글자 단축키는 조합 중에도 판정 · 한글 자판 대응 214-2)
|
||||
if ((e.isComposing || e.keyCode === 229) && !((e.ctrlKey || e.metaKey) && keyLetter(e))) return;
|
||||
const inBar = e.target === ed.formula;
|
||||
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
|
||||
if (done) {
|
||||
|
||||
@@ -91,7 +91,7 @@ def _build(
|
||||
return detail, carried
|
||||
|
||||
|
||||
# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1)
|
||||
# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1)
|
||||
PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m")
|
||||
|
||||
|
||||
@@ -130,7 +130,10 @@ def _apply_new_defaults(
|
||||
if diameter_m > 0:
|
||||
for key in PIPE_HEIGHT_KEYS:
|
||||
if key in keys:
|
||||
options.setdefault(key, pipe_wall_min_height_m(diameter_m))
|
||||
side = (
|
||||
"inlet" if key == "inlet_revet_height_m" else ""
|
||||
) # 유입 흙막이 + 0.7(211-1)
|
||||
options.setdefault(key, pipe_wall_min_height_m(diameter_m, side))
|
||||
if point.facility == "ford_bridge" and "ford_height_m" not in options:
|
||||
flow = (flows or {}).get(round(point.chainage_m, 2))
|
||||
height = ford_height_m(flow, float(options.get("ford_width_m") or 0))
|
||||
|
||||
@@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse {
|
||||
route_id: number;
|
||||
profile_alignment: unknown;
|
||||
grade_summary: RouteGradeSummary | null;
|
||||
/** 측점 · 관 · 구조물 자리가 없어 서버가 버린 계획고 편집 자리(m · 213-2). */
|
||||
dropped_edit_chainages?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -158,6 +158,86 @@ def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray)
|
||||
return np.interp(targets, pvi_s, pvi_z)
|
||||
|
||||
|
||||
#: 반올림만 다른 같은 측점(m) — 관 측점 0.001m(자동설계) ↔ 0.01m(화면 저장) 차이.
|
||||
#: 화면 `SAME_STATION_SNAP_M`(B05_Profile_UI_Profile_Alignment.ts) 와 짝.
|
||||
SAME_STATION_SNAP_M = 0.006
|
||||
|
||||
|
||||
def _nearest_station(value: float, station_s: np.ndarray) -> float | None:
|
||||
if not station_s.size:
|
||||
return None
|
||||
near = float(station_s[int(np.argmin(np.abs(station_s - value)))])
|
||||
return near if abs(near - value) < SAME_STATION_SNAP_M else None
|
||||
|
||||
|
||||
def snap_to_stations(
|
||||
base_s: np.ndarray,
|
||||
base_z: np.ndarray,
|
||||
edits: dict[str, dict[str, float]],
|
||||
station_s: np.ndarray,
|
||||
) -> tuple[np.ndarray, np.ndarray, dict[str, dict[str, float]]]:
|
||||
"""측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1).
|
||||
|
||||
관 측점이 반올림으로 옮겨도(263.206 → 263.21) 꼭짓점 · 편집 키는 옛 글자로 남아, 새 측점
|
||||
편집과 0.004m 사이 두 꼭짓점이 됨(−15000% 계단 · 숨은 측점). 겹치면 측점 위 것이 이김.
|
||||
옮긴 꼭짓점은 옛 선의 측점 높이를 받아 측점 계획고가 안 바뀜. 양 끝 · 측점에서 먼 키는 그대로.
|
||||
"""
|
||||
last = len(base_s) - 1
|
||||
nodes: dict[float, tuple[float, float, float]] = {}
|
||||
for index, (s, z) in enumerate(zip(base_s.tolist(), base_z.tolist())):
|
||||
near = None if index in (0, last) else _nearest_station(s, station_s)
|
||||
at = s if near is None else near
|
||||
gap = 0.0 if near is None else abs(near - s)
|
||||
slot = round(at, _KEY_DECIMALS)
|
||||
if slot not in nodes or gap < nodes[slot][0]:
|
||||
# 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로
|
||||
nodes[slot] = (gap, at, z if near is None else float(np.interp(at, base_s, base_z)))
|
||||
ordered = [nodes[slot] for slot in sorted(nodes)]
|
||||
snapped: dict[str, dict[str, float]] = {}
|
||||
for name, values in edits.items():
|
||||
best: dict[str, tuple[float, float]] = {}
|
||||
for key, value in values.items():
|
||||
try:
|
||||
chainage = float(key)
|
||||
except (TypeError, ValueError):
|
||||
chainage = float("nan")
|
||||
near = _nearest_station(chainage, station_s) if np.isfinite(chainage) else None
|
||||
target = key if near is None else chainage_key(near)
|
||||
gap = 0.0 if near is None else abs(near - chainage)
|
||||
if target not in best or gap < best[target][0]:
|
||||
best[target] = (gap, value)
|
||||
snapped[name] = {key: value for key, (_, value) in best.items()}
|
||||
return (
|
||||
np.array([item[1] for item in ordered], dtype=np.float64),
|
||||
np.array([item[2] for item in ordered], dtype=np.float64),
|
||||
snapped,
|
||||
)
|
||||
|
||||
|
||||
def drop_unplaced_edits(
|
||||
edits: dict[str, dict[str, float]], places: np.ndarray
|
||||
) -> tuple[dict[str, dict[str, float]], list[float]]:
|
||||
"""측점 · 관 · 구조물 자리(`places`) 0.006m 안에 없는 편집 키를 버린다(213-2).
|
||||
|
||||
옮긴 관의 옛 자리 · 지운 시설 자리에 남은 키는 측점선 없는 꼭짓점(유령)이 된다 — 세션 초안이
|
||||
옛 키를 다시 보내도 [저장]에서 걸러진다. (남은 편집, 버린 자리 누가거리).
|
||||
"""
|
||||
kept: dict[str, dict[str, float]] = {}
|
||||
dropped: set[float] = set()
|
||||
for name, values in edits.items():
|
||||
kept[name] = {}
|
||||
for key, value in values.items():
|
||||
try:
|
||||
chainage = float(key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if places.size and float(np.min(np.abs(places - chainage))) < SAME_STATION_SNAP_M:
|
||||
kept[name][key] = value
|
||||
else:
|
||||
dropped.add(round(chainage, _KEY_DECIMALS))
|
||||
return kept, sorted(dropped)
|
||||
|
||||
|
||||
def resolve_pvi(
|
||||
base_s: np.ndarray,
|
||||
base_z: np.ndarray,
|
||||
@@ -407,6 +487,22 @@ def build_alignment(
|
||||
if value is not None
|
||||
}
|
||||
|
||||
station_s = np.array(
|
||||
sorted(
|
||||
float(station["chainage_m"])
|
||||
for station in stations
|
||||
if isinstance(station.get("chainage_m"), (int, float))
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
base_s, base_z, snapped = snap_to_stations(
|
||||
base_s,
|
||||
base_z,
|
||||
{"station_offsets": station_offsets, "curve_radii": curve_radii},
|
||||
station_s,
|
||||
)
|
||||
station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"]
|
||||
|
||||
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
|
||||
curves, warnings = build_curves(
|
||||
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -15,6 +16,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
|
||||
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
||||
from B05_Profile.B05_Profile_Engine import run_route_design
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
@@ -384,13 +386,30 @@ async def update_contour_interval(
|
||||
)
|
||||
|
||||
|
||||
def _structure_marks(project_root: Path, route_data_path: str) -> list[float]:
|
||||
"""관 · 구조물 자리(m) — 관 지점 전부 + 구조물 측점 자리(시작 · 기준 · 종료)."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
_load_pipe_points,
|
||||
_load_route_polyline,
|
||||
resolve_extra_stations,
|
||||
)
|
||||
|
||||
pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path))
|
||||
extras = resolve_extra_stations(project_root, pipes)
|
||||
return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras]
|
||||
|
||||
|
||||
def _apply_alignment_edits(
|
||||
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다.
|
||||
project_root: Path,
|
||||
longitudinal_file_path: str,
|
||||
edits: dict[str, Any],
|
||||
marks: list[float] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], list[float]]:
|
||||
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약, 버린 자리)를 반환한다.
|
||||
|
||||
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
|
||||
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다.
|
||||
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. `marks`(관 · 구조물 자리)를 주면
|
||||
측점 · 관 · 구조물 어디에도 없는 편집 키를 버린다(213-2 — 저장 차례가 관 · 구조물 뒤라 최신).
|
||||
"""
|
||||
root = project_root.resolve()
|
||||
path = (root / longitudinal_file_path).resolve()
|
||||
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
|
||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||
dropped: list[float] = []
|
||||
if marks is not None:
|
||||
stations = [float(s.get("chainage_m", 0.0)) for s in longitudinal.get("stations") or []]
|
||||
edits, dropped = drop_unplaced_edits(
|
||||
{
|
||||
"station_offsets": dict(edits.get("station_offsets") or {}),
|
||||
"curve_radii": dict(edits.get("curve_radii") or {}),
|
||||
},
|
||||
np.array(stations + marks, dtype=np.float64),
|
||||
)
|
||||
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
profiles = longitudinal.get("design_profiles")
|
||||
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
|
||||
else:
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
atomic_write_json(path, longitudinal)
|
||||
return alignment, {"id": profile["id"], **profile["summary"]}
|
||||
return alignment, {"id": profile["id"], **profile["summary"]}, dropped
|
||||
|
||||
|
||||
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
|
||||
@@ -426,11 +455,18 @@ async def save_profile_alignment(
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
alignment, grade_summary = await asyncio.to_thread(
|
||||
route = await get_latest_route(connection, project_id)
|
||||
marks = None
|
||||
if route and route["id"] == request.route_id and route.get("route_data_path"):
|
||||
marks = await asyncio.to_thread(
|
||||
_structure_marks, project_root, str(route["route_data_path"])
|
||||
)
|
||||
alignment, grade_summary, dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
request.edits(),
|
||||
marks,
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
@@ -446,6 +482,7 @@ async def save_profile_alignment(
|
||||
route_id=request.route_id,
|
||||
profile_alignment=alignment,
|
||||
grade_summary=grade_summary,
|
||||
dropped_edit_chainages=dropped,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
|
||||
@@ -214,27 +214,41 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
||||
None,
|
||||
)
|
||||
moves[chainage_key(old)] = None if near is None else chainage_key(near)
|
||||
|
||||
def moved(value: float) -> tuple[bool, str | None]:
|
||||
"""옛 측점 0.006m 안이면 (참, 옮길 키 · 지우면 None) — 옛 글자(263.206)도 같은 측점(213-1).
|
||||
남은 측점 위 값은 그대로."""
|
||||
if chainage_key(value) in after_keys:
|
||||
return False, None
|
||||
for old_key, new_key in moves.items():
|
||||
if abs(float(old_key) - value) < _SAME_STATION_M:
|
||||
return True, new_key
|
||||
return False, None
|
||||
|
||||
edits = stored.get("edits") or {}
|
||||
next_edits: dict[str, dict[str, Any]] = {}
|
||||
changed = False
|
||||
for name in ("station_offsets", "curve_radii"):
|
||||
values = dict(edits.get(name) or {})
|
||||
for old_key, new_key in moves.items():
|
||||
if old_key in values:
|
||||
value = values.pop(old_key)
|
||||
if new_key is not None:
|
||||
values.setdefault(new_key, value)
|
||||
changed = True
|
||||
# 옛 측점 글자 그대로인 키가 먼저 — 반올림 글자보다 이김
|
||||
for key in sorted(values, key=lambda item: item not in moves):
|
||||
hit, new_key = moved(float(key))
|
||||
if not hit:
|
||||
continue
|
||||
value = values.pop(key)
|
||||
if new_key is not None:
|
||||
values.setdefault(new_key, value)
|
||||
changed = True
|
||||
next_edits[name] = values
|
||||
base: list[dict[str, Any]] = []
|
||||
for node in stored["base_pvi"]:
|
||||
key = chainage_key(node["chainage_m"])
|
||||
if key not in moves:
|
||||
hit, new_key = moved(float(node["chainage_m"]))
|
||||
if not hit:
|
||||
base.append(node)
|
||||
continue
|
||||
changed = True
|
||||
if moves[key] is not None:
|
||||
base.append({**node, "chainage_m": float(moves[key])})
|
||||
if new_key is not None:
|
||||
base.append({**node, "chainage_m": float(new_key)})
|
||||
if not changed:
|
||||
return None
|
||||
stored["base_pvi"] = base
|
||||
|
||||
@@ -173,7 +173,9 @@ async def apply_profile_target(
|
||||
root = Path(resolve_stored_project_path(stored_path))
|
||||
relative = str(row["longitudinal_file_path"])
|
||||
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
|
||||
_alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {})
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits, root, relative, {}
|
||||
)
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
await update_longitudinal_grade_summary(
|
||||
|
||||
@@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel):
|
||||
route_id: int
|
||||
profile_alignment: dict[str, Any]
|
||||
grade_summary: dict[str, Any] | None = None
|
||||
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
|
||||
dropped_edit_chainages: list[float] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RouteSolveResponse(BaseModel):
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
||||
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||
export interface PipeProjection {
|
||||
@@ -103,20 +104,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
|
||||
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
||||
function applyIrregularStations(stations: IrregularStation[]): void {
|
||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
|
||||
// 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김.
|
||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다.
|
||||
// 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2)
|
||||
// — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움.
|
||||
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
||||
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
|
||||
irregularStations
|
||||
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
|
||||
.forEach((station) => {
|
||||
const same = stations.find(
|
||||
(next) =>
|
||||
Math.abs(next.chainage_m - station.chainage_m) < 0.006 &&
|
||||
!oldKeys.has(next.chainage_m.toFixed(3)),
|
||||
);
|
||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||
});
|
||||
const gone = irregularStations.filter(
|
||||
(station) => !nextKeys.has(station.chainage_m.toFixed(3)),
|
||||
);
|
||||
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
|
||||
gone.forEach((station) => {
|
||||
const same = followedStation(station, gone, born);
|
||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||
});
|
||||
irregularStations = stations;
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations(withoutFordPavement(stations));
|
||||
@@ -361,6 +361,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
},
|
||||
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
|
||||
irregularStations: () => withoutFordPavement(irregularStations),
|
||||
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
|
||||
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
|
||||
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
||||
setPipes(pipes: PipeProjection[]): void {
|
||||
pipeStations = pipesToStations(pipes);
|
||||
|
||||
@@ -182,6 +182,81 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 사라진 비정규 측점의 편집이 따라갈 새 측점(213-2) — 0.006m 안(반올림) · 같은 id · 사라진 것 하나와
|
||||
* 새것 하나(옮긴 관)면 그것. 없으면 null(편집을 지움).
|
||||
*/
|
||||
export function followedStation<T extends { id: string; chainage_m: number }>(
|
||||
station: T,
|
||||
gone: ReadonlyArray<T>,
|
||||
born: ReadonlyArray<T>,
|
||||
): T | null {
|
||||
return (
|
||||
born.find((next) => Math.abs(next.chainage_m - station.chainage_m) < 0.006) ??
|
||||
born.find((next) => next.id === station.id) ??
|
||||
(gone.length === 1 && born.length === 1 ? born[0] : null)
|
||||
);
|
||||
}
|
||||
|
||||
/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */
|
||||
const SAME_STATION_SNAP_M = 0.006;
|
||||
|
||||
function nearestStation(value: number, stationS: number[]): number | null {
|
||||
let near: number | null = null;
|
||||
stationS.forEach((station) => {
|
||||
if (near === null || Math.abs(station - value) < Math.abs(near - value)) near = station;
|
||||
});
|
||||
return near !== null && Math.abs(near - value) < SAME_STATION_SNAP_M ? near : null;
|
||||
}
|
||||
|
||||
function snapKeys(values: Record<string, number>, stationS: number[]): Record<string, number> {
|
||||
const best = new Map<string, { gap: number; value: number }>();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
const chainage = Number(key);
|
||||
const near = Number.isFinite(chainage) ? nearestStation(chainage, stationS) : null;
|
||||
const target = near === null ? key : chainageKey(near);
|
||||
const gap = near === null ? 0 : Math.abs(near - chainage);
|
||||
const held = best.get(target);
|
||||
if (!held || gap < held.gap) best.set(target, { gap, value });
|
||||
});
|
||||
return Object.fromEntries([...best].map(([key, entry]) => [key, entry.value]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1) — 서버
|
||||
* `snap_to_stations` 와 같은 규칙. 관 측점이 반올림으로 옮겨도(263.206 → 263.21) 옛 글자로 남아
|
||||
* 0.004m 사이 두 꼭짓점이 되던 흠. 겹치면 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이
|
||||
* (측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로.
|
||||
*/
|
||||
function snapToStations(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
): { basePvi: AlignmentNode[]; edits: AlignmentEdits } {
|
||||
const stationS = base.stations.map((station) => station.chainage_m);
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const last = base.basePvi.length - 1;
|
||||
const nodes = new Map<number, { gap: number; node: AlignmentNode }>();
|
||||
base.basePvi.forEach((node, index) => {
|
||||
const near = index === 0 || index === last ? null : nearestStation(node.chainage_m, stationS);
|
||||
const at = near ?? node.chainage_m;
|
||||
const gap = near === null ? 0 : Math.abs(near - node.chainage_m);
|
||||
const slot = Number(at.toFixed(3));
|
||||
const held = nodes.get(slot);
|
||||
// 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로
|
||||
const elevation = near === null ? node.elevation_m : interpolate(baseS, baseZ, at);
|
||||
if (!held || gap < held.gap)
|
||||
nodes.set(slot, { gap, node: { chainage_m: at, elevation_m: elevation } });
|
||||
});
|
||||
return {
|
||||
basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!.node),
|
||||
edits: {
|
||||
station_offsets: snapKeys(edits.station_offsets, stationS),
|
||||
curve_radii: snapKeys(edits.curve_radii, stationS),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */
|
||||
function resolvePvi(
|
||||
base: AlignmentBase,
|
||||
@@ -316,8 +391,10 @@ function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): ProfileAlignment {
|
||||
const edits = normalizeEdits(input);
|
||||
export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): ProfileAlignment {
|
||||
const snapped = snapToStations(raw, normalizeEdits(input));
|
||||
const base: AlignmentBase = { ...raw, basePvi: snapped.basePvi };
|
||||
const edits = snapped.edits;
|
||||
const warnings: string[] = [];
|
||||
const nodes = resolvePvi(base, edits.station_offsets);
|
||||
const pviS = nodes.map((node) => node.chainage_m);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조.
|
||||
* ========================================================================== */
|
||||
|
||||
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
tiltStraightRun,
|
||||
} from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import { createFreeDraw } from "../A00_Common/design/design_profile_free";
|
||||
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
|
||||
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */
|
||||
const KEY_STEP_M = 0.1;
|
||||
@@ -111,6 +115,18 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
pause: () => (syncPipes(), inner.pause()),
|
||||
};
|
||||
|
||||
// [자유지정] — 적용은 직선화 결과와 같은 길(applyEdits · 이력 · 막기 규칙)(215-1).
|
||||
const free = createFreeDraw({
|
||||
base: ctx.base,
|
||||
alignment: ctx.alignment,
|
||||
edits: ctx.edits,
|
||||
apply: (next) => ctx.applyEdits(next),
|
||||
close: () => tools.clear(),
|
||||
changed: ctx.refresh,
|
||||
});
|
||||
const freeView = createFreeView(free);
|
||||
let lastGeom: FreeChartGeom | null = null;
|
||||
|
||||
const tools = createProfileTools({
|
||||
onStraighten: (fromM, toM) => {
|
||||
const base = ctx.base();
|
||||
@@ -143,12 +159,19 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
return alignment ? hasStraightRuns(alignment) : false;
|
||||
},
|
||||
onChanged: ctx.refresh,
|
||||
onModeChange: (mode) => freeView.mount(lastGeom, mode === "free"),
|
||||
freeHint: () => free.hint(),
|
||||
mountOverlay: (geom) => {
|
||||
lastGeom = geom;
|
||||
freeView.mount(geom, tools.mode() === "free");
|
||||
},
|
||||
});
|
||||
|
||||
/** 그래프에서 누른 자리를 도구가 먹는다. */
|
||||
function handleToolPick(chainageM: number | null): boolean {
|
||||
const mode = tools.mode();
|
||||
if (mode === "none") return false;
|
||||
if (mode === "free") return true; // 자유지정 클릭은 오버레이가 먼저 먹는다.
|
||||
if (chainageM === null) return tools.handleRunPick(null);
|
||||
const alignment = ctx.alignment();
|
||||
if (mode === "shift") {
|
||||
@@ -200,6 +223,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
|
||||
return true;
|
||||
}
|
||||
// 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1)
|
||||
if (!arrowsBelongToProfile()) return false;
|
||||
// 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져
|
||||
// (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측).
|
||||
const station = holdStation ?? selectedIrregular();
|
||||
@@ -283,7 +308,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
// 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다.
|
||||
if (target && target.closest("input, select, textarea")) return;
|
||||
const key = event.key;
|
||||
if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) {
|
||||
// [자유지정] 중 Enter 끝 · Esc 취소 · Backspace 점 빼기 — 단추 초점의 Enter 누름도 막는다.
|
||||
if (tools.mode() === "free" && free.key(key)) {
|
||||
event.preventDefault();
|
||||
freeView.redraw();
|
||||
return;
|
||||
}
|
||||
if (isCtrlKey(event, "z")) {
|
||||
event.preventDefault();
|
||||
flushHold();
|
||||
if (event.shiftKey) history.redo();
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Y_AXIS_WINDOW_CLASS,
|
||||
type ElevationWindowResult,
|
||||
} from "@util/common_util_chart_ywindow";
|
||||
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { ProfileTools } from "./B05_Profile_UI_Profile_Tools";
|
||||
import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover";
|
||||
import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import type { ProfileZoomState } from "./B05_Profile_UI_Profile_Zoom";
|
||||
@@ -106,8 +106,8 @@ export interface ProfileRenderContext {
|
||||
) => { min: number; max: number } | undefined;
|
||||
/** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */
|
||||
toolActive: () => boolean;
|
||||
/** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */
|
||||
selectedRuns: () => StraightRun[];
|
||||
/** [쉬프트]로 고른 직선 구간(빨간 강조) · [자유지정] 오버레이(215-1). */
|
||||
tools: Pick<ProfileTools, "selectedRuns" | "mountOverlay">;
|
||||
stationIdAtStructure: (structureId: string | null) => string | null;
|
||||
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
|
||||
redraw: () => void;
|
||||
@@ -368,7 +368,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
if (alignment) {
|
||||
const highlight = createRunHighlight({
|
||||
alignment,
|
||||
runs: ctx.selectedRuns(),
|
||||
runs: ctx.tools.selectedRuns(),
|
||||
x,
|
||||
axis: yAxis,
|
||||
widthPx: width,
|
||||
@@ -403,6 +403,16 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
canvas.append(chartWrap, structureLane);
|
||||
body.replaceChildren(canvas);
|
||||
body.scrollLeft = scrollLeft;
|
||||
// [자유지정] — 범위 강조 · 점선 미리 보기 · 클릭 점(215-1). 화면에 붙인 뒤라야 자리를 잰다.
|
||||
ctx.tools.mountOverlay({
|
||||
chart: chartWrap,
|
||||
scroller: body,
|
||||
x,
|
||||
toChainage,
|
||||
widthPx: width,
|
||||
heightPx: chartHeight,
|
||||
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
|
||||
});
|
||||
// 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다.
|
||||
if (table) table.style.width = `${width}px`;
|
||||
tableOverlay.setTable(table);
|
||||
|
||||
@@ -46,7 +46,7 @@ function pviChainages(alignment: ProfileAlignment): number[] {
|
||||
}
|
||||
|
||||
/** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */
|
||||
function baseElevationAt(base: AlignmentBase, chainageM: number): number {
|
||||
export function baseElevationAt(base: AlignmentBase, chainageM: number): number {
|
||||
const nodes = base.basePvi;
|
||||
if (!nodes.length) return 0;
|
||||
if (chainageM <= nodes[0].chainage_m) return nodes[0].elevation_m;
|
||||
@@ -181,7 +181,7 @@ export function straightenBetween(
|
||||
}
|
||||
|
||||
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
|
||||
function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number {
|
||||
export function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number {
|
||||
const length = base.policy.default_curve_length_m;
|
||||
if (typeof length === "number" && Number.isFinite(length) && length > 0) return length;
|
||||
return base.policy.default_curve_radius_m * Math.abs(deltaGrade);
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
* [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림.
|
||||
* 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에
|
||||
* 라운드가 생기고 양쪽이 탄젠트 직선이 된다.
|
||||
* [자유지정] → 범위를 고르고 클릭으로 계획선을 잇는다(`design_profile_free` 몫 · 215-1).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import { sameRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
|
||||
|
||||
export type ProfileToolMode = "none" | "straighten" | "shift";
|
||||
export type ProfileToolMode = "none" | "straighten" | "shift" | "free";
|
||||
|
||||
export interface ProfileToolsCallbacks {
|
||||
/** 두 측점을 직선으로 잇는다. */
|
||||
@@ -36,6 +38,12 @@ export interface ProfileToolsCallbacks {
|
||||
hasStraightRuns: () => boolean;
|
||||
/** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */
|
||||
onChanged: () => void;
|
||||
/** 모드가 바뀜 — [자유지정] 오버레이를 붙이고 뗀다. */
|
||||
onModeChange: (mode: ProfileToolMode) => void;
|
||||
/** [자유지정] 단계 안내. */
|
||||
freeHint: () => string;
|
||||
/** 그래프를 새로 그림 — [자유지정] 오버레이를 다시 붙인다. */
|
||||
mountOverlay: (geom: FreeChartGeom) => void;
|
||||
}
|
||||
|
||||
export interface ProfileTools {
|
||||
@@ -54,6 +62,8 @@ export interface ProfileTools {
|
||||
nudge: (delta: number) => boolean;
|
||||
/** 모드·선택을 모두 끈다. */
|
||||
clear: () => void;
|
||||
/** 그래프를 새로 그릴 때마다 — [자유지정] 오버레이 자리. */
|
||||
mountOverlay: (geom: FreeChartGeom) => void;
|
||||
}
|
||||
|
||||
function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
||||
@@ -78,6 +88,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
mode = "none";
|
||||
pending = null;
|
||||
runs = [];
|
||||
callbacks.onModeChange(mode);
|
||||
}
|
||||
|
||||
function setMode(next: ProfileToolMode): void {
|
||||
@@ -87,6 +98,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
mode = next;
|
||||
pending = null;
|
||||
runs = [];
|
||||
callbacks.onModeChange(mode);
|
||||
}
|
||||
callbacks.onChanged();
|
||||
}
|
||||
@@ -106,6 +118,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
nudge: step,
|
||||
selectedRuns: () => runs,
|
||||
pendingStation: () => pending,
|
||||
mountOverlay: callbacks.mountOverlay,
|
||||
clear() {
|
||||
if (mode === "none" && pending === null && !runs.length) return;
|
||||
reset();
|
||||
@@ -174,15 +187,26 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
() => setMode("shift"),
|
||||
);
|
||||
shift.classList.toggle("is-active", mode === "shift");
|
||||
const free = toolButton(
|
||||
"자유지정",
|
||||
"범위를 고른 뒤 클릭으로 계획선을 잇습니다 (Enter 끝 · Backspace 점 빼기 · Esc 취소)",
|
||||
() => setMode("free"),
|
||||
);
|
||||
free.classList.toggle("is-active", mode === "free");
|
||||
|
||||
const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1));
|
||||
const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1));
|
||||
const idle = mode === "none" || (!runs.length && mode === "shift");
|
||||
const idle = mode === "none" || mode === "free" || (!runs.length && mode === "shift");
|
||||
up.disabled = idle || (mode === "straighten" && runs.length !== 1);
|
||||
down.disabled = up.disabled;
|
||||
|
||||
wrap.append(reset, undo, redo, straighten, shift, up, down);
|
||||
if (mode === "straighten" && pending !== null) {
|
||||
wrap.append(reset, undo, redo, straighten, free, shift, up, down);
|
||||
if (mode === "free") {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = callbacks.freeHint();
|
||||
wrap.append(hint);
|
||||
} else if (mode === "straighten" && pending !== null) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`;
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { keyLetter } from "../A00_Common/key_shortcut";
|
||||
|
||||
export type Vertex = [number, number];
|
||||
|
||||
/** 되돌릴 수 있는 편집 상태 한 벌. */
|
||||
@@ -154,7 +156,7 @@ export function bindHistoryControls(params: HistoryControlsParams): {
|
||||
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const key = keyLetter(event);
|
||||
if (key === "z" && !event.shiftKey) step("undo");
|
||||
else if (key === "y" || (key === "z" && event.shiftKey)) step("redo");
|
||||
else return;
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface SelectionSyncPorts {
|
||||
selectGraph: (stationId: string | null, chainageM?: number | null) => void;
|
||||
/** 좌측 구조물 폼 선택(누가거리 기준). */
|
||||
selectSidebar: (chainageM: number | null) => void;
|
||||
/** 측점 하나 고름(측점선 · 3D 띠) → 좌측 폼 — 횡단 카드 고름과 같은 규칙(관 → 덮는 구조물 · 노면공 뺌 →
|
||||
* 없으면 폼 비움 · 216-1). 없으면 selectSidebar. */
|
||||
openAtStation?: (chainageM: number | null) => void;
|
||||
/** 배수유역도 세부유역 강조(누가거리 기준). */
|
||||
selectBasin: (chainageM: number | null) => void;
|
||||
/** 배수유역도 계획선 위 측점 마킹(누가거리 기준). 유역 없는 구조물도 위치를 보여 준다. */
|
||||
@@ -79,17 +82,18 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
|
||||
// 측점선 · 3D 띠 · 표 고름도 고름 한 칸에 — 일반 측점을 안 적어 옛 배관 고름이 이겼음(188-1)
|
||||
const at =
|
||||
stationId === null ? null : (station?.chainage_m ?? ports.regularChainage?.(stationId));
|
||||
if (at !== undefined) ports.remember?.(at);
|
||||
// 규칙 측점을 고른 것이면 구조물 폼은 건드리지 않는다(고를 항목이 없다).
|
||||
if (stationId !== null && !stationId.startsWith(prefix)) return;
|
||||
if (at === undefined) return; // 모르는 측점 — 폼 · 고름 한 칸 안 건드림
|
||||
ports.setSyncing(true);
|
||||
try {
|
||||
ports.selectSidebar(station ? station.chainage_m : null);
|
||||
// 일반 측점도 폼이 따라감 — 일반 측점이면 폼을 안 건드려 옛 폼이 남았음(B06 카드 규칙 · 216-1)
|
||||
(ports.openAtStation ?? ports.selectSidebar)(at);
|
||||
ports.selectPipeForm?.(station && isPipeStation(station) ? station.chainage_m : null);
|
||||
ports.selectStructure3D?.(station ? station.chainage_m : null);
|
||||
} finally {
|
||||
ports.setSyncing(false);
|
||||
}
|
||||
// 폼이 연 구조물 · 푼 관 알림이 적은 고름을 이 측점으로 덮음 — 마지막 고름 하나(188-1)
|
||||
ports.remember?.(at);
|
||||
},
|
||||
selectStationOfPipe(chainageM) {
|
||||
if (ports.isSyncing()) return;
|
||||
|
||||
@@ -617,8 +617,8 @@ export function createStructuresSection(
|
||||
loadPipeForm(pipe);
|
||||
},
|
||||
resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures),
|
||||
selectAt(selection) {
|
||||
const hit = resolveSelection(selection, pipeFacilities, structures);
|
||||
selectAt(selection, coverable) {
|
||||
const hit = resolveSelection(selection, pipeFacilities, structures, coverable);
|
||||
if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM);
|
||||
else this.selectById(hit.structureId);
|
||||
return hit;
|
||||
|
||||
@@ -174,8 +174,11 @@ export interface StructuresSection {
|
||||
/** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */
|
||||
resolveAt: (selection: DesignSelection) => ResolvedSelection;
|
||||
/** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다.
|
||||
* 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. */
|
||||
selectAt: (selection: DesignSelection) => ResolvedSelection;
|
||||
* 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. `coverable` = 덮어 열어도 되는 구조물(측점 고름 · 216-1). */
|
||||
selectAt: (
|
||||
selection: DesignSelection,
|
||||
coverable?: (entry: StructureInstance) => boolean,
|
||||
) => ResolvedSelection;
|
||||
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */
|
||||
addAt: (chainageM: number, typeId: string) => void;
|
||||
/** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */
|
||||
|
||||
@@ -569,7 +569,7 @@
|
||||
}
|
||||
|
||||
/* 상자 안 작은 단추 템플릿(54-41) — 높이 = 숫자 칸 [-][+](`--b05-control-h`) · 글 · 안 여백도 그 높이에 ·
|
||||
* 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 제외 */
|
||||
* 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 아래 205-1 */
|
||||
.b05-structure__group-body button:not(.b05-structure__step),
|
||||
.b05-structure__group > legend > button {
|
||||
box-sizing: border-box;
|
||||
@@ -580,6 +580,24 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄(205-1) — 단추 높이 = 상자 안 작은 단추(`--b05-control-h`) ·
|
||||
* 폼이 길어도 스크롤 영역 아래 끝에 붙어 늘 보임(자리 고정 · 뒤 내용이 비치지 않게 불투명 배경) */
|
||||
.b05-structure-section .b05-route__irregular-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-route__irregular-actions > button {
|
||||
box-sizing: border-box;
|
||||
height: var(--b05-control-h);
|
||||
min-height: 0;
|
||||
padding-block: 0;
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */
|
||||
.b05-structure__widening[hidden] {
|
||||
display: none;
|
||||
|
||||
@@ -334,6 +334,56 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* [자유지정] 오버레이 — 범위 띠 · 지나온 점선 · 마우스까지 미리 보기 · 기울기 글(215-1).
|
||||
기준 기울기를 넘는 구간은 빨강. 클릭은 그래프 래퍼가 받으므로 오버레이는 마우스를 통과시킨다. */
|
||||
.b05-profile-freemark {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__band {
|
||||
fill: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.b05-profile-freemark__edge {
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__line {
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__line.is-preview {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__line.is-over {
|
||||
stroke: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
.b05-profile-freemark__dot {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__grade {
|
||||
fill: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__grade.is-over {
|
||||
fill: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
.b05-profile-edit {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
|
||||
@@ -633,8 +633,16 @@ export interface CrossSectionPatch {
|
||||
export type CrossCellSource =
|
||||
| { src: "area"; key: string }
|
||||
| { src: "length"; start: [number, number]; end: [number, number] }
|
||||
| { src: "length" | "area"; pieces: CrossPickPiece[] }
|
||||
| { src: "text"; text: string };
|
||||
|
||||
/** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */
|
||||
export interface CrossPickPiece {
|
||||
line: "design" | "ground" | "spoil" | "structure";
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
}
|
||||
|
||||
/** [상세] 표 손질 — 칸 출처(표 키 →) · 더한 행(`after` 아래 · 없으면 끝) · 더한 열(오른쪽 끝). */
|
||||
export interface CrossDetailTable {
|
||||
cells: Record<string, CrossCellSource>;
|
||||
|
||||
@@ -78,19 +78,23 @@ REVET_FACE_SLOPE = 0.3
|
||||
# `test_b06_pipe_wall_height_mirror`.
|
||||
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
|
||||
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
|
||||
# 관 보호공 **유입** 흙막이 빈 칸 기본 = 관경(올림) + 0.7 — 유출 · 집수정은 근입 0.5 그대로(211-1).
|
||||
# ⚠ TS 짝 `common_util_culvert_sets.INLET_REVET_ADD_M` · `_Cross_Culvert_Const.INLET_REVET_ADD_M`.
|
||||
INLET_REVET_ADD_M = 0.7
|
||||
# 옛 정의 기본 높이(10-6 표 · 관경 무관) — 관경 > 1.0 관에 이 값이면 관 윗면보다 낮은 벽이라
|
||||
# 읽을 때 관경 기준 값으로 봄(139-1).
|
||||
OLD_PIPE_WALL_DEFAULT_M = 1.5
|
||||
|
||||
|
||||
def pipe_wall_min_height_m(diameter_m: float) -> float:
|
||||
def pipe_wall_min_height_m(diameter_m: float, side: str = "") -> float:
|
||||
"""관 벽 **순수 높이**(근입 포함) 기본값 = 관경(0.1m 로 올림) + 근입 0.5 — 벽 윗면 = 관 윗면(139-1).
|
||||
|
||||
관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값.
|
||||
관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값. `side="inlet"`(유입 흙막이) = + 0.7(211-1).
|
||||
"""
|
||||
raw = float(diameter_m)
|
||||
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
|
||||
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
|
||||
add = INLET_REVET_ADD_M if side == "inlet" else REVET_EMBED_DEPTH_M
|
||||
return round(steps * REVET_HEIGHT_STEP_M + add, 3)
|
||||
|
||||
|
||||
def pipe_wall_height_m(stored: float | None, diameter_m: float) -> float | None:
|
||||
@@ -211,10 +215,11 @@ def _side_spec(
|
||||
spec["basin_form"] = str(options.get("inlet_structure") or "") or None
|
||||
return spec
|
||||
|
||||
# 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 139-1) · 옛 1.5 도 관경 > 1.0 이면 그 값.
|
||||
# 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 유입 + 0.7 · 139-1 · 211-1) ·
|
||||
# 옛 1.5 도 관경 > 1.0 이면 관경 + 0.5(저장 값 해석 · 안 바뀜).
|
||||
height = pipe_wall_height_m(_number(options.get(f"{side}_revet_height_m"), None), diameter_m)
|
||||
if height is None:
|
||||
height = pipe_wall_min_height_m(diameter_m)
|
||||
height = pipe_wall_min_height_m(diameter_m, side)
|
||||
length = _number(
|
||||
options.get(f"{side}_revet_length_m"),
|
||||
None,
|
||||
|
||||
@@ -121,7 +121,11 @@ class _SectionGeometry:
|
||||
# ⚠ 옛 저장분(`ditch_choice` 가 없던 것)은 `legacy_ditch_enabled` 로 온다. 그 값은
|
||||
# **자동값과 다를 때만 뜻이 있다** — 같으면 자동이 그렇게 냈던 것이고, 다르면
|
||||
# 사용자가 일부러 바꾼 것이다. 그래서 **다를 때만** 선택으로 살린다(설계 의도 보존).
|
||||
if section_mode == "both_fill":
|
||||
# ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1) — 입력 `section_mode` 는 옛 저장값일 수
|
||||
# 있다(계획고를 올려 우 절토 → 양측 성토 · 입력은 right_cut 그대로). 입력으로 가르면 옛
|
||||
# 측구 결과가 선택으로 둔갑해 양측 성토 단면에 측구가 섰다. ⚠ TS 짝 `SectionGeometry`.
|
||||
both_fill = self.left_role == "fill" and self.right_role == "fill"
|
||||
if both_fill:
|
||||
auto_ditch = False
|
||||
elif ground_at is not None:
|
||||
ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent
|
||||
@@ -136,10 +140,10 @@ class _SectionGeometry:
|
||||
# 없는데, 그것을 선택으로 굳히면 **다시 같은 병**(지형이 바뀌어도 안 따라감)이 된다.
|
||||
if choice is not None and bool(choice) == auto_ditch:
|
||||
choice = None
|
||||
# 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다.
|
||||
self.has_ditch = auto_ditch if (choice is None or section_mode == "both_fill") else choice
|
||||
# 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움.
|
||||
self.has_ditch = auto_ditch if (choice is None or both_fill) else choice
|
||||
#: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다.
|
||||
self.ditch_choice = choice
|
||||
self.ditch_choice = None if both_fill else choice
|
||||
|
||||
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
# 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도.
|
||||
|
||||
@@ -50,7 +50,7 @@ def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None)
|
||||
key: _format(value) if isinstance(value, (int, float)) else "-"
|
||||
for key, value in table.items()
|
||||
}
|
||||
_values, texts, repick = edited_cells(design)
|
||||
_values, texts, repick = edited_cells(design, source.get("samples"))
|
||||
text.update(texts)
|
||||
return {
|
||||
"quantity_table": table,
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -28,6 +29,7 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M
|
||||
from B05_Profile.B05_Profile_Repository import (
|
||||
get_latest_route,
|
||||
get_surface_crs_epsg,
|
||||
@@ -41,10 +43,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Section"])
|
||||
|
||||
#: 「그 자리에 측점이 있다」고 볼 거리(m) — 측점 생성의 스냅 폭과 같음
|
||||
#: (`B05_Profile_Engine_Sections_Core` · 같은 횡단 이름이 되는 0.005m 안만 한 측점).
|
||||
#: 「그 자리에 측점이 있다」고 볼 거리(m) — 같은 측점 폭(213-1 · 반올림 차이 0.006m) 한 벌(219-1).
|
||||
#: 넓으면 조금 옮긴 관(769.28 → 769.38)을 「측점 있음」으로 세어 측점이 옛 자리에 남음(192-1).
|
||||
STATION_MATCH_TOLERANCE_M = 0.005
|
||||
STATION_MATCH_TOLERANCE_M = SAME_STATION_SNAP_M
|
||||
|
||||
SNAPSHOT_MISSING = (
|
||||
"지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤"
|
||||
@@ -96,9 +97,15 @@ def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, A
|
||||
if not extras:
|
||||
return []
|
||||
existing = _station_chainages(project_root)
|
||||
# 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄)
|
||||
end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:]))
|
||||
missing = []
|
||||
for chainage, label in extras:
|
||||
value = float(chainage)
|
||||
if value < -STATION_MATCH_TOLERANCE_M or (
|
||||
end_m > 0 and value > end_m + STATION_MATCH_TOLERANCE_M
|
||||
):
|
||||
continue
|
||||
if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing):
|
||||
continue
|
||||
missing.append({"chainage_m": round(value, 3), "label": label})
|
||||
|
||||
@@ -135,14 +135,24 @@ class BoxAdjustPatch(BaseModel):
|
||||
right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch)
|
||||
|
||||
|
||||
class CrossPickPiece(BaseModel):
|
||||
"""[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고)."""
|
||||
|
||||
line: Literal["design", "ground", "spoil", "structure"]
|
||||
start: list[float] = Field(..., min_length=2, max_length=2)
|
||||
end: list[float] = Field(..., min_length=2, max_length=2)
|
||||
|
||||
|
||||
class CrossCellSource(BaseModel):
|
||||
"""[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text."""
|
||||
"""[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text ·
|
||||
pieces(209-1) = 고른 선 조각 목록(length = 길이 합 · area = 닫힌 넓이)."""
|
||||
|
||||
src: Literal["area", "length", "text"]
|
||||
key: str | None = Field(default=None, max_length=40)
|
||||
start: list[float] | None = Field(default=None, min_length=2, max_length=2)
|
||||
end: list[float] | None = Field(default=None, min_length=2, max_length=2)
|
||||
text: str | None = Field(default=None, max_length=200)
|
||||
pieces: list[CrossPickPiece] | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
class CrossDetailRow(BaseModel):
|
||||
|
||||
@@ -71,6 +71,10 @@ export function pipeWallThicknessM(kind: string | null, diameterM: number): numb
|
||||
*/
|
||||
export const REVET_EMBED_DEPTH_M = 0.5;
|
||||
|
||||
/** 관 보호공 **유입** 흙막이 빈 칸 기본 순수 높이 = 관경(올림) + 0.7(211-1) — 유출은 근입 0.5 그대로.
|
||||
* ⚠ 파이썬 짝 `INLET_REVET_ADD_M` · 세트 짝 `common_util_culvert_sets.INLET_REVET_ADD_M`(거울 시험). */
|
||||
export const INLET_REVET_ADD_M = 0.7;
|
||||
|
||||
/* 기슭막이 높이는 형태와 무관하게 **사용자 입력 그대로** — 형태별 높이 한계·원지반 맞춤
|
||||
* 자동 자르기 없음. 형태가 바꾸는 것은 안 무늬뿐. 높이 기준은 **순수 높이**(벽 바닥~상단,
|
||||
* 근입 0.5m 포함). 단 하나 — 벽 윗면은 노면 위로 올라오지 않음(`buildRevetWallGeometry`). */
|
||||
@@ -138,7 +142,7 @@ export function revetWallSpec(
|
||||
// 관경 기준 높이는 기본값만 — 하한은 그림이 서는 값뿐(54-51 · 사용자가 고름).
|
||||
const preferred = hiddenPipe
|
||||
? (spec.revet_height_m ?? autoHeightM ?? REVET_EMBED_DEPTH_M)
|
||||
: (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM)); // 칸 값 → 그림(139-1)
|
||||
: (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM, spec.role)); // 칸 값 → 그림(139-1)
|
||||
const pureHeight = Math.max(adjust.h ?? preferred, WALL_MIN_PURE_HEIGHT_M);
|
||||
return {
|
||||
form,
|
||||
@@ -155,10 +159,12 @@ export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180);
|
||||
/**
|
||||
* 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이 = 근입 0.5.
|
||||
* **순수 높이 기본 = 관경 + 0.5**(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 ·
|
||||
* 10-6 표 Ø1000 = 1.5 · 139-1). 칸을 비우면 이 값.
|
||||
* 10-6 표 Ø1000 = 1.5 · 139-1 · 유입 흙막이 = 관경 + 0.7 · 211-1). 칸을 비우면 이 값.
|
||||
*/
|
||||
export function pipeWallMinPureHeight(diameterM: number): number {
|
||||
return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;
|
||||
export function pipeWallMinPureHeight(diameterM: number, side = ""): number {
|
||||
return (
|
||||
revetTargetHeight(diameterM) + (side === "inlet" ? INLET_REVET_ADD_M : REVET_EMBED_DEPTH_M)
|
||||
);
|
||||
}
|
||||
/** 벽 순수 높이 하한(m) — 0 이하는 그림이 안 섬. 관경 · 근입 하한 없음(54-51). */
|
||||
export const WALL_MIN_PURE_HEIGHT_M = 0.1;
|
||||
|
||||
@@ -388,8 +388,10 @@ export function buildDesignControls(
|
||||
groundSegment.classList.add("b06-design__seg--header");
|
||||
// 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성).
|
||||
// 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에.
|
||||
const rockCut = isRock(state.ground) && state.mode !== "both_fill";
|
||||
const hasDitch = state.mode !== "both_fill";
|
||||
// 숨김은 「막힘 + 그려진 측구 없음」일 때만(206-1) — 측구가 그려져 있으면 단추를 보여 끌 수 있게.
|
||||
const ditchDrawn = design?.ditch_enabled === true;
|
||||
const rockCut = isRock(state.ground) && (state.mode !== "both_fill" || ditchDrawn);
|
||||
const hasDitch = state.mode !== "both_fill" || ditchDrawn;
|
||||
// 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다.
|
||||
// 화면 표시는 **결과**를 보인다 — 선택이 없으면 자동으로 선 결과가 답이다.
|
||||
const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true;
|
||||
|
||||
@@ -332,6 +332,7 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
axes = drawn.axes;
|
||||
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
|
||||
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
|
||||
tablePick.redraw(); // 선 조각 고르기(209-1)
|
||||
}
|
||||
showPicked();
|
||||
},
|
||||
@@ -367,11 +368,11 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
},
|
||||
stop: () => {
|
||||
resize.disconnect();
|
||||
detachTable();
|
||||
tablePick.detach();
|
||||
},
|
||||
};
|
||||
// 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음
|
||||
const detachTable = attachDetailTable({
|
||||
const tablePick = attachDetailTable({
|
||||
tableBox,
|
||||
cad,
|
||||
section: () => state.section,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Geom.ts
|
||||
* [상세] 그래프 고르기 기하(209-1) — 선을 만나는 점에서 조각으로 나눔 · 조각 길 · 길이 · 닫힌 넓이.
|
||||
*
|
||||
* 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py`
|
||||
* (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) ·
|
||||
* 거울 시험 `resources/tester/test_209_1_detail_select.py`.
|
||||
* 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
|
||||
|
||||
export type Pt = [number, number];
|
||||
export type LineKind = CrossPickPiece["line"];
|
||||
/** 고를 수 있는 조각 — 어느 선 · 그 선 위 꺾은선(m). */
|
||||
export interface SplitPiece {
|
||||
line: LineKind;
|
||||
points: Pt[];
|
||||
}
|
||||
|
||||
/** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */
|
||||
export const LINE_TOLERANCE_M = 0.05;
|
||||
/** 다른 선 끝이 이만큼 안이면 닿음으로 보고 자름(m) — 설계선 표고 0.1 mm 반올림. */
|
||||
const TOUCH_M = 0.01;
|
||||
/** 이보다 짧은 조각은 버림(m) — 누를 수도 없음. */
|
||||
const MIN_PIECE_M = 0.02;
|
||||
|
||||
const dist = (a: Pt, b: Pt): number => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
|
||||
/* ── 서버 짝(검산 · 값) ─────────────────────────────────────────────────── */
|
||||
|
||||
/** 원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(서버 `ground_runs` · 카드 `appendGroundProfile`). */
|
||||
export function groundRuns(samples: ReadonlyArray<SectionSample> | undefined): Pt[][] {
|
||||
const runs: Pt[][] = [[]];
|
||||
for (const sample of samples ?? []) {
|
||||
const x = sample.offset_m;
|
||||
const y = sample.elevation_m ?? sample.z;
|
||||
if (sample.valid === false || typeof x !== "number" || typeof y !== "number") runs.push([]);
|
||||
else runs[runs.length - 1].push([x, y]);
|
||||
}
|
||||
return runs.filter((run) => run.length > 1);
|
||||
}
|
||||
|
||||
const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) =>
|
||||
(raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite));
|
||||
|
||||
/** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */
|
||||
export function pickLines(
|
||||
design: CrossDesign | undefined,
|
||||
samples: ReadonlyArray<SectionSample> | undefined,
|
||||
): Record<string, Pt[][]> {
|
||||
return {
|
||||
design: [polyline(design?.design_line)],
|
||||
ground: groundRuns(samples),
|
||||
spoil: [polyline(design?.spoil_fill_line)],
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 null. */
|
||||
function locate(point: Pt, run: Pt[]): number | null {
|
||||
let best: [number, number] | null = null;
|
||||
let along = 0;
|
||||
for (let i = 0; i + 1 < run.length; i++) {
|
||||
const [ax, ay] = run[i];
|
||||
const [bx, by] = run[i + 1];
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const size = Math.hypot(dx, dy);
|
||||
const raw = size === 0 ? 0 : ((point[0] - ax) * dx + (point[1] - ay) * dy) / size ** 2;
|
||||
const t = Math.max(0, Math.min(1, raw));
|
||||
const gap = Math.hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy));
|
||||
if (gap <= LINE_TOLERANCE_M && (best === null || gap < best[0])) best = [gap, along + t * size];
|
||||
along += size;
|
||||
}
|
||||
return best ? best[1] : null;
|
||||
}
|
||||
|
||||
function at(run: Pt[], s: number): Pt {
|
||||
for (let i = 0; i + 1 < run.length; i++) {
|
||||
const [ax, ay] = run[i];
|
||||
const [bx, by] = run[i + 1];
|
||||
const size = Math.hypot(bx - ax, by - ay);
|
||||
if (s <= size) {
|
||||
const t = size === 0 ? 0 : s / size;
|
||||
return [ax + t * (bx - ax), ay + t * (by - ay)];
|
||||
}
|
||||
s -= size;
|
||||
}
|
||||
return run[run.length - 1];
|
||||
}
|
||||
|
||||
/** 선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함(서버 `sub_path`). */
|
||||
export function subPath(run: Pt[], s0: number, s1: number): Pt[] {
|
||||
const lo = Math.min(s0, s1);
|
||||
const hi = Math.max(s0, s1);
|
||||
const inner: Pt[] = [];
|
||||
let along = 0;
|
||||
for (let i = 0; i + 1 < run.length; i++) {
|
||||
along += dist(run[i], run[i + 1]);
|
||||
if (lo < along && along < hi) inner.push(run[i + 1]);
|
||||
}
|
||||
const path = [at(run, lo), ...inner, at(run, hi)];
|
||||
return s0 <= s1 ? path : path.reverse();
|
||||
}
|
||||
|
||||
/** 고른 조각 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 null(구조물 선 = 두 끝 직선 · 검산 없음). */
|
||||
export function piecePath(piece: CrossPickPiece, lines: Record<string, Pt[][]>): Pt[] | null {
|
||||
if (piece.line === "structure") return [piece.start, piece.end];
|
||||
for (const run of lines[piece.line] ?? []) {
|
||||
const s0 = locate(piece.start, run);
|
||||
const s1 = locate(piece.end, run);
|
||||
if (s0 !== null && s1 !== null) return subPath(run, s0, s1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const pathLength = (path: Pt[]): number =>
|
||||
path.slice(1).reduce((sum, p, i) => sum + dist(path[i], p), 0);
|
||||
|
||||
const near = (a: Pt, b: Pt): boolean => dist(a, b) <= LINE_TOLERANCE_M;
|
||||
|
||||
/** 조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = null(서버 `ring_area`). */
|
||||
export function ringArea(paths: Pt[][]): number | null {
|
||||
if (!paths.length) return null;
|
||||
const ring = [...paths[0]];
|
||||
const rest = paths.slice(1).map((path) => [...path]);
|
||||
while (rest.length) {
|
||||
const tail = ring[ring.length - 1];
|
||||
const index = rest.findIndex((p) => near(p[0], tail) || near(p[p.length - 1], tail));
|
||||
if (index < 0) return null;
|
||||
const [path] = rest.splice(index, 1);
|
||||
ring.push(...(near(path[0], tail) ? path : path.reverse()).slice(1));
|
||||
}
|
||||
if (ring.length < 4 || !near(ring[0], ring[ring.length - 1])) return null;
|
||||
ring.push(ring[0]); // 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음
|
||||
let twice = 0;
|
||||
for (let i = 0; i + 1 < ring.length; i++)
|
||||
twice += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
|
||||
const area = Math.abs(twice) / 2;
|
||||
return area > 1e-9 ? area : null;
|
||||
}
|
||||
|
||||
/** 고른 조각들 값(서버 `cell_value` 조각 갈래) — 길이 합 · 닫힌 넓이 · 못 셈 = null. */
|
||||
export function piecesValue(
|
||||
kind: "length" | "area",
|
||||
pieces: CrossPickPiece[],
|
||||
lines: Record<string, Pt[][]>,
|
||||
): number | null {
|
||||
const paths = pieces.map((piece) => piecePath(piece, lines));
|
||||
if (!paths.length || paths.some((path) => path === null)) return null;
|
||||
const valid = paths as Pt[][];
|
||||
return kind === "length" ? valid.reduce((sum, p) => sum + pathLength(p), 0) : ringArea(valid);
|
||||
}
|
||||
|
||||
/* ── 나눔(화면 몫) ───────────────────────────────────────────────────────── */
|
||||
|
||||
const cross = (a: Pt, b: Pt): number => a[0] * b[1] - a[1] * b[0];
|
||||
const sub = (a: Pt, b: Pt): Pt => [a[0] - b[0], a[1] - b[1]];
|
||||
|
||||
/** 점이 선분 a→b 위 어디(0~1)에 닿나 — `TOUCH_M` 밖이면 null. */
|
||||
function touchAt(point: Pt, a: Pt, b: Pt): number | null {
|
||||
const d = sub(b, a);
|
||||
const size2 = d[0] ** 2 + d[1] ** 2;
|
||||
if (size2 === 0) return null;
|
||||
const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * d[0] + (point[1] - a[1]) * d[1]) / size2));
|
||||
return dist(point, [a[0] + t * d[0], a[1] + t * d[1]]) <= TOUCH_M ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선들을 서로 만나는 점(교차 · 다른 선 끝이 닿는 점)에서 조각으로 나눔.
|
||||
* 자르는 자리 = 꺾은선 위치(선분 번호 + 0~1). 같은 선끼리는 안 자름.
|
||||
* ponytail: 겹쳐 나란한 선분(같은 직선 위)은 교점 없음으로 봄 — 끝 닿음만 자름.
|
||||
*/
|
||||
export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] {
|
||||
const out: SplitPiece[] = [];
|
||||
lines.forEach((line, li) => {
|
||||
const cuts: number[] = [];
|
||||
const pts = line.points;
|
||||
for (let i = 0; i + 1 < pts.length; i++) {
|
||||
const p = pts[i];
|
||||
const r = sub(pts[i + 1], p);
|
||||
lines.forEach((other, oi) => {
|
||||
if (oi === li) return;
|
||||
const q = other.points;
|
||||
for (let j = 0; j + 1 < q.length; j++) {
|
||||
const s = sub(q[j + 1], q[j]);
|
||||
const denom = cross(r, s);
|
||||
if (Math.abs(denom) > 1e-12) {
|
||||
const t = cross(sub(q[j], p), s) / denom;
|
||||
const u = cross(sub(q[j], p), r) / denom;
|
||||
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) cuts.push(i + t);
|
||||
}
|
||||
}
|
||||
for (const end of [q[0], q[q.length - 1]]) {
|
||||
const t = touchAt(end, p, pts[i + 1]);
|
||||
if (t !== null) cuts.push(i + t);
|
||||
}
|
||||
});
|
||||
}
|
||||
const at = (c: number): Pt => {
|
||||
const i = Math.min(Math.floor(c), pts.length - 2);
|
||||
const t = c - i;
|
||||
return [
|
||||
pts[i][0] + t * (pts[i + 1][0] - pts[i][0]),
|
||||
pts[i][1] + t * (pts[i + 1][1] - pts[i][1]),
|
||||
];
|
||||
};
|
||||
const marks = [0, ...cuts.sort((a, b) => a - b), pts.length - 1];
|
||||
for (let k = 0; k + 1 < marks.length; k++) {
|
||||
const [c0, c1] = [marks[k], marks[k + 1]];
|
||||
const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1));
|
||||
const points = [at(c0), ...inner, at(c1)];
|
||||
if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */
|
||||
export function pieceSource(piece: SplitPiece): CrossPickPiece {
|
||||
const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4];
|
||||
return {
|
||||
line: piece.line,
|
||||
start: round(piece.points[0]),
|
||||
end: round(piece.points[piece.points.length - 1]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Select.ts
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로
|
||||
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
|
||||
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
|
||||
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
|
||||
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀).
|
||||
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossPickPiece } from "./B06_Section_Api_Types";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
|
||||
import {
|
||||
DESIGN_LINE_ATTR,
|
||||
mergeStraightPieces,
|
||||
pieceLabel,
|
||||
} from "./B06_Section_UI_Cross_Design_Pick";
|
||||
import {
|
||||
pathLength,
|
||||
pieceSource,
|
||||
ringArea,
|
||||
splitAtCrossings,
|
||||
type Pt,
|
||||
type SplitPiece,
|
||||
} from "./B06_Section_UI_Cross_Detail_Geom";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const PIECE_HIT_CLASS = "b06-detail__piece";
|
||||
|
||||
/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */
|
||||
export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
|
||||
return ([px, py]) => {
|
||||
const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter;
|
||||
return [
|
||||
axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter,
|
||||
axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration,
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
function mToPx(axes: CrossAxesOptions): (p: Pt) => string {
|
||||
return ([offset, elevation]) => {
|
||||
const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration;
|
||||
const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter;
|
||||
const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter;
|
||||
return `${x},${y}`;
|
||||
};
|
||||
}
|
||||
|
||||
function pointsOf(el: Element): number[][] {
|
||||
if (el.tagName.toLowerCase() === "line") {
|
||||
const n = (name: string) => Number(el.getAttribute(name));
|
||||
return [
|
||||
[n("x1"), n("y1")],
|
||||
[n("x2"), n("y2")],
|
||||
];
|
||||
}
|
||||
return (el.getAttribute("points") ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => pair.split(",").map(Number))
|
||||
.filter((p) => p.length === 2 && p.every(Number.isFinite));
|
||||
}
|
||||
|
||||
/** 그림 도형 층 요소 → 고를 조각(m) — 설계 · 구조물 선은 같은 방향 토막을 직선 하나로 합친 뒤 나눔. */
|
||||
export function collectPieces(elements: Element[], axes: CrossAxesOptions): SplitPiece[] {
|
||||
const toM = pxToM(axes);
|
||||
const runs: Parameters<typeof mergeStraightPieces>[0] = [];
|
||||
const others: SplitPiece[] = [];
|
||||
for (const el of elements) {
|
||||
const points = pointsOf(el).map(toM);
|
||||
if (points.length < 2) continue;
|
||||
if (el.classList.contains("b06-chart__design-cross"))
|
||||
runs.push({
|
||||
points: points.map(([offset, elevation]) => ({ offset, elevation })),
|
||||
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
|
||||
});
|
||||
else if (el.classList.contains("b06-chart__cross-profile"))
|
||||
others.push({ line: "ground", points });
|
||||
else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points });
|
||||
}
|
||||
const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({
|
||||
line: piece.structure ? "structure" : "design",
|
||||
points: [
|
||||
[piece.from.offset, piece.from.elevation],
|
||||
[piece.to.offset, piece.to.elevation],
|
||||
],
|
||||
}));
|
||||
return splitAtCrossings([...straight, ...others]);
|
||||
}
|
||||
|
||||
/** 고름 바꿈 — 더하기(Shift/Ctrl) = 넣고 빼기 · 아니면 그 하나(다시 누르면 비움). */
|
||||
export function toggleSelection(selected: number[], index: number, additive: boolean): number[] {
|
||||
if (additive)
|
||||
return selected.includes(index) ? selected.filter((i) => i !== index) : [...selected, index];
|
||||
return selected.length === 1 && selected[0] === index ? [] : [index];
|
||||
}
|
||||
|
||||
const LINE_NAME: Record<SplitPiece["line"], string> = {
|
||||
design: "설계선",
|
||||
structure: "구조물 선",
|
||||
ground: "원지반선",
|
||||
spoil: "사토장선",
|
||||
};
|
||||
|
||||
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
|
||||
export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string {
|
||||
if (!pieces.length) return "";
|
||||
if (pieces.length === 1) {
|
||||
const [piece] = pieces;
|
||||
const from = piece.points[0];
|
||||
const to = piece.points[piece.points.length - 1];
|
||||
if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil")
|
||||
return pieceLabel(design, {
|
||||
from: { offset: from[0], elevation: from[1] },
|
||||
to: { offset: to[0], elevation: to[1] },
|
||||
structure: piece.line === "structure",
|
||||
});
|
||||
return (
|
||||
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` +
|
||||
`수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m`
|
||||
);
|
||||
}
|
||||
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
|
||||
const area = ringArea(pieces.map((piece) => piece.points));
|
||||
return (
|
||||
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
|
||||
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`)
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 조각 → 칸 출처(209-1 저장 꼴) — 면적은 닫혀야 · 길이는 하나 이상. */
|
||||
export function selectionSource(
|
||||
kind: "area" | "length",
|
||||
pieces: SplitPiece[],
|
||||
): { src: "area" | "length"; pieces: CrossPickPiece[] } | null {
|
||||
if (!pieces.length) return null;
|
||||
if (kind === "area" && ringArea(pieces.map((piece) => piece.points)) === null) return null;
|
||||
return { src: kind, pieces: pieces.map(pieceSource) };
|
||||
}
|
||||
|
||||
/* ── 모달 배선 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DetailSelect {
|
||||
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */
|
||||
redraw: () => void;
|
||||
/** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */
|
||||
setMode: (kind: "area" | "length" | null) => void;
|
||||
source: (kind: "area" | "length") => ReturnType<typeof selectionSource>;
|
||||
clear: () => void;
|
||||
detach: () => void;
|
||||
}
|
||||
|
||||
export function attachDetailSelect(input: {
|
||||
cad: HTMLElement;
|
||||
design: () => CrossDesign | undefined;
|
||||
axes: () => CrossAxesOptions | null;
|
||||
/** 조각 누름 뒤(더하기 여부) — 표가 길이 고르기 한 번 누름이면 바로 칸에 넣음. */
|
||||
picked: (additive: boolean) => void;
|
||||
/** [칸에 넣기] 단추. */
|
||||
commit: () => void;
|
||||
}): DetailSelect {
|
||||
const { cad } = input;
|
||||
let pieces: SplitPiece[] = [];
|
||||
let hits: SVGPolylineElement[] = [];
|
||||
let selected: number[] = [];
|
||||
let mode: "area" | "length" | null = null;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "b06-cross-card__line-dims b06-detail__measure";
|
||||
const put = document.createElement("button");
|
||||
put.type = "button";
|
||||
put.className = "b06-cross-card__zoom-btn b06-detail__commit";
|
||||
put.textContent = "칸에 넣기";
|
||||
put.title = "고른 선을 칸에 넣음(Enter)";
|
||||
put.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
input.commit();
|
||||
});
|
||||
|
||||
const show = (): void => {
|
||||
const chosen = selected.map((i) => pieces[i]);
|
||||
hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i)));
|
||||
label.textContent = selectionLabel(input.design(), chosen);
|
||||
label.hidden = !chosen.length;
|
||||
put.hidden = !mode || !selectionSource(mode, chosen);
|
||||
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
|
||||
const bar = cad.querySelector(".b06-cross-card__zoom");
|
||||
if (bar && label.parentElement !== bar) bar.prepend(label, put);
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`);
|
||||
const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area";
|
||||
if (!hit) {
|
||||
if (!additive && !mode && selected.length) {
|
||||
selected = [];
|
||||
show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive);
|
||||
show();
|
||||
input.picked(additive);
|
||||
};
|
||||
cad.addEventListener("click", onClick);
|
||||
|
||||
return {
|
||||
redraw() {
|
||||
selected = [];
|
||||
const axes = input.axes();
|
||||
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
|
||||
const toPx = axes ? mToPx(axes) : null;
|
||||
hits = toPx
|
||||
? pieces.map((piece) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute("points", piece.points.map(toPx).join(" "));
|
||||
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`);
|
||||
layer?.append(hit);
|
||||
return hit;
|
||||
})
|
||||
: [];
|
||||
show();
|
||||
},
|
||||
setMode(kind) {
|
||||
mode = kind;
|
||||
show();
|
||||
},
|
||||
source: (kind) =>
|
||||
selectionSource(
|
||||
kind,
|
||||
selected.map((i) => pieces[i]),
|
||||
),
|
||||
clear() {
|
||||
selected = [];
|
||||
show();
|
||||
},
|
||||
detach() {
|
||||
cad.removeEventListener("click", onClick);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
* 값은 서버(`B07_DesignDetail_Engine_Cross_Table_Edit`)가 그릴 때마다 다시 셈 — 모달 · B07 도면 표 한 벌.
|
||||
* 조작 직후 그 칸 글만 `localCellValue` 로 즉시 — 짝(① 파이썬 · TS) · 거울 시험
|
||||
* `resources/tester/test_179_4_detail_table.py`.
|
||||
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) · 길이 = 보라 판정 선 두 끝(화면 px → m · 축 옵션).
|
||||
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) —
|
||||
* 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록.
|
||||
* 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기].
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
@@ -17,8 +19,10 @@ import type {
|
||||
CrossDetailTable,
|
||||
CrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { SectionSample } from "./B06_Section_Api_Types";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
|
||||
import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom";
|
||||
import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select";
|
||||
|
||||
/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */
|
||||
export const MAX_ROWS = 12;
|
||||
@@ -162,7 +166,6 @@ const AREA_BANDS: ReadonlyArray<[string, string]> = [
|
||||
["b06-chart__spoil-fill", "spoil_fill"],
|
||||
];
|
||||
export const AREA_PICK_SELECTOR = ".b06-chart__area, .b06-chart__spoil-fill";
|
||||
export const LENGTH_PICK_SELECTOR = ".b06-chart__design-hit";
|
||||
|
||||
export function areaSourceOf(target: Element): CrossCellSource | null {
|
||||
const hit = target.closest(AREA_PICK_SELECTOR);
|
||||
@@ -170,27 +173,6 @@ export function areaSourceOf(target: Element): CrossCellSource | null {
|
||||
return band ? { src: "area", key: band[1] } : null;
|
||||
}
|
||||
|
||||
const round4 = (value: number): number => Math.round(value * 1e4) / 1e4;
|
||||
|
||||
/** 판정 선 두 끝 → [offset, 표고](m) — 도형 층 좌표(카드 `x` · `toDisplayY` 의 역). */
|
||||
export function lengthSourceOf(target: Element, axes: CrossAxesOptions): CrossCellSource | null {
|
||||
const hit = target.closest(LENGTH_PICK_SELECTOR);
|
||||
const points = (hit?.getAttribute("points") ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => pair.split(",").map(Number));
|
||||
if (points.length < 2 || points.some((p) => p.length !== 2 || !p.every(Number.isFinite)))
|
||||
return null;
|
||||
const toM = ([px, py]: number[]): [number, number] => {
|
||||
const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter;
|
||||
return [
|
||||
round4(axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter),
|
||||
round4(axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration),
|
||||
];
|
||||
};
|
||||
return { src: "length", start: toM(points[0]), end: toM(points[points.length - 1]) };
|
||||
}
|
||||
|
||||
/* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */
|
||||
|
||||
const AREA_DESIGN: Readonly<Record<string, string>> = {
|
||||
@@ -219,9 +201,15 @@ function onLine([px, py]: [number, number], line: ReadonlyArray<[number, number]
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. */
|
||||
export function localCellValue(source: CrossCellSource, design: CrossDesign | undefined) {
|
||||
/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null.
|
||||
* `samples` = 측점 원지반(조각 검산 · 209-1). */
|
||||
export function localCellValue(
|
||||
source: CrossCellSource,
|
||||
design: CrossDesign | undefined,
|
||||
samples?: ReadonlyArray<SectionSample>,
|
||||
) {
|
||||
const record = (design ?? {}) as unknown as Record<string, unknown>;
|
||||
if ("pieces" in source) return piecesValue(source.src, source.pieces, pickLines(design, samples));
|
||||
if (source.src === "area") return finite(record[AREA_DESIGN[source.key] ?? ""]);
|
||||
if (source.src !== "length") return null;
|
||||
const line = (design?.design_line ?? [])
|
||||
@@ -292,12 +280,16 @@ export interface DetailTableHost {
|
||||
let menu: ReturnType<typeof createMapContextMenu> | null = null;
|
||||
|
||||
const PICK_HINT = {
|
||||
area: "면적 띠를 누름 · Esc 취소",
|
||||
length: "보라 선을 누름 · Esc 취소",
|
||||
area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소",
|
||||
length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소",
|
||||
} as const;
|
||||
|
||||
/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. 돌려준 함수 = 떼기(모달 닫힐 때). */
|
||||
export function attachDetailTable(host: DetailTableHost): () => void {
|
||||
/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. detach = 떼기(모달 닫힐 때) ·
|
||||
* redraw = 그림을 새로 그린 뒤(선 조각 · 판정 선 다시 · 209-1). */
|
||||
export function attachDetailTable(host: DetailTableHost): {
|
||||
detach: () => void;
|
||||
redraw: () => void;
|
||||
} {
|
||||
menu ??= createMapContextMenu("b06-detail");
|
||||
const ctxMenu = menu;
|
||||
if (!ctxMenu.element.isConnected) {
|
||||
@@ -318,9 +310,30 @@ export function attachDetailTable(host: DetailTableHost): () => void {
|
||||
picking = null;
|
||||
delete cad.dataset.pick;
|
||||
delete cad.dataset.pickHint;
|
||||
select.setMode(null);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
/** 고른 출처를 칸에 — 칸 글 즉시(짝 ①) · 세션 초안 → 서버 표 다시 받음. */
|
||||
const commit = (source: CrossCellSource | null): void => {
|
||||
if (!picking || !source) return;
|
||||
const { key } = picking;
|
||||
stopPick();
|
||||
select.clear();
|
||||
const section = host.section();
|
||||
const shown = localCellValue(source, section.design, section.samples);
|
||||
const td = tableBox.querySelector<HTMLElement>(`td[data-cell="${CSS.escape(key)}"]`);
|
||||
if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2);
|
||||
edit((table) => setCell(table, key, source));
|
||||
};
|
||||
const commitSelection = (): void => {
|
||||
if (picking) commit(select.source(picking.kind));
|
||||
};
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitSelection();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Escape") return;
|
||||
event.stopPropagation(); // 모달은 그대로 — 고르기만 취소
|
||||
stopPick();
|
||||
@@ -329,29 +342,30 @@ export function attachDetailTable(host: DetailTableHost): () => void {
|
||||
picking = { kind, key };
|
||||
cad.dataset.pick = kind;
|
||||
cad.dataset.pickHint = PICK_HINT[kind];
|
||||
select.setMode(kind);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
};
|
||||
// 선 조각 고름(209-1) — 길이 한 번 누름(더하기 아님) = 바로 칸에
|
||||
const select = attachDetailSelect({
|
||||
cad,
|
||||
design: () => host.section().design,
|
||||
axes: host.axes,
|
||||
picked: (additive) => {
|
||||
if (picking?.kind === "length" && !additive) commitSelection();
|
||||
},
|
||||
commit: commitSelection,
|
||||
});
|
||||
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음
|
||||
const onPick = (event: MouseEvent): void => {
|
||||
if (!picking) return;
|
||||
const selector = picking.kind === "area" ? AREA_PICK_SELECTOR : LENGTH_PICK_SELECTOR;
|
||||
// 겹친 요소(띠 위 판정 선 등) 아래까지 — 그 자리 맨 위 대상
|
||||
if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`))
|
||||
return;
|
||||
const hit = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.find((el) => cad.contains(el) && el.closest(selector));
|
||||
const axes = host.axes();
|
||||
const source = !hit
|
||||
? null
|
||||
: picking.kind === "area"
|
||||
? areaSourceOf(hit)
|
||||
: axes && lengthSourceOf(hit, axes);
|
||||
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
|
||||
const source = hit ? areaSourceOf(hit) : null;
|
||||
if (!source) return;
|
||||
event.stopPropagation();
|
||||
const { key } = picking;
|
||||
stopPick();
|
||||
const shown = localCellValue(source, host.section().design);
|
||||
const td = tableBox.querySelector<HTMLElement>(`td[data-cell="${CSS.escape(key)}"]`);
|
||||
if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2);
|
||||
edit((table) => setCell(table, key, source));
|
||||
commit(source);
|
||||
};
|
||||
cad.addEventListener("click", onPick);
|
||||
|
||||
@@ -401,12 +415,16 @@ export function attachDetailTable(host: DetailTableHost): () => void {
|
||||
tableBox.addEventListener("focusout", onBlur);
|
||||
tableBox.addEventListener("keydown", onEnter);
|
||||
|
||||
return () => {
|
||||
stopPick();
|
||||
ctxMenu.close();
|
||||
cad.removeEventListener("click", onPick);
|
||||
tableBox.removeEventListener("contextmenu", onMenu);
|
||||
tableBox.removeEventListener("focusout", onBlur);
|
||||
tableBox.removeEventListener("keydown", onEnter);
|
||||
return {
|
||||
detach: () => {
|
||||
stopPick();
|
||||
ctxMenu.close();
|
||||
select.detach();
|
||||
cad.removeEventListener("click", onPick);
|
||||
tableBox.removeEventListener("contextmenu", onMenu);
|
||||
tableBox.removeEventListener("focusout", onBlur);
|
||||
tableBox.removeEventListener("keydown", onEnter);
|
||||
},
|
||||
redraw: () => select.redraw(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,10 +41,13 @@ function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChip
|
||||
});
|
||||
}
|
||||
|
||||
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
|
||||
export function createFillSlopeNotice(
|
||||
onPick: (stationId: string) => void,
|
||||
selected: () => string | null,
|
||||
): FillSlopeNotice {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section__notice-bar";
|
||||
const chips = createNoticeChips(onPick);
|
||||
const chips = createNoticeChips(onPick, selected);
|
||||
root.append(chips.root);
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버.
|
||||
* 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 ·
|
||||
* 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로).
|
||||
* ⭐ 207-1 — 내용 칸은 줄 아래 카드 위에 겹쳐 뜸(흐름 밖 · CSS) · 측점 누르면 접힘 ·
|
||||
* 열 때 지금 고른 측점(`selected`) 단추 강조.
|
||||
* 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -29,7 +31,10 @@ export interface NoticeChips {
|
||||
update: (items: NoticeChipItem[]) => void;
|
||||
}
|
||||
|
||||
export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips {
|
||||
export function createNoticeChips(
|
||||
onPick: (stationId: string) => void,
|
||||
selected: () => string | null = () => null,
|
||||
): NoticeChips {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section__notice-blocks";
|
||||
const chips = document.createElement("div");
|
||||
@@ -57,12 +62,14 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh
|
||||
);
|
||||
panel.hidden = !open;
|
||||
panel.classList.toggle("is-info", open?.tone === "info");
|
||||
const picked = open ? selected() : null;
|
||||
panel.replaceChildren(
|
||||
...(open?.rows ?? []).map((row) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = row.text;
|
||||
button.dataset.stationId = row.stationId;
|
||||
button.classList.toggle("is-selected", row.stationId === picked);
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
@@ -77,7 +84,10 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh
|
||||
});
|
||||
panel.addEventListener("click", (event) => {
|
||||
const id = (event.target as HTMLElement).closest("button")?.dataset.stationId;
|
||||
if (id) onPick(id);
|
||||
if (!id) return;
|
||||
openKey = null;
|
||||
render();
|
||||
onPick(id);
|
||||
});
|
||||
attachHoverInfo(
|
||||
chips,
|
||||
|
||||
@@ -80,7 +80,9 @@ if (typeof document !== "undefined")
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
lastPointer = event.target instanceof Element ? event.target : null;
|
||||
// 새 창 종단도가 넘긴 누름은 다른 창 요소라 instanceof Element 가 거짓 — closest 로 가림(219-1)
|
||||
const target = event.target as Element | null;
|
||||
lastPointer = typeof target?.closest === "function" ? target : null;
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -90,6 +92,14 @@ export function arrowsBelongToProfile(): boolean {
|
||||
return !!lastPointer?.closest(".b05-route-profile");
|
||||
}
|
||||
|
||||
/** 조정창 없는 고름(관 · 도수로/돌붙임 띠)이 방향키를 삼키나 — 그 동안 방향키는 아무것도 안 옮김.
|
||||
* 안 삼키면 종단도 듣개가 받아 관 기준측점을 0.1 m 씩 몰래 옮겼음(219-1). 종단도를 눌렀으면 종단도 몫. */
|
||||
export function swallowsArrows(side: string | undefined, key: string): boolean {
|
||||
return (
|
||||
(side === "pipe" || side === "chute") && key.startsWith("Arrow") && !arrowsBelongToProfile()
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
|
||||
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { L } from "./B06_Section_UI_Section_Common";
|
||||
import {
|
||||
arrowsBelongToProfile,
|
||||
swallowsArrows,
|
||||
bindDragHandle,
|
||||
dragReadout,
|
||||
makeButton,
|
||||
@@ -437,6 +438,14 @@ export function buildStructurePanel(
|
||||
document.removeEventListener("keydown", onArrowKey);
|
||||
return;
|
||||
}
|
||||
// 관 · 도수로 띠 고름(조정창 없음) = 방향키 삼킴 · 보기가 보일 때만(219-1)
|
||||
if (
|
||||
swallowsArrows(root.dataset.side, event.key) &&
|
||||
root.parentElement?.getClientRects().length
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const slot = ARROW_SLOT[event.key];
|
||||
// 보기가 숨었으면(한 페이지 두 보기 · 계획서 24-3a) 다른 보기의 방향키를 먹지 않는다.
|
||||
if (!slot || root.classList.contains("is-hidden") || !root.getClientRects().length) return;
|
||||
@@ -503,7 +512,10 @@ export function buildStructurePanel(
|
||||
root.dataset.side = pick === "pipe" || pick === "chute" ? pick : "";
|
||||
document.removeEventListener("keydown", onArrowKey);
|
||||
drag.detach();
|
||||
if (!key) return;
|
||||
if (!key) {
|
||||
if (!opts?.dock && root.dataset.side) document.addEventListener("keydown", onArrowKey);
|
||||
return;
|
||||
}
|
||||
if (!opts?.dock) {
|
||||
document.addEventListener("keydown", onArrowKey);
|
||||
drag.attach();
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import {
|
||||
coversAtStation,
|
||||
coversChainage,
|
||||
NO_SELECTION,
|
||||
PIPE_MATCH_M,
|
||||
@@ -163,7 +164,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
let markTypes: StructureType[] = [];
|
||||
/** 카드 누름이 덮어 여는 구조물 — 노면공(G군)은 뺌(152-5 · 목록 · 알약 고름은 id 길로 그대로). */
|
||||
const coverable = (entry: StructureInstance): boolean =>
|
||||
markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G";
|
||||
coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group);
|
||||
/** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */
|
||||
let preferredId: string | null = null;
|
||||
/** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */
|
||||
@@ -475,7 +476,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (hit) hit.chainage_m = to;
|
||||
if (hit) {
|
||||
// 그 관 측점의 계획고 편집도 새 자리로 — 옛 키가 유령 꼭짓점으로 남지 않게(213-2)
|
||||
deps.shared.profile?.resetStationEdit(hit.chainage_m, to);
|
||||
hit.chainage_m = to;
|
||||
}
|
||||
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
|
||||
currentChainageM = to;
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
|
||||
@@ -106,6 +106,8 @@ interface StaleCandidate {
|
||||
geometry_preset?: string;
|
||||
two_stage_slope?: boolean;
|
||||
surface_drop_m?: number;
|
||||
section_mode?: string;
|
||||
ditch_enabled?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -120,6 +122,8 @@ function isStaleSection(
|
||||
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
|
||||
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
|
||||
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
|
||||
// 옛 엔진이 양측 성토에 측구를 세워 둔 저장분(206-1) — 지금 엔진으로 다시 셈.
|
||||
if (design.section_mode === "both_fill" && design.ditch_enabled === true) return true;
|
||||
if (!profiles?.length) return false;
|
||||
const planned = designElevationAt(profiles, section.chainage_m);
|
||||
if (planned === undefined) return false;
|
||||
|
||||
@@ -124,8 +124,9 @@ export function createSectionView(
|
||||
} = { balloonScope: "-" };
|
||||
|
||||
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 카드 위 · 측점 누르면 그 카드로.
|
||||
const fillSlopeNotice = createFillSlopeNotice((id) =>
|
||||
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
|
||||
const fillSlopeNotice = createFillSlopeNotice(
|
||||
(id) => (selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true)),
|
||||
() => selectedStationId,
|
||||
);
|
||||
// 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림
|
||||
const columnsField = createInputField({
|
||||
|
||||
@@ -124,7 +124,8 @@
|
||||
}
|
||||
|
||||
/* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸.
|
||||
위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게 */
|
||||
위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게.
|
||||
217-1 — 칩 줄 위 여백 16→8 · 칩 줄 ↔ 첫 카드 28→16(카드 칸 gap 24 에서 8 을 당김) */
|
||||
.b06-section__notice-bar {
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--spacing-16));
|
||||
@@ -132,8 +133,8 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: calc(-1 * var(--spacing-16));
|
||||
padding-block: var(--spacing-16) var(--spacing-4);
|
||||
margin-block: calc(-1 * var(--spacing-16)) calc(-1 * var(--spacing-8));
|
||||
padding-block: var(--spacing-8) 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@@ -172,9 +173,10 @@
|
||||
}
|
||||
|
||||
.b06-section__notice-chip {
|
||||
padding: 0 var(--spacing-8);
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--radius-sm);
|
||||
/* 둥근 사각(207-1) — 공용 단추 반경 · 알약 아님 */
|
||||
border-radius: var(--radius-buttons);
|
||||
background: transparent;
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-caption);
|
||||
@@ -193,14 +195,24 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */
|
||||
/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 칸 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게.
|
||||
207-1 — 줄 아래 카드 위에 겹쳐 뜸(흐름 밖) · 열고 닫아도 줄 높이 · 카드 자리 그대로 */
|
||||
.b06-section__notice-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
max-height: 30vh;
|
||||
overflow: auto;
|
||||
padding-top: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
@@ -213,6 +225,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 지금 고른 측점(207-1) — 카드 고름 색과 같음 */
|
||||
.b06-section__notice-list > button.is-selected {
|
||||
outline: 2px solid var(--color-danger);
|
||||
outline-offset: -1px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b06-cross-card {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
|
||||
@@ -164,11 +164,42 @@
|
||||
|
||||
/* 그래프에서 고르기 — 고를 대상만 손가락 · 왼쪽 아래(눈금 자리) 안내 글 */
|
||||
.b06-detail__cad[data-pick="area"] .b06-chart__area > polygon,
|
||||
.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill,
|
||||
.b06-detail__cad[data-pick="length"] .b06-chart__design-hit {
|
||||
.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 선 조각 판정 선(209-1) — 투명 굵은 선 · 배율 무관 굵기 · 고른 조각 = 붉은 띠 */
|
||||
.b06-detail__piece {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 10;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-detail__piece:hover {
|
||||
stroke: color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.b06-detail__piece.is-selected {
|
||||
stroke: color-mix(in srgb, var(--color-danger) 55%, transparent);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
/* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */
|
||||
.b06-detail__commit {
|
||||
width: auto;
|
||||
padding: 0 var(--spacing-4);
|
||||
color: var(--color-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b06-detail__commit[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-detail__cad[data-pick]::after {
|
||||
content: attr(data-pick-hint);
|
||||
position: absolute;
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface CadDrawing {
|
||||
stale_layout?: boolean;
|
||||
/** 횡단 장 — 지금과 다른 표 · 글 배율로 확정된 저장본(180-2). */
|
||||
stale_scale?: boolean;
|
||||
/** 횡단 장 — 측점 도면층(212-2) 앞에 확정된 저장본. [수정]을 누르면 측점 층으로 다시 만든다. */
|
||||
stale_layers?: boolean;
|
||||
layers: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침).
|
||||
칸 = 인쇄되는 표 격자선 · 블록 테두리 없음 · 칸보다 큰 단면은 `overflow_cells`(169-2).
|
||||
칸 안 = 표를 칸 아래 가운데에 먼저 · 그 위 글 · 그림(도로 중심 = 칸 가운데)(174-1).
|
||||
측점마다 도면층 하나(「횡단 No.3」) = 원지반 · 계획선 · 구조물 · 암 그림 · 종류는 색 · 선 꼴
|
||||
· 표 · 글은 수량표 층(212-2).
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -52,6 +54,10 @@ CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?")
|
||||
_BORDER_PAD_MM = CROSS_BORDER_PAD_MM
|
||||
# 칸 아래선 ~ 표 아래선 여백(mm) — 같은 행 표가 한 높이(174-1)
|
||||
CELL_TABLE_PAD_MM = 5.0
|
||||
# 측점 도면층 id 머리(212-2) — 뒤에 측점 · DXF 층 이름도 이 id
|
||||
STATION_LAYER_PREFIX = "b08-station-"
|
||||
# 측점 층으로 옮길 그림 종류 층 — 표 · 글(수량표 층)은 그대로
|
||||
_DRAWING_LAYER_IDS = {GROUND_LAYER_ID, DESIGN_LAYER_ID, STRUCTURE_LAYER_ID, ROCK_LAYER_ID}
|
||||
|
||||
|
||||
def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]:
|
||||
@@ -178,11 +184,20 @@ def _grid_lines(sheet_id: str, cols: int, rows: int) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _move_to_layer(entity: dict[str, Any], layer_id: str) -> None:
|
||||
"""그림 종류 층 개체(자식 선까지)를 측점 층으로 — id 는 그대로(212-2)."""
|
||||
if entity.get("layerId") in _DRAWING_LAYER_IDS:
|
||||
entity["layerId"] = layer_id
|
||||
for child in entity.get("children") or []:
|
||||
child["layerId"] = layer_id
|
||||
|
||||
|
||||
def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로)."""
|
||||
entities: list[dict[str, Any]] = []
|
||||
placements: list[dict[str, Any]] = []
|
||||
overflow: list[list[float]] = []
|
||||
station_layers: list[dict[str, Any]] = []
|
||||
slots = sheet.get("slots") or []
|
||||
cols, rows = sheet.get("grid") or (4, 4)
|
||||
usable_w, usable_h = usable_area()
|
||||
@@ -222,8 +237,17 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
group_id = str(uuid5(_ENTITY_NS, f"{seed_id}:group"))
|
||||
for entity in placed["entities"]:
|
||||
entity["groupId"] = group_id
|
||||
# 측점 도면층(212-2) — 층 관리에서 그 측점 그림만 고르기 · 켜고 끄기 · 잠그기
|
||||
layer_id = f"{STATION_LAYER_PREFIX}{section['chainage']}"
|
||||
title = section.get("title") or f"{section['chainage']}"
|
||||
station_layers.append(_layer(layer_id, f"횡단 {title}"))
|
||||
design_ids = [e["id"] for e in placed["entities"] if e["layerId"] == DESIGN_LAYER_ID]
|
||||
for entity in placed["entities"]:
|
||||
_move_to_layer(entity, layer_id)
|
||||
for placement in placed.get("cross_placements") or []:
|
||||
placement["group_id"] = group_id
|
||||
placement["layer_id"] = layer_id
|
||||
placement["design_ids"] = design_ids # 화면이 다시 그려 갈아 끼울 서버 설계선
|
||||
entities.extend(placed["entities"])
|
||||
placements.extend(placed.get("cross_placements") or [])
|
||||
|
||||
@@ -246,10 +270,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
# 칸보다 큰 단면의 칸 [x0, y0, x1, y1] — 라우터가 CAD 가이드 붉은 틀로 보냄(169-2)
|
||||
"overflow_cells": overflow,
|
||||
"layers": [
|
||||
_layer(GROUND_LAYER_ID, "원지반"),
|
||||
_layer(DESIGN_LAYER_ID, "계획선"),
|
||||
_layer(STRUCTURE_LAYER_ID, "구조물"),
|
||||
_layer(ROCK_LAYER_ID, "암 경계선"),
|
||||
*station_layers,
|
||||
_layer(CROSS_TABLE_LAYER_ID, "수량 산출표"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
|
||||
@@ -6,11 +6,17 @@
|
||||
area = 설계 면적 칸 그대로(`AREA_KEYS` · 띠 키 = 표 키)
|
||||
length = 두 끝(m · offset · 표고)이 설계선 위면 hypot · 아니면 비움 → 「다시 고름」
|
||||
text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만
|
||||
고른 조각(209-1 · `pieces` = [{line, start, end}]) — 교차점에서 나눈 선 조각 목록:
|
||||
length = 조각 길이 합 · area = 조각이 닫힌 고리를 이루면 그 넓이(안 닫힘 → 「다시 고름」)
|
||||
조각 길 = 그 선(설계선 · 원지반선 · 사토장선) 위 두 끝 사이 꺾은선
|
||||
(두 끝이 선 밖 → 「다시 고름」)
|
||||
· 구조물 선(`structure` · 브라우저 기하)은 위치 검산 없이 두 끝 직선.
|
||||
행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값).
|
||||
열(`columns`) = 오른쪽 끝 · 모든 행(비고 등).
|
||||
|
||||
짝(① 파이썬 · TS) = `B06_Section/B06_Section_UI_Cross_Detail_Table.ts` `localCellValue`
|
||||
(조작 중 즉시 표시) · 거울 시험 `resources/tester/test_179_4_detail_table.py`.
|
||||
(조작 중 즉시 표시) · 조각 길 · 넓이 = `B06_Section/B06_Section_UI_Cross_Detail_Geom.ts` ·
|
||||
거울 시험 `resources/tester/test_179_4_detail_table.py` · `test_209_1_detail_select.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -46,9 +52,9 @@ def _point(value: Any) -> tuple[float, float] | None:
|
||||
return (x, y) if x is not None and y is not None else None
|
||||
|
||||
|
||||
def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
def _polyline(raw: Any) -> list[tuple[float, float]]:
|
||||
points = []
|
||||
for point in design.get("design_line") or []:
|
||||
for point in raw if isinstance(raw, list) else []:
|
||||
if isinstance(point, dict):
|
||||
x, y = _num(point.get("offset_m")), _num(point.get("elevation_m"))
|
||||
if x is not None and y is not None:
|
||||
@@ -56,6 +62,24 @@ def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
return points
|
||||
|
||||
|
||||
def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
return _polyline(design.get("design_line"))
|
||||
|
||||
|
||||
def ground_runs(samples: Any) -> list[list[tuple[float, float]]]:
|
||||
"""원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(카드 `appendGroundProfile` 과 같은 규칙)."""
|
||||
runs: list[list[tuple[float, float]]] = [[]]
|
||||
for sample in samples if isinstance(samples, list) else []:
|
||||
sample = sample if isinstance(sample, dict) else {}
|
||||
x = _num(sample.get("offset_m"))
|
||||
y = _num(sample.get("elevation_m", sample.get("z")))
|
||||
if sample.get("valid") is False or x is None or y is None:
|
||||
runs.append([])
|
||||
else:
|
||||
runs[-1].append((x, y))
|
||||
return [run for run in runs if len(run) > 1]
|
||||
|
||||
|
||||
def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> bool:
|
||||
"""점이 설계선(꺾은선) 조각 하나에서 `LINE_TOLERANCE_M` 안인가."""
|
||||
px, py = point
|
||||
@@ -68,12 +92,119 @@ def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> boo
|
||||
return False
|
||||
|
||||
|
||||
Point = tuple[float, float]
|
||||
|
||||
|
||||
def _locate(point: Point, run: list[Point]) -> float | None:
|
||||
"""선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 None."""
|
||||
best: tuple[float, float] | None = None
|
||||
along = 0.0
|
||||
for (ax, ay), (bx, by) in zip(run, run[1:]):
|
||||
dx, dy = bx - ax, by - ay
|
||||
size = hypot(dx, dy)
|
||||
t = 0.0 if size == 0 else ((point[0] - ax) * dx + (point[1] - ay) * dy) / size**2
|
||||
t = max(0.0, min(1.0, t))
|
||||
gap = hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy))
|
||||
if gap <= LINE_TOLERANCE_M and (best is None or gap < best[0]):
|
||||
best = (gap, along + t * size)
|
||||
along += size
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _at(run: list[Point], s: float) -> Point:
|
||||
"""선 위 자리 s(m) 의 점."""
|
||||
for (ax, ay), (bx, by) in zip(run, run[1:]):
|
||||
size = hypot(bx - ax, by - ay)
|
||||
if s <= size:
|
||||
t = 0.0 if size == 0 else s / size
|
||||
return (ax + t * (bx - ax), ay + t * (by - ay))
|
||||
s -= size
|
||||
return run[-1]
|
||||
|
||||
|
||||
def sub_path(run: list[Point], s0: float, s1: float) -> list[Point]:
|
||||
"""선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함."""
|
||||
lo, hi = min(s0, s1), max(s0, s1)
|
||||
inner: list[Point] = []
|
||||
along = 0.0
|
||||
for a, b in zip(run, run[1:]):
|
||||
along += hypot(b[0] - a[0], b[1] - a[1])
|
||||
if lo < along < hi:
|
||||
inner.append(b)
|
||||
path = [_at(run, lo), *inner, _at(run, hi)]
|
||||
return path if s0 <= s1 else path[::-1]
|
||||
|
||||
|
||||
def piece_path(piece: Any, lines: dict[str, list[list[Point]]]) -> list[Point] | None:
|
||||
"""고른 조각 하나 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 None(구조물 선 = 두 끝 직선)."""
|
||||
if not isinstance(piece, dict):
|
||||
return None
|
||||
start, end = _point(piece.get("start")), _point(piece.get("end"))
|
||||
if not start or not end:
|
||||
return None
|
||||
if piece.get("line") == "structure":
|
||||
return [start, end]
|
||||
for run in lines.get(str(piece.get("line")), []):
|
||||
s0, s1 = _locate(start, run), _locate(end, run)
|
||||
if s0 is not None and s1 is not None:
|
||||
return sub_path(run, s0, s1)
|
||||
return None
|
||||
|
||||
|
||||
def path_length(path: list[Point]) -> float:
|
||||
return sum(hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(path, path[1:]))
|
||||
|
||||
|
||||
def _near(a: Point, b: Point) -> bool:
|
||||
return hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M
|
||||
|
||||
|
||||
def ring_area(paths: list[list[Point]]) -> float | None:
|
||||
"""조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = None."""
|
||||
if not paths:
|
||||
return None
|
||||
ring = list(paths[0])
|
||||
rest = [list(path) for path in paths[1:]]
|
||||
while rest:
|
||||
index = next(
|
||||
(i for i, p in enumerate(rest) if _near(p[0], ring[-1]) or _near(p[-1], ring[-1])),
|
||||
None,
|
||||
)
|
||||
if index is None:
|
||||
return None
|
||||
path = rest.pop(index)
|
||||
ring += (path if _near(path[0], ring[-1]) else path[::-1])[1:]
|
||||
if len(ring) < 4 or not _near(ring[0], ring[-1]):
|
||||
return None
|
||||
ring.append(ring[0]) # 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음
|
||||
area = abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(ring, ring[1:]))) / 2
|
||||
return area if area > 1e-9 else None
|
||||
|
||||
|
||||
def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]:
|
||||
"""서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선)."""
|
||||
return {
|
||||
"design": [_design_line(design)],
|
||||
"ground": ground_runs(samples),
|
||||
"spoil": [_polyline(design.get("spoil_fill_line"))],
|
||||
}
|
||||
|
||||
|
||||
def cell_value(
|
||||
source: dict[str, Any], design: dict[str, Any] | None
|
||||
source: dict[str, Any], design: dict[str, Any] | None, samples: Any = None
|
||||
) -> tuple[float | None, str | None, bool]:
|
||||
"""출처 하나 → (숫자, 글, 다시 고름)."""
|
||||
"""출처 하나 → (숫자, 글, 다시 고름). `samples` = 측점 원지반 샘플(조각 검산)."""
|
||||
design = design if isinstance(design, dict) else {}
|
||||
kind = source.get("src")
|
||||
pieces = source.get("pieces")
|
||||
if kind in ("length", "area") and isinstance(pieces, list):
|
||||
lines = pick_lines(design, samples)
|
||||
paths = [piece_path(piece, lines) for piece in pieces]
|
||||
valid = [path for path in paths if path is not None]
|
||||
if not valid or len(valid) != len(paths):
|
||||
return None, None, True
|
||||
number = sum(map(path_length, valid)) if kind == "length" else ring_area(valid)
|
||||
return number, None, number is None
|
||||
if kind == "area":
|
||||
return _num(design.get(_AREA_DESIGN.get(str(source.get("key")), ""))), None, False
|
||||
if kind == "length":
|
||||
@@ -89,9 +220,9 @@ def cell_value(
|
||||
|
||||
|
||||
def edited_cells(
|
||||
design: dict[str, Any] | None,
|
||||
design: dict[str, Any] | None, samples: Any = None
|
||||
) -> tuple[dict[str, float | None], dict[str, str], list[str]]:
|
||||
"""patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만."""
|
||||
"""patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만 · `samples` = 원지반."""
|
||||
values: dict[str, float | None] = {}
|
||||
texts: dict[str, str] = {}
|
||||
repick: list[str] = []
|
||||
@@ -99,7 +230,7 @@ def edited_cells(
|
||||
for key, source in (cells if isinstance(cells, dict) else {}).items():
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
number, text, again = cell_value(source, design)
|
||||
number, text, again = cell_value(source, design, samples)
|
||||
if text is not None:
|
||||
if key[:2] in ("r:", "c:"):
|
||||
texts[key] = text
|
||||
|
||||
@@ -18,6 +18,7 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
GROUND_LAYER_ID,
|
||||
build_cross_drawing,
|
||||
infer_station_interval,
|
||||
station_no_label,
|
||||
@@ -32,6 +33,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_hau
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
STATION_LAYER_PREFIX,
|
||||
build_cross_sheet,
|
||||
plan_cross_sheets,
|
||||
)
|
||||
@@ -313,7 +315,7 @@ def _quantity_table(
|
||||
for key in QUANTITY_VALUE_KEYS:
|
||||
table.setdefault(key, num(quantities.get(key)))
|
||||
# [상세] 표 손질(179-4) — 고른 칸 · 더한 칸을 서버가 다시 셈(모달 · B07 도면 한 벌)
|
||||
table.update(edited_cells(design)[0])
|
||||
table.update(edited_cells(design, source.get("samples"))[0])
|
||||
return table
|
||||
|
||||
|
||||
@@ -462,12 +464,22 @@ def _read_drawing(
|
||||
else:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
layers = [layer for layer in saved.get("layers") or [] if isinstance(layer, dict)]
|
||||
if kind == "cross":
|
||||
# 180-1 앞 확정본 = 원지반 층 잠금째 저장 — 열 때 풂(212-1)
|
||||
for layer in layers:
|
||||
if layer.get("id") == GROUND_LAYER_ID:
|
||||
layer["isLocked"] = False
|
||||
if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT:
|
||||
saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내
|
||||
elif kind == "cross" and drawing_id.startswith("cross_s"):
|
||||
scale = read_cross_layout(project_root)["table_scale"]
|
||||
if manifest_entry.get("table_scale", 1.0) != scale:
|
||||
saved["stale_scale"] = True # 옛 배율로 확정한 장 — 그대로 두고 안내(180-2)
|
||||
elif not any(
|
||||
str(layer.get("id")).startswith(STATION_LAYER_PREFIX) for layer in layers
|
||||
):
|
||||
saved["stale_layers"] = True # 측점 층 앞 확정본 — [수정] 때 측점 층(212-2)
|
||||
stored_table = manifest_entry.get("quantity_table")
|
||||
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
|
||||
return kind, label, saved, True, table
|
||||
|
||||
@@ -34,6 +34,16 @@ export interface CrossPlacement {
|
||||
frame?: number[];
|
||||
/** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */
|
||||
group_id?: string;
|
||||
/** 측점 도면층(212-2) — 덧그림도 그 층에. */
|
||||
layer_id?: string;
|
||||
/** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */
|
||||
design_ids?: string[];
|
||||
}
|
||||
|
||||
/** 개체(자식 선까지)를 측점 층으로(212-2). */
|
||||
function moveToLayer(entity: Entity, layerId: string): void {
|
||||
entity.layerId = layerId;
|
||||
for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId);
|
||||
}
|
||||
|
||||
/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */
|
||||
@@ -62,6 +72,7 @@ export async function appendStructureEntities(
|
||||
let added = 0;
|
||||
const fresh: Entity[] = [];
|
||||
const replaced: number[][] = [];
|
||||
const designIds = new Set<string>();
|
||||
for (const placement of placements) {
|
||||
const section = sections.find(
|
||||
(item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M,
|
||||
@@ -81,6 +92,8 @@ export async function appendStructureEntities(
|
||||
}
|
||||
if (!entities.length) continue;
|
||||
if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id;
|
||||
if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id);
|
||||
for (const id of placement.design_ids ?? []) designIds.add(id);
|
||||
// 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다.
|
||||
replaced.push(frame);
|
||||
fresh.push(...entities);
|
||||
@@ -89,7 +102,7 @@ export async function appendStructureEntities(
|
||||
if (replaced.length) {
|
||||
// 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다.
|
||||
drawing.entities = drawing.entities.filter((entity) => {
|
||||
if (entity.layerId !== DESIGN_LAYER_ID) return true;
|
||||
if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true;
|
||||
const box = entityBox(entity);
|
||||
if (!box) return true;
|
||||
return !replaced.some(
|
||||
|
||||
@@ -365,6 +365,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
"옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.",
|
||||
"info",
|
||||
);
|
||||
} else if (response.drawing.stale_layers) {
|
||||
showToast(
|
||||
"측점 도면층 앞에 확정된 장입니다. [수정]을 누르면 측점마다 층(횡단 No.)으로 다시 만듭니다.",
|
||||
"info",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
|
||||
|
||||
@@ -185,8 +185,9 @@ export interface CrossLayoutExtras {
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」 + 「표 배율 [1] [적용]」.
|
||||
* `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔.
|
||||
* 횡단 장 배치 칸(140-3 · 153-1) — 줄마다 한 일(212-3):
|
||||
* 「배치 열 [4] 행 [4]」 · 「[이 장] [모든 장]」 · 「표 배율 [1] [배율 적용]」 · (부르는 쪽이 붙이는 [안내선 숨기기]).
|
||||
* 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*`) · `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔.
|
||||
*/
|
||||
export function buildCrossLayoutControls(
|
||||
grid: [number, number],
|
||||
@@ -196,6 +197,12 @@ export function buildCrossLayoutControls(
|
||||
): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b07-cross-layout";
|
||||
const row = (...items: HTMLElement[]): HTMLElement => {
|
||||
const line = document.createElement("div");
|
||||
line.className = "b07-cross-layout__row";
|
||||
line.append(...items);
|
||||
return line;
|
||||
};
|
||||
const number = (value: number, label: string) =>
|
||||
createInputField({
|
||||
label,
|
||||
@@ -227,10 +234,11 @@ export function buildCrossLayoutControls(
|
||||
return button;
|
||||
};
|
||||
box.append(
|
||||
cols.root,
|
||||
rows.root,
|
||||
action("이 장", "이 장만 이 배치로", onApply),
|
||||
action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
|
||||
row(cols.root, rows.root),
|
||||
row(
|
||||
action("이 장", "이 장만 이 배치로", onApply),
|
||||
action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
|
||||
),
|
||||
);
|
||||
if (extras.scale) {
|
||||
const { onApply: applyScale } = extras.scale;
|
||||
@@ -255,7 +263,7 @@ export function buildCrossLayoutControls(
|
||||
},
|
||||
});
|
||||
button.title = "모든 장의 표 · 표 위 글 크기(기본 1)";
|
||||
box.append(scale.root, button);
|
||||
box.append(row(scale.root, button));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -225,8 +225,9 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 횡단 장 배치(153-1) — 횡단도 묶음 맨 아래 전체 폭: 배치 열 [4] 행 [4] [이 장] [모든 장].
|
||||
좌측이 182px 까지 줄어드니 줄바꿈 허용 · 공용 칸의 오류 자리는 접음. */
|
||||
/* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일:
|
||||
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기].
|
||||
단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */
|
||||
.b07-cross-layout-slot {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -237,25 +238,45 @@
|
||||
|
||||
.b07-cross-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-cross-layout > .ui-field {
|
||||
.b07-cross-layout__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-cross-layout__row > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-btn,
|
||||
.b07-cross-layout .ui-field .ui-input {
|
||||
height: auto;
|
||||
padding: var(--btn-bar-padding);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field__label {
|
||||
margin: 0;
|
||||
font-size: var(--btn-bar-font-size);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-input {
|
||||
width: 48px;
|
||||
.b07-cross-layout .ui-field .ui-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field__error {
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) */
|
||||
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */
|
||||
import type { FC } from 'react';
|
||||
import { runCommandInput } from '../commands/run-command';
|
||||
import { getScreenCanvasDrawController } from '../state';
|
||||
import {
|
||||
getScreenCanvasDrawController,
|
||||
isDrawingReadOnly,
|
||||
isHostSave,
|
||||
isViewOnly,
|
||||
} from '../state';
|
||||
|
||||
export const ViewControls: FC = () => {
|
||||
const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1;
|
||||
// 제목줄(빠른 막대)이 없는 M02 양식 · 구조물 카드 — 고칠 수 있을 때만
|
||||
const showUndo = isHostSave() && !isViewOnly() && !isDrawingReadOnly();
|
||||
|
||||
return (
|
||||
<div className="cad-view-controls controls">
|
||||
{showUndo && (
|
||||
<>
|
||||
<button type="button" onClick={() => runCommandInput('UNDO')} title="실행 취소 (Ctrl+Z)">
|
||||
↶
|
||||
</button>
|
||||
<button type="button" onClick={() => runCommandInput('REDO')} title="다시 실행 (Ctrl+Y)">
|
||||
↷
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cad-view-controls__fit"
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
isPlainSpace,
|
||||
} from './ime-sink.ts';
|
||||
import { drawInputField } from './input-field-draw.ts';
|
||||
import { ctrlShortcut } from './input-shortcut.ts';
|
||||
import { wheelZoom } from './input-wheel.ts';
|
||||
import {
|
||||
ABSOLUTE_POINT_REGEXP,
|
||||
@@ -339,7 +340,9 @@ export class InputController {
|
||||
if (target !== this.ime.element && target?.closest('input, textarea, select')) {
|
||||
return;
|
||||
}
|
||||
if (isImeKey(evt)) return; // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김
|
||||
const shortcut = ctrlShortcut(evt);
|
||||
// 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(Ctrl 단축키는 조합 중에도 먹음)
|
||||
if (isImeKey(evt) && !shortcut) return;
|
||||
console.log(`key pressed: ${evt.key}`);
|
||||
if (evt.key === 'F12') {
|
||||
// F12 => open developer tools
|
||||
@@ -386,19 +389,16 @@ export class InputController {
|
||||
}
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
|
||||
if (shortcut === 'undo') {
|
||||
// User wants to undo the last action
|
||||
this.handleUndo(evt);
|
||||
} else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') {
|
||||
} else if (shortcut === 'redo') {
|
||||
// User wants to redo the last action
|
||||
this.handleRedo(evt);
|
||||
} else if (evt.ctrlKey && evt.key === 'y') {
|
||||
// User wants to redo the last action
|
||||
this.handleRedo(evt);
|
||||
} else if (evt.ctrlKey && evt.key === 's') {
|
||||
} else if (shortcut === 'save') {
|
||||
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
|
||||
if (isHostSave()) requestHostSave();
|
||||
} else if (evt.ctrlKey && evt.key === 'a') {
|
||||
} else if (shortcut === 'selectAll') {
|
||||
// User wants to select everything
|
||||
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
|
||||
} else if (evt.key === 'Backspace') {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Ctrl 단축키 판정 — 공용 `A00_Common/key_shortcut.ts`(214-2)를 그대로 내보냄.
|
||||
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞아 자판 자리(`code`)로 본다.
|
||||
*/
|
||||
export {
|
||||
ctrlShortcut,
|
||||
isCtrlKey,
|
||||
keyLetter,
|
||||
type CtrlShortcut,
|
||||
} from '../../../../A00_Common/key_shortcut';
|
||||
@@ -25,6 +25,6 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "../../A00_Common/key_shortcut.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -187,11 +187,13 @@ def _revets(
|
||||
sides: list[tuple[str, str | None]],
|
||||
empty_height: float | None,
|
||||
names: dict[str, str],
|
||||
inlet_empty_height: float | None = None,
|
||||
) -> None:
|
||||
"""흙막이 — 유입 · 유출(흙막이일 때 · `sides`) + 흙막이 n(container_rows 가 선 단까지만 냄).
|
||||
|
||||
저장 키 = 필수는 `{쪽}_revet_{칸}_m` · 선택 흙막이는 `soil_add_{n}_{칸}`(명세 인스턴스 키) ·
|
||||
빈 높이 = `empty_height`(None 이면 「미입력」 열) · 머리 글은 관 정의 이름(공용 열).
|
||||
빈 높이 = `empty_height`(None 이면 「미입력」 열 · 관 유입 쪽만 `inlet_empty_height` · 211-1) ·
|
||||
머리 글은 관 정의 이름(공용 열).
|
||||
"""
|
||||
sources = [(f"{side}_revet_", "_m", side, combo) for side, combo in sides]
|
||||
sources += [
|
||||
@@ -203,7 +205,9 @@ def _revets(
|
||||
form = str(options.get(f"{head}form") or "")
|
||||
height = _num(options.get(f"{head}height{unit}"))
|
||||
if height is None:
|
||||
height = empty_height
|
||||
height = (
|
||||
inlet_empty_height if container == "inlet" and inlet_empty_height else empty_height
|
||||
)
|
||||
height_text = fill._label("height_m", height)
|
||||
sheet.add(
|
||||
at,
|
||||
@@ -588,7 +592,8 @@ def pipe_summary(
|
||||
)
|
||||
wall_options = {**options, **moved}
|
||||
wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None
|
||||
_revets(sheet, at, wall_options, links, sources, wall, names)
|
||||
wall_in = pipe_wall_min_height_m(diameter / 1000, "inlet") if diameter else None
|
||||
_revets(sheet, at, wall_options, links, sources, wall, names, wall_in)
|
||||
_chutes(sheet, at, options, links, names)
|
||||
|
||||
out = sheet.result()
|
||||
|
||||
@@ -152,13 +152,13 @@ export function note(text: string): HTMLElement {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 표 한 장 — 공용 컨테이너(카드) 안에 설명 · 공용 표 · 아래 줄 */
|
||||
/** 표 한 장 — 공용 컨테이너(카드) 안에 공용 표 · 아래 줄 · 맨 아래 안내 · 경고 줄(202-1) */
|
||||
export function tableCard(
|
||||
info: HTMLElement[],
|
||||
table: HTMLElement,
|
||||
after: HTMLElement[] = [],
|
||||
): HTMLElement {
|
||||
return createCard({ body: [...info, table, ...after] });
|
||||
return createCard({ body: [table, ...after, ...info] });
|
||||
}
|
||||
|
||||
function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow {
|
||||
|
||||
@@ -40,6 +40,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
|
||||
note: row.note || " ",
|
||||
})),
|
||||
frozen: 1,
|
||||
mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침)
|
||||
caption: "토공집계표",
|
||||
fillViewport: true,
|
||||
});
|
||||
|
||||
@@ -109,6 +109,8 @@ interface Table {
|
||||
editable: (index: number, key: string) => boolean;
|
||||
/** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */
|
||||
target: (index: number, key: string) => [Record<string, Cells> | Cells, string, string | null];
|
||||
/** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
|
||||
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
@@ -218,10 +220,10 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string {
|
||||
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows, total } = s.concrete;
|
||||
const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침
|
||||
const rowsOut: DataRow[] = keep.map((at, i) => {
|
||||
const rowsOut: DataRow[] = keep.map((at) => {
|
||||
const row = rows[at];
|
||||
const out: DataRow = {
|
||||
name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name,
|
||||
name: row.name, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
qty: row.qty,
|
||||
};
|
||||
@@ -256,6 +258,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
rows: rowsOut,
|
||||
total: totalRow,
|
||||
frozen: 2,
|
||||
mergeSame: ["name"],
|
||||
editable: (_i, key) => key.startsWith("per:"),
|
||||
target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)],
|
||||
};
|
||||
@@ -275,10 +278,10 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
numberColumn({ key: "amount", label: "합계", width: 96 }),
|
||||
{ key: "note", label: "비고", width: 96 },
|
||||
],
|
||||
rows: keep.map((at, i) => {
|
||||
rows: keep.map((at) => {
|
||||
const row = rows[at];
|
||||
return {
|
||||
group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group,
|
||||
group: row.group, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: row.qty,
|
||||
@@ -295,6 +298,7 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
}),
|
||||
total: null,
|
||||
frozen: 2,
|
||||
mergeSame: ["group"],
|
||||
editable: (i, key) =>
|
||||
key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY,
|
||||
target: (i) => [o.surcharge, rows[keep[i]].key, null],
|
||||
@@ -356,7 +360,7 @@ export async function renderMaterialSheet(
|
||||
const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
|
||||
concreteNote.dataset.b08MaterialConcreteWhy = "";
|
||||
if (sheet === "concrete") notes.append(concreteNote);
|
||||
toolbar.append(notes, save);
|
||||
toolbar.append(save);
|
||||
|
||||
let built = BUILD[sheet](sheets, overrides);
|
||||
const table = createDataTable({
|
||||
@@ -364,6 +368,7 @@ export async function renderMaterialSheet(
|
||||
rows: built.rows,
|
||||
total: built.total,
|
||||
frozen: built.frozen,
|
||||
mergeSame: built.mergeSame,
|
||||
empty: "",
|
||||
caption: BASIS,
|
||||
fillViewport: true,
|
||||
@@ -395,7 +400,7 @@ export async function renderMaterialSheet(
|
||||
concreteNote.hidden = !concreteNote.textContent;
|
||||
};
|
||||
redraw();
|
||||
host.replaceChildren(createCard({ body: [toolbar, table.root, excel] }));
|
||||
host.replaceChildren(createCard({ body: [toolbar, table.root, excel, notes] })); // 202-1 안내 · 경고 = 맨 아래
|
||||
onOptionsChange(host, () => {
|
||||
sheets = calc();
|
||||
redraw();
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
.b08-material__toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-16);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -281,8 +281,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
)
|
||||
: note(L("B08_Quantity_Summary_NoStation"));
|
||||
let table = draw();
|
||||
parts.push(table);
|
||||
host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3)
|
||||
host.replaceChildren(createCard({ body: [table, ...parts] })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3) · 안내 · 경고 = 표 아래(202-1)
|
||||
onOptionsChange(host, () => {
|
||||
const next = draw();
|
||||
table.replaceWith(next);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""B09 설계내역서 연결 — 서고 구조물조합 · 일위대가 사전조합 → 프로젝트 사본(PLAN 204-2).
|
||||
|
||||
자리 = 프로젝트 `B09_Estimation/v1/library/{폴더}/` · 폴더 = `{종류}@{층}@{주인}@{키}`
|
||||
(주인 `/` → `-` · 마스터 `-`) — 같은 것을 다시 고르면 새로 안 들여오고 있는 사본(같은 호표):
|
||||
combo 구조물조합 = B08 구조물 사본과 한 꼴(`M02_Item_Snapshot.build_snapshot` — 집계 ·
|
||||
도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값)
|
||||
precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} ·
|
||||
`단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌)
|
||||
사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) ·
|
||||
대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) ·
|
||||
단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} ·
|
||||
[저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Store import folder as store_folder
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
LABOR_FILE,
|
||||
PRICE_FILE,
|
||||
item_name,
|
||||
labor_doc_rows,
|
||||
price_bundle,
|
||||
read_json,
|
||||
)
|
||||
|
||||
LIBRARY = "library"
|
||||
KINDS = ("combo", "precombo")
|
||||
#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`)
|
||||
NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$")
|
||||
|
||||
|
||||
def library_dir(project_root: str | Path) -> Path:
|
||||
return store_folder(project_root) / LIBRARY
|
||||
|
||||
|
||||
def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str:
|
||||
"""고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError."""
|
||||
name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}"
|
||||
if not NAME.match(name):
|
||||
raise ValueError(f"서고 항목 모양이 틀림 「{name}」")
|
||||
return name
|
||||
|
||||
|
||||
def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
|
||||
ref = {"층": layer, "주인": owner, "키": key}
|
||||
doc = refs.read_ref(ref)
|
||||
if doc is None or doc.get("종류") != refs.TITLE["precombo"]:
|
||||
raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음")
|
||||
line = {"참조": ref, "수량": {"손": 1}}
|
||||
labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]})
|
||||
return {
|
||||
LABOR_FILE: labor,
|
||||
PRICE_FILE: price_bundle(labor, refs.folder_of(layer, owner)),
|
||||
"_원본": {"참조": ref, "name": doc.get("이름") or key, "단위": doc.get("단위") or ""},
|
||||
}
|
||||
|
||||
|
||||
def import_copy(
|
||||
project_root: str | Path,
|
||||
kind: str,
|
||||
layer: str,
|
||||
owner: str | None,
|
||||
key: str,
|
||||
who: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) ·
|
||||
원본이 없으면 LookupError · 모양이 틀리면 ValueError."""
|
||||
if kind not in KINDS:
|
||||
raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나")
|
||||
name = folder_name(kind, layer, owner, key)
|
||||
target = library_dir(project_root) / name
|
||||
if isinstance(read_json(target / MANIFEST_NAME), dict):
|
||||
return {"폴더": name, "만듦": False}
|
||||
if kind == "combo":
|
||||
item = {"layer": layer, "owner": owner, "key": key}
|
||||
item["name"] = item_name(item)
|
||||
files, origin = build_snapshot(item), origin_of(item)
|
||||
else:
|
||||
files = _precombo_files(layer, owner, key)
|
||||
origin = files.pop("_원본")
|
||||
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
|
||||
shutil.rmtree(target)
|
||||
target.mkdir(parents=True)
|
||||
for file, content in files.items():
|
||||
atomic_write_json(target / file, content)
|
||||
stamp = now_text()
|
||||
manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who}
|
||||
atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더
|
||||
return {"폴더": name, "만듦": True}
|
||||
|
||||
|
||||
def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
|
||||
"""다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례."""
|
||||
base = library_dir(project_root)
|
||||
out = []
|
||||
for child in sorted(base.iterdir()) if base.is_dir() else []:
|
||||
manifest = read_json(child / MANIFEST_NAME)
|
||||
if child.is_dir() and NAME.match(child.name) and isinstance(manifest, dict):
|
||||
out.append((child, manifest))
|
||||
return out
|
||||
|
||||
|
||||
def used_folders(user: dict[str, Any]) -> set[str]:
|
||||
"""설계내역서 덮개 줄이 잇는 서고 사본 폴더."""
|
||||
rows = ((user or {}).get("bill") or {}).get("rows") or []
|
||||
return {
|
||||
str(ref["lib"])
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and isinstance(ref := row.get("ref"), dict)
|
||||
and ref.get("tab") == "dg"
|
||||
and ref.get("lib")
|
||||
}
|
||||
|
||||
|
||||
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
|
||||
"""[저장] 뒤 — 줄이 안 잇는 사본 폴더를 지움(다시 고르면 새로 들여옴) · 지운 이름."""
|
||||
keep = used_folders(user)
|
||||
base = library_dir(project_root)
|
||||
gone = []
|
||||
for child in sorted(base.iterdir()) if base.is_dir() else []:
|
||||
if child.is_dir() and NAME.match(child.name) and child.name not in keep:
|
||||
shutil.rmtree(child)
|
||||
gone.append(child.name)
|
||||
return gone
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) ·
|
||||
`test_184_2_b09_bill_free.py`(자유 편집).
|
||||
`test_184_2_b09_bill_free.py`(자유 편집) · `test_204_1_b09_bill_drag.py`(묶음 자동).
|
||||
|
||||
실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔):
|
||||
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
|
||||
@@ -33,7 +33,9 @@
|
||||
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) =
|
||||
기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) ·
|
||||
원천이 안 낸 키 = 안 보임 ·
|
||||
부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
|
||||
부모 못 찾음 · 자재 줄(`mat:`) · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
|
||||
항목 줄에 자식이 생김 = 저절로 묶음(204 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음 —
|
||||
자식이 다 빠지면 다시 항목) ·
|
||||
관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래.
|
||||
옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` ·
|
||||
`after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2).
|
||||
@@ -331,7 +333,7 @@ def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tup
|
||||
|
||||
|
||||
def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
|
||||
"""부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
|
||||
관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section
|
||||
(깊이 5 넘음 = 레벨 4 조상 아래)."""
|
||||
by_key = {row["key"]: row for row in order}
|
||||
@@ -353,7 +355,7 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
up = by_key.get(row.get("parent") or "")
|
||||
if row["key"] == "sec:G":
|
||||
row["parent"] = None
|
||||
elif up is None or up["kind"] == "item" or up is row:
|
||||
elif up is None or up["key"].startswith("mat:") or up is row:
|
||||
row["parent"] = row["_home"] if row["_home"] in by_key else None
|
||||
for row in order:
|
||||
if loops(row):
|
||||
@@ -383,6 +385,11 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
row["section"] = chain[-1]["section"] if chain else None
|
||||
rows.append(row)
|
||||
stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], [])))
|
||||
parents = {row["parent"] for row in rows}
|
||||
for row in rows: # 자식이 생긴 항목 = 저절로 묶음(204)
|
||||
if row["kind"] == "item" and row["key"] in parents:
|
||||
row.update({"kind": "group", "qty": None, "ref": None, "_gaps": []})
|
||||
row.update({f"price_{p}": None for p in (*PARTS, "sum")})
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집).
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집) ·
|
||||
* `test_204_1_b09_bill_drag.py`(묶음 자동).
|
||||
*
|
||||
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||
@@ -304,7 +305,8 @@ function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
|
||||
/** 부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
|
||||
* 자식이 생긴 항목 = 저절로 묶음(204 · 자식이 다 빠지면 다시 항목) →
|
||||
* 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */
|
||||
function tree(order: Row[]): Row[] {
|
||||
const byKey = new Map<string, Row>();
|
||||
@@ -325,7 +327,7 @@ function tree(order: Row[]): Row[] {
|
||||
for (const row of order) {
|
||||
const up = byKey.get(row.parent ?? "");
|
||||
if (row.key === "sec:G") row.parent = null;
|
||||
else if (!up || up.kind === "item" || up === row) row.parent = home(row);
|
||||
else if (!up || up.key.startsWith("mat:") || up === row) row.parent = home(row);
|
||||
}
|
||||
for (const row of order) {
|
||||
if (!loops(row)) continue;
|
||||
@@ -354,6 +356,13 @@ function tree(order: Row[]): Row[] {
|
||||
rows.push(row);
|
||||
for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]);
|
||||
}
|
||||
const parents = new Set(rows.map((row) => row.parent));
|
||||
for (const row of rows) {
|
||||
// 자식이 생긴 항목 = 저절로 묶음(204)
|
||||
if (row.kind !== "item" || !parents.has(row.key)) continue;
|
||||
Object.assign(row, { kind: "group", qty: null, ref: null, _gaps: [] });
|
||||
for (const p of [...PARTS, "sum"]) row[`price_${p}`] = null;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sg
|
||||
from B09_Estimation import B09_Estimation_Sangeun_Text as st
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
@@ -282,6 +283,19 @@ class Builder:
|
||||
}
|
||||
)
|
||||
|
||||
def library(self, folder: Path, manifest: dict[str, Any]) -> str | None:
|
||||
"""설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로."""
|
||||
if manifest.get("종류") != "precombo":
|
||||
return self.combo(folder)
|
||||
labor = read_json(folder / LABOR_FILE)
|
||||
ref = (manifest.get("원본") or {}).get("참조")
|
||||
copies = (labor or {}).get("참조사본") or {}
|
||||
doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None
|
||||
if not isinstance(doc, dict):
|
||||
return None
|
||||
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||||
return self.precombo(whole, ref, doc, copies, 1)
|
||||
|
||||
|
||||
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
|
||||
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
|
||||
@@ -327,9 +341,11 @@ def _plain(value: Any) -> Any:
|
||||
|
||||
|
||||
def daega_inputs(project_root: str | Path) -> dict[str, Any]:
|
||||
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것.
|
||||
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 ·
|
||||
설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}.
|
||||
|
||||
차례 = 조합(구조물 id 차례) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례.
|
||||
차례 = 조합(구조물 id 차례 → 서고 사본) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) —
|
||||
호표 번호 = 이 차례.
|
||||
"""
|
||||
base = structures_dir(project_root)
|
||||
build = Builder()
|
||||
@@ -343,6 +359,15 @@ def daega_inputs(project_root: str | Path) -> dict[str, Any]:
|
||||
bid = None # 한 사본이 막혀도 나머지 블록은 셈 — 그 구조물은 없음
|
||||
if bid:
|
||||
owners[owner] = bid
|
||||
libs: dict[str, str] = {}
|
||||
for folder, manifest in library.copies(project_root):
|
||||
try:
|
||||
bid = build.library(folder, manifest)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
bid = None
|
||||
if bid:
|
||||
libs[folder.name] = bid
|
||||
order = {"조합": 0, "사전조합": 1, "품셈": 2}
|
||||
blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]])
|
||||
return _plain({"blocks": blocks, "owners": owners})
|
||||
out = {"blocks": blocks, "owners": owners}
|
||||
return _plain({**out, "libs": libs} if libs else out)
|
||||
|
||||
@@ -67,13 +67,19 @@ def used_keys(folder: Path) -> list[str]:
|
||||
|
||||
|
||||
def linked_resource_keys(project_root: str | Path) -> list[str]:
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 · 겹침 없음."""
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) ·
|
||||
겹침 없음."""
|
||||
from B09_Estimation.B09_Estimation_Bill_Library import copies
|
||||
|
||||
base = structures_dir(project_root)
|
||||
folders = [
|
||||
base / linked["폴더"]
|
||||
for _owner, linked in sorted(linked_snapshots(project_root).items())
|
||||
if "일위대가" in linked.get("부분", [])
|
||||
]
|
||||
out: list[str] = []
|
||||
for _owner, linked in sorted(linked_snapshots(project_root).items()):
|
||||
if "일위대가" not in linked.get("부분", []):
|
||||
continue
|
||||
for key in used_keys(base / linked["폴더"]):
|
||||
for folder in [*folders, *(f for f, _ in copies(project_root))]:
|
||||
for key in used_keys(folder):
|
||||
if key not in out:
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
@@ -197,6 +198,23 @@ def refresh_book(keys: list[str]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None:
|
||||
"""203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None)."""
|
||||
if book is None:
|
||||
return "단가 아직 안 굳힘"
|
||||
fresh = json.loads(json.dumps(refresh_book(keys), ensure_ascii=False)) # 저장 꼴(JSON)로 견줌
|
||||
rows = book.get("rows") or {}
|
||||
new = sum(key not in rows for key in fresh["rows"])
|
||||
changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items())
|
||||
parts = [
|
||||
f"새 단가 줄 {new}" if new else "",
|
||||
f"M01 에서 바뀐 단가 {changed}" if changed else "",
|
||||
"운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "",
|
||||
"환율 바뀜" if book.get("fx") != fresh["fx"] else "",
|
||||
]
|
||||
return " · ".join(p for p in parts if p) or None
|
||||
|
||||
|
||||
def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]:
|
||||
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘."""
|
||||
if book is None:
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
[저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈
|
||||
POST /api/projects/{id}/estimation/prices
|
||||
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘
|
||||
POST /api/projects/{id}/estimation/library {kind, layer, owner, key}
|
||||
설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로
|
||||
(있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} ·
|
||||
층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`)
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
단가 = `B09_Estimation_Prices` ·
|
||||
@@ -20,11 +24,13 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
@@ -38,6 +44,9 @@ from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table
|
||||
from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router
|
||||
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import importer
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
|
||||
@@ -121,6 +130,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
"inputs": inputs,
|
||||
"user": user,
|
||||
"book": {"made": book.get("made")} if book else None,
|
||||
# 203-1 [업데이트] 배지 까닭(지금 M01 과 다른 것 한 줄 · 같으면 None)
|
||||
"stale": await asyncio.to_thread(prices.book_stale, book, keys),
|
||||
"sheets": sheets,
|
||||
"rate_candidates": rate_candidates(),
|
||||
"rate_table": rate_table(),
|
||||
@@ -139,7 +150,8 @@ async def estimation(project_id: UUID) -> dict[str, Any]:
|
||||
@router.put("/{project_id}/estimation")
|
||||
async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
user = await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본
|
||||
keys = await _demand(root)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.extend_book, book, keys)
|
||||
@@ -156,5 +168,31 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]:
|
||||
return await payload(project_id, root)
|
||||
|
||||
|
||||
class LibraryBody(BaseModel):
|
||||
kind: str
|
||||
layer: str
|
||||
owner: str | None = None
|
||||
key: str
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/library")
|
||||
async def import_library(
|
||||
project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
got = access.grant(session, body.layer, body.owner) # 못 읽는 층 = 403 · 회사 없음 = 409
|
||||
root = await _project_root(project_id)
|
||||
try:
|
||||
made = await asyncio.to_thread(
|
||||
library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session)
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
out = await payload(project_id, root)
|
||||
key = ((out["inputs"].get("daega") or {}).get("libs") or {}).get(made["폴더"])
|
||||
return {**made, "key": key, "payload": out}
|
||||
|
||||
|
||||
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
|
||||
router.include_router(quotes_router)
|
||||
|
||||
@@ -95,12 +95,14 @@ def _text(value: Any) -> str:
|
||||
|
||||
|
||||
def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
||||
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름)."""
|
||||
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) ·
|
||||
dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움)."""
|
||||
if not isinstance(ref, dict):
|
||||
return None
|
||||
tab = ref.get("tab")
|
||||
if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]:
|
||||
return {"tab": tab, "key": ref["key"]}
|
||||
lib = ref.get("lib") if tab == "dg" else None
|
||||
return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})}
|
||||
if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]:
|
||||
given = ref.get("given") if isinstance(ref.get("given"), dict) else {}
|
||||
picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Check.ts
|
||||
* B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용).
|
||||
*
|
||||
* [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
|
||||
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
|
||||
* 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
export interface CheckItem {
|
||||
link: Link;
|
||||
tab: string;
|
||||
name: string;
|
||||
why: string;
|
||||
}
|
||||
|
||||
/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */
|
||||
export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } {
|
||||
const gaps: Row[] = sheets.gaps || [];
|
||||
const items = gaps.map((g) => ({
|
||||
link: { tab: g.tab as TabId, key: g.key },
|
||||
tab: tabName(g.tab),
|
||||
name: String(g.name ?? ""),
|
||||
why: String(g.why ?? ""),
|
||||
}));
|
||||
const notes: string[] = [...(payload.notes || [])];
|
||||
const missing: string[] = payload.inputs?.missing || [];
|
||||
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
|
||||
return { items, notes };
|
||||
}
|
||||
|
||||
/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */
|
||||
export function refreshTip(payload: Row | null): string {
|
||||
const made = payload?.book?.made;
|
||||
const when = made ? `단가 굳힘 ${String(made).replace("T", " ")}` : "단가 아직 안 굳힘";
|
||||
const stale = payload?.stale;
|
||||
return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
|
||||
}
|
||||
|
||||
export interface CheckPanel {
|
||||
/** 묶음 안 연결 점검 자리 */
|
||||
list: HTMLElement;
|
||||
draw: (payload: Row, sheets: Row) => void;
|
||||
}
|
||||
|
||||
export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => void): CheckPanel {
|
||||
let payload: Row | null = null;
|
||||
let items: CheckItem[] = [];
|
||||
attachHoverInfo(refresh, () => refreshTip(payload));
|
||||
const list = document.createElement("div");
|
||||
list.className = "b09-check";
|
||||
attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]");
|
||||
|
||||
const draw = (next: Row, sheets: Row): void => {
|
||||
payload = next;
|
||||
refresh.classList.toggle("is-pending", Boolean(next.stale));
|
||||
const made = checkItems(sheets, next);
|
||||
items = made.items;
|
||||
const head = document.createElement("p");
|
||||
head.className = "b09-check__head";
|
||||
head.textContent = `연결 없음 ${items.length}곳`;
|
||||
const rows = document.createElement("div");
|
||||
rows.className = "ui-sidebar-list";
|
||||
rows.append(
|
||||
...items.map((item, i) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "ui-sidebar-row";
|
||||
row.dataset.i = String(i);
|
||||
const name = document.createElement("span");
|
||||
name.textContent = item.name;
|
||||
const tab = document.createElement("span");
|
||||
tab.className = "b09-check__tab";
|
||||
tab.textContent = item.tab;
|
||||
row.append(name, tab);
|
||||
row.addEventListener("click", () => goto(item.link));
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
const notes = made.notes.map((text) => {
|
||||
const p = document.createElement("p");
|
||||
p.className = "b09-check__note";
|
||||
p.textContent = text;
|
||||
return p;
|
||||
});
|
||||
list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes);
|
||||
};
|
||||
return { list, draw };
|
||||
}
|
||||
@@ -36,58 +36,63 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 171-3 묶음 탭 머리 — 안내 글 + 보기 단추 */
|
||||
.b09-estimation__bar {
|
||||
/* 202-1 보조 탭 줄 오른쪽 도구(박스보기 · 가로 열 수) — 빈 보조 줄에서도 보임 · 단추 · 글 한 줄 */
|
||||
.b09-estimation__tools {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex: none;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 185-2 보기 단추 글 한 줄 */
|
||||
.b09-estimation__bar .ui-btn {
|
||||
.b09-estimation__tools .ui-btn,
|
||||
.b09-estimation__tools .ui-group-cards__label {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv1 td {
|
||||
font-size: var(--text-body);
|
||||
/* 202-1 안내 줄 = 표 · 카드 판 아래(묶음 탭은 한 줄 줄임표 · 전문 = 공용 호버) */
|
||||
.b09-estimation .ui-data-table + .ui-general-block__empty,
|
||||
.b09-estimation .ui-group-cards + .ui-general-block__empty {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td {
|
||||
font-size: 13px;
|
||||
.b09-estimation__line-hint {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-group td {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 1em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 2em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 3em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 4em);
|
||||
}
|
||||
|
||||
/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
|
||||
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
|
||||
}
|
||||
|
||||
/* 200-2 설계내역서 글 칸 왼쪽 맞춤(수량만 오른쪽) */
|
||||
.b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */
|
||||
.b09-estimation__level {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b09-estimation__level .b09-estimation__hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick td {
|
||||
--pick-t: inset 0 0 0 0 transparent;
|
||||
@@ -119,20 +124,51 @@
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 185-1 총괄 · 원가계산서 레벨 — 글자 크기 = 위 is-lvN · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 */
|
||||
.b09-estimation .ui-data-table__table tbody tr[class*="is-lv"] td.is-flat:first-child {
|
||||
padding-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td.is-indent {
|
||||
padding-left: calc(var(--spacing-8) + 1em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent {
|
||||
padding-left: calc(var(--spacing-8) + 2em);
|
||||
}
|
||||
|
||||
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
|
||||
.b09-estimation .ui-data-table__table tbody td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
}
|
||||
|
||||
/* 203-1 좌측 「데이터 점검」 — [업데이트] 필요 = 단추 위 점 배지(B05 [3D 업데이트] 꼴) */
|
||||
.b09-options .ui-btn.is-pending {
|
||||
position: relative;
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.b09-options .ui-btn.is-pending::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */
|
||||
.b09-check__head,
|
||||
.b09-check__note {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b09-check__tab {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */
|
||||
.b09-estimation__drop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--color-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b09-estimation__drop.is-bad {
|
||||
border-top: 2px dashed var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄
|
||||
* 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) +
|
||||
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices
|
||||
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [업데이트] = POST …/prices
|
||||
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createDataTable, type DataTable } from "@ui/ui_template_data_table";
|
||||
import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
@@ -31,9 +31,9 @@ import { computeEstimation } from "./B09_Estimation_Calc_Bill";
|
||||
import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
|
||||
import { computeTotal } from "./B09_Estimation_Calc_Total";
|
||||
import { createTabBars } from "./B09_Estimation_UI_TabBar";
|
||||
import { createCheckPanel } from "./B09_Estimation_UI_Check";
|
||||
import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor";
|
||||
import {
|
||||
buildCheck,
|
||||
rowIndex,
|
||||
TABS,
|
||||
type Link,
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs_Base";
|
||||
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
|
||||
import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
|
||||
import {
|
||||
buildSumExpense,
|
||||
buildSumLabor,
|
||||
@@ -69,7 +70,6 @@ function L(key: keyof typeof ui_locales): string {
|
||||
const LATER: Record<string, string> = {};
|
||||
|
||||
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
check: buildCheck,
|
||||
dg_list: buildDaegaList,
|
||||
dg: buildDaegaBlocks,
|
||||
bill: buildBill,
|
||||
@@ -151,7 +151,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
let payload: Row | null = null;
|
||||
let user: Row = {};
|
||||
let sheets: Row = {};
|
||||
let current: TabId = "check";
|
||||
let current: TabId = TABS[0][0];
|
||||
|
||||
// 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar`
|
||||
const bars = createTabBars((tab) => show(tab));
|
||||
@@ -162,11 +162,10 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
body.className = "b09-estimation";
|
||||
body.append(bars.top, bars.sub, panel);
|
||||
|
||||
const made = document.createElement("p");
|
||||
made.className = "b09-estimation__hint";
|
||||
|
||||
const recompute = (): void => {
|
||||
if (payload) sheets = computeSheets(payload, user);
|
||||
if (!payload) return;
|
||||
sheets = computeSheets(payload, user);
|
||||
check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지
|
||||
};
|
||||
|
||||
const setUser = (next: Row): void => {
|
||||
@@ -184,6 +183,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const show = (tab: TabId, focus?: string): void => {
|
||||
current = tab;
|
||||
bars.draw(tab);
|
||||
bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1)
|
||||
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
|
||||
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
|
||||
const build = BUILDERS[tab];
|
||||
@@ -209,6 +209,8 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
|
||||
: spec.rowClass,
|
||||
cellClass: spec.cellClass,
|
||||
level: spec.level,
|
||||
mergeSame: spec.mergeSame,
|
||||
onCellClick: (i, key) => {
|
||||
if (spec.onCell) spec.onCell(i, key);
|
||||
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
|
||||
@@ -222,7 +224,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
if (spec.choices)
|
||||
made.root.addEventListener("click", (event) => {
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
|
||||
const column = cell ? spec.columns[cell.cellIndex] : undefined;
|
||||
const column = cell ? spec.columns[cellColumn(cell)] : undefined;
|
||||
const items =
|
||||
cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key);
|
||||
if (!cell || !items?.length) return;
|
||||
@@ -234,7 +236,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
attachHoverInfo(
|
||||
made.root,
|
||||
(cell) => {
|
||||
const column = spec.columns[(cell as HTMLTableCellElement).cellIndex];
|
||||
const column = spec.columns[cellColumn(cell as HTMLTableCellElement)];
|
||||
return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key);
|
||||
},
|
||||
"tr[data-i] td",
|
||||
@@ -246,11 +248,23 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
if (isGroupTab(tab)) {
|
||||
const redraw = (): void => show(tab);
|
||||
return void panel.replaceChildren(
|
||||
...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }),
|
||||
...renderGroupPanel({
|
||||
tab,
|
||||
spec,
|
||||
hint,
|
||||
focus,
|
||||
goto,
|
||||
makeTable,
|
||||
redraw,
|
||||
setTools: bars.setTools,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const table = makeTable();
|
||||
panel.replaceChildren(...(hint ? [hint] : []), table.root);
|
||||
// 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 · 202-1 안내 줄 = 표 아래
|
||||
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
|
||||
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
|
||||
const level = bill ? levelBar(table.root, bill) : null;
|
||||
panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []));
|
||||
if (focus !== undefined) {
|
||||
const index = rowIndex(spec.rows, focus);
|
||||
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
|
||||
@@ -262,9 +276,6 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
|
||||
user = draft ?? next.user ?? {};
|
||||
recompute();
|
||||
made.textContent = next.book?.made
|
||||
? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로`
|
||||
: "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘";
|
||||
show(current);
|
||||
};
|
||||
|
||||
@@ -293,12 +304,13 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
});
|
||||
save.dataset.b09Save = "";
|
||||
const refresh = createButton({
|
||||
label: "단가 갱신",
|
||||
label: "업데이트",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
|
||||
});
|
||||
refresh.dataset.b09Refresh = "";
|
||||
const check = createCheckPanel(refresh, (link) => show(link.tab, link.key));
|
||||
// 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역
|
||||
const reset = createButton({
|
||||
label: "내역 초기화",
|
||||
@@ -318,8 +330,8 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "원가 단가";
|
||||
section.append(heading, made, refresh);
|
||||
heading.textContent = "데이터 점검";
|
||||
section.append(heading, refresh, check.list);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(save, reset);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움)
|
||||
* 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭
|
||||
* 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐
|
||||
* 보기 단추 · 가로 열 수(202-1) = 아래 줄 오른쪽 맞춤 자리(`setTools`) · 빈 줄에서도 보임
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
@@ -15,6 +16,8 @@ export interface TabBars {
|
||||
sub: HTMLElement;
|
||||
/** 이 탭이 켜진 꼴로 두 줄을 다시 그림 */
|
||||
draw: (tab: TabId) => void;
|
||||
/** 아래 줄 오른쪽 도구(박스보기 · 가로 열 수) — 빈 목록 = 비움 */
|
||||
setTools: (tools: HTMLElement[]) => void;
|
||||
}
|
||||
|
||||
function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) {
|
||||
@@ -35,6 +38,8 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
|
||||
top.setAttribute("role", "tablist");
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "b09-estimation__tabs";
|
||||
const tools = document.createElement("div");
|
||||
tools.className = "b09-estimation__tools";
|
||||
const lastSeen = new Map<TabId, TabId>();
|
||||
|
||||
const draw = (tab: TabId): void => {
|
||||
@@ -53,11 +58,11 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
|
||||
const single = picked.length === 1;
|
||||
sub.classList.toggle("b09-estimation__tabs--blank", single);
|
||||
sub.setAttribute("role", single ? "presentation" : "tablist");
|
||||
sub.setAttribute("aria-hidden", String(single));
|
||||
if (single) {
|
||||
const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" });
|
||||
ghost.tabIndex = -1;
|
||||
return void sub.replaceChildren(ghost);
|
||||
ghost.setAttribute("aria-hidden", "true"); // 도구는 보이는 줄이라 줄 전체를 숨기지 않음
|
||||
return void sub.replaceChildren(ghost, tools);
|
||||
}
|
||||
sub.replaceChildren(
|
||||
...picked.map((id) => {
|
||||
@@ -65,7 +70,9 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
|
||||
button.dataset.b09Tab = id;
|
||||
return button;
|
||||
}),
|
||||
tools,
|
||||
);
|
||||
};
|
||||
return { top, sub, draw };
|
||||
const setTools = (items: HTMLElement[]): void => tools.replaceChildren(...items);
|
||||
return { top, sub, draw, setTools };
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
* B09_Estimation_UI_Tabs.ts
|
||||
* B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례).
|
||||
*
|
||||
* 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷
|
||||
* 탭 = 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷
|
||||
* 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`)
|
||||
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만
|
||||
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴만
|
||||
* 연결 점검(옛 첫 탭) = 좌측 「데이터 점검」 묶음(203-1 `…_UI_Check`)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import type { DataColumn, DataRow } from "@ui/ui_template_data_table";
|
||||
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
|
||||
export type TabId =
|
||||
| "check"
|
||||
| "cost"
|
||||
| "total"
|
||||
| "bill"
|
||||
@@ -34,7 +34,6 @@ export type TabId =
|
||||
|
||||
/** 엑셀 탭 차례 — [id, 탭 이름] */
|
||||
export const TABS: Array<[TabId, string]> = [
|
||||
["check", "연결 점검"],
|
||||
["cost", "공사원가계산서"],
|
||||
["total", "총괄설계내역서"],
|
||||
["bill", "설계내역서"],
|
||||
@@ -59,7 +58,6 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1]
|
||||
|
||||
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
|
||||
export const TAB_GROUPS: Array<[string, TabId[]]> = [
|
||||
["연결 점검", ["check"]],
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
@@ -112,10 +110,20 @@ export interface TabTable {
|
||||
cellClass?: (index: number, key: string) => string | null | undefined;
|
||||
/** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */
|
||||
choices?: (index: number, key: string) => MapContextMenuItem[] | null;
|
||||
/** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */
|
||||
level?: DataTableOptions["level"];
|
||||
/** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
|
||||
export type TabBuild = (ctx: TabContext) => TabTable | string;
|
||||
|
||||
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
||||
export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({
|
||||
key,
|
||||
of: (i) => (top(i) ? 1 : 2),
|
||||
});
|
||||
|
||||
/* ── 열 꼴 ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({
|
||||
@@ -156,36 +164,3 @@ export function parseNumber(value: string): number | null | undefined {
|
||||
export function rowIndex(rows: DataRow[], key: string): number {
|
||||
return rows.findIndex((row) => row.key === key);
|
||||
}
|
||||
|
||||
/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */
|
||||
|
||||
export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => {
|
||||
const gaps: Row[] = sheets.gaps || [];
|
||||
const rows: DataRow[] = gaps.map((g, i) => ({
|
||||
no: i + 1,
|
||||
key: `${g.tab}|${g.key}|${i}`,
|
||||
tab: tabName(g.tab),
|
||||
name: g.name,
|
||||
why: g.why,
|
||||
}));
|
||||
const notes: string[] = [...(payload.notes || [])];
|
||||
const missing: string[] = payload.inputs?.missing || [];
|
||||
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "번호", 0),
|
||||
text("tab", "탭", 150),
|
||||
text("name", "명칭", 260),
|
||||
text("why", "까닭", 320),
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint:
|
||||
`연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` +
|
||||
(notes.length ? ` · ${notes.join(" · ")}` : ""),
|
||||
link: (i) => {
|
||||
const g = gaps[i];
|
||||
return g ? { tab: g.tab as TabId, key: g.key } : null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
|
||||
import {
|
||||
blockLevel,
|
||||
money,
|
||||
parseNumber,
|
||||
qty,
|
||||
@@ -51,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.labor || [],
|
||||
"단가(원/인)",
|
||||
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`,
|
||||
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`,
|
||||
);
|
||||
|
||||
export const buildExpense: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.expense || [],
|
||||
"기준가(천원)",
|
||||
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`,
|
||||
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`,
|
||||
);
|
||||
|
||||
function madeText(payload: Row): string {
|
||||
@@ -102,6 +103,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
mergeSame: ["part"],
|
||||
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
|
||||
link: (i) => {
|
||||
const row = rows[i];
|
||||
@@ -344,7 +346,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
|
||||
rows.push({
|
||||
key: `${b.key}|${ln.name}`,
|
||||
name: ` ${ln.name}`,
|
||||
name: ln.name,
|
||||
spec: ln.spec,
|
||||
qty: ln.qty,
|
||||
unit: ln.unit,
|
||||
@@ -361,7 +363,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
}
|
||||
rows.push({
|
||||
key: `${b.key}|합계`,
|
||||
name: " 합 계",
|
||||
name: "합 계",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
@@ -388,5 +390,6 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
frozen: 1,
|
||||
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
|
||||
link: (i) => links[i] ?? null,
|
||||
level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개
|
||||
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
|
||||
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
import {
|
||||
billMenu,
|
||||
dropMove,
|
||||
editCell,
|
||||
linkable,
|
||||
openLinkMenu,
|
||||
@@ -93,9 +94,11 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const r = rows[i];
|
||||
if (!r) return null;
|
||||
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
|
||||
return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ");
|
||||
return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null;
|
||||
},
|
||||
level: { key: "name", of: (i) => rows[i]?.level as number | undefined },
|
||||
menu: (i) => billMenu(ctx, raw, i),
|
||||
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
|
||||
onCell: (i, key) => {
|
||||
if (key === "connect") openLinkMenu(ctx, raw, i);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Drag.ts
|
||||
* B09 설계내역서 줄 끌기(PLAN 204-1) — 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(한 칸 = 1em) · 공용 표 위 덧그림.
|
||||
*
|
||||
* 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음
|
||||
* 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만
|
||||
* 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed)
|
||||
* 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
export interface DropAt {
|
||||
parent: string | null;
|
||||
before: string | null;
|
||||
level: number;
|
||||
}
|
||||
|
||||
const holds = (row: DataRow): boolean => row.key !== "fee" && !String(row.key).startsWith("mat:");
|
||||
|
||||
/** 틈 gap(0 ~ 줄 수 · 그 줄 앞) · 바라는 레벨 → 놓을 자리 · 같은 자리 · 못 놓음 = null */
|
||||
export function dropAt(rows: DataRow[], from: number, gap: number, want: number): DropAt | null {
|
||||
const row = rows[from];
|
||||
if (!row) return null;
|
||||
const lv = (r: DataRow | undefined): number => Number(r?.level ?? 1);
|
||||
let end = from + 1;
|
||||
while (end < rows.length && lv(rows[end]) > lv(row)) end += 1;
|
||||
if (gap > from && gap < end) return null; // 자기 자손 안
|
||||
const rest = [...rows.slice(0, from), ...rows.slice(end)];
|
||||
const g = gap <= from ? gap : gap - (end - from);
|
||||
const up = rest[g - 1];
|
||||
const down = rest[g];
|
||||
const max = up ? lv(up) + (holds(up) ? 1 : 0) : 1;
|
||||
const min = Math.max(down ? lv(down) : 1, lv(row) === 1 ? 1 : 2);
|
||||
if (min > max) return null;
|
||||
const level = Math.min(max, Math.max(min, want));
|
||||
if (level === lv(row) && (gap === from || gap === end)) return null; // 제자리
|
||||
let parent: DataRow | undefined = up;
|
||||
const byKey = new Map(rest.map((r) => [String(r.key), r]));
|
||||
while (parent && lv(parent) >= level) parent = byKey.get(String(parent.parent ?? ""));
|
||||
return {
|
||||
parent: parent ? String(parent.key) : null,
|
||||
before: down && lv(down) === level ? String(down.key) : null,
|
||||
level,
|
||||
};
|
||||
}
|
||||
|
||||
/** 표 root 에 줄 끌기를 붙임 — 놓으면 done(끈 줄 키) 뒤 drop 실행 */
|
||||
export function attachDrag(root: HTMLElement, table: BillTable, done: (key: string) => void): void {
|
||||
let endAt = 0;
|
||||
// 끌고 난 누름 = 줄 고름 아님
|
||||
root.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (Date.now() - endAt < 300) event.stopPropagation();
|
||||
},
|
||||
true,
|
||||
);
|
||||
root.addEventListener("pointerdown", (down) => {
|
||||
const target = down.target as Element;
|
||||
if (down.button !== 0 || !table.drop || target.closest("input,textarea,select,button,a"))
|
||||
return;
|
||||
const tr = target.closest<HTMLElement>("tr[data-i]");
|
||||
if (!tr || !root.contains(tr)) return;
|
||||
const from = Number(tr.dataset.i);
|
||||
const step = parseFloat(getComputedStyle(tr).fontSize) || 14;
|
||||
const line = document.createElement("div");
|
||||
line.className = "b09-estimation__drop";
|
||||
let on = false;
|
||||
let run: (() => void) | null = null;
|
||||
let seen = "";
|
||||
const move = (event: PointerEvent): void => {
|
||||
const dx = event.clientX - down.clientX;
|
||||
if (!on && Math.abs(dx) + Math.abs(event.clientY - down.clientY) < 6) return;
|
||||
if (!on) {
|
||||
on = true;
|
||||
document.body.append(line);
|
||||
document.body.style.userSelect = "none";
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
const trs = table.rows.map((_r, i) => root.querySelector<HTMLElement>(`tr[data-i="${i}"]`));
|
||||
let gap = trs.length;
|
||||
let y = trs.at(-1)?.getBoundingClientRect().bottom ?? 0;
|
||||
for (const [i, el] of trs.entries()) {
|
||||
const box = el?.getBoundingClientRect();
|
||||
if (box && event.clientY < box.top + box.height / 2) {
|
||||
[gap, y] = [i, box.top];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const want = Number(table.rows[from]?.level ?? 1) + Math.round(dx / step);
|
||||
const at = dropAt(table.rows, from, gap, want);
|
||||
const key = `${gap}|${at?.level}`;
|
||||
if (key !== seen) {
|
||||
seen = key;
|
||||
run = at ? table.drop!(from, at.parent, at.before) : null;
|
||||
}
|
||||
const box = root.getBoundingClientRect();
|
||||
const left = box.left + ((at?.level ?? want) - 1) * step + 8;
|
||||
Object.assign(line.style, {
|
||||
top: `${y - 1}px`,
|
||||
left: `${left}px`,
|
||||
width: `${Math.max(box.right - left, 40)}px`,
|
||||
});
|
||||
line.classList.toggle("is-bad", !run);
|
||||
document.body.style.cursor = run ? "grabbing" : "not-allowed";
|
||||
};
|
||||
const stop = (event: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
line.remove();
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
if (!on) return;
|
||||
endAt = Date.now();
|
||||
if (event.type === "pointerup" && run) {
|
||||
done(String(table.rows[from].key));
|
||||
run();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
|
||||
* 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기
|
||||
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
|
||||
* 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 ·
|
||||
* 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -24,7 +26,11 @@ import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
|
||||
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
|
||||
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
|
||||
export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] };
|
||||
export type BillTable = TabTable & {
|
||||
menu?: (index: number) => MapContextMenuItem[];
|
||||
/** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */
|
||||
drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null;
|
||||
};
|
||||
|
||||
const NEW_NAME = "새 줄";
|
||||
const NEW_SECTION = "새 공종";
|
||||
@@ -47,6 +53,8 @@ interface Work {
|
||||
|
||||
/** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */
|
||||
const fixed = (key: string): boolean => key === "fee" || key === "sec:G";
|
||||
/** 자식을 둘 수 있는 줄 — 자재 · 조달수수료 빼고(항목은 저절로 묶음) */
|
||||
const canHold = (key: string): boolean => key !== "fee" && !key.startsWith("mat:");
|
||||
|
||||
/** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */
|
||||
export const linkable = (row: DataRow | undefined): boolean =>
|
||||
@@ -148,6 +156,11 @@ function attempt(
|
||||
const before = w.bill.rows.map((e) => ({ ...e }));
|
||||
if (change(w) === false) return null;
|
||||
w.bill.rows = tidy(w.bill.rows);
|
||||
// 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) — 자식이 생긴 항목 = 셈이 묶음으로
|
||||
const had = new Set(before.map((e) => e.parent));
|
||||
const has = new Set(w.bill.rows.map((e) => e.parent));
|
||||
for (const e of w.bill.rows)
|
||||
if (e.kind === "group" && e.parent && had.has(e.key) && !has.has(e.key)) delete e.kind;
|
||||
return fits(w, before) ? () => put(ctx, w.bill) : null;
|
||||
}
|
||||
|
||||
@@ -273,7 +286,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
add(2, "들여쓰기 →", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const up = sib[sib.indexOf(e) - 1];
|
||||
if (!up || !isGroup(w, up.key)) return false;
|
||||
if (!up || !canHold(up.key)) return false;
|
||||
e.parent = up.key;
|
||||
w.bill.rows.push(pull(w.bill.rows, key));
|
||||
});
|
||||
@@ -315,6 +328,28 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 끌어 놓기(204) — 자손째 parent 아래 before 앞으로 · 지킬 것(깊이 · 자재 돈 갈래)은 메뉴와 같음 */
|
||||
export function dropMove(
|
||||
ctx: TabContext,
|
||||
rows: DataRow[],
|
||||
i: number,
|
||||
parent: string | null,
|
||||
before: string | null,
|
||||
): (() => void) | null {
|
||||
const key = String(rows[i]?.key);
|
||||
if (!rows[i] || fixed(key) || (parent !== null && !canHold(parent))) return null;
|
||||
return attempt(ctx, rows, (w) => {
|
||||
const e = entryOf(w, key);
|
||||
if (!e || (parent !== null && !entryOf(w, parent))) return false;
|
||||
if (parent !== null && chain(w.bill.rows, parent).includes(e)) return false; // 자기 자손 아래
|
||||
if (parent === null) delete e.parent;
|
||||
else e.parent = parent;
|
||||
pull(w.bill.rows, key);
|
||||
const at = before === null ? -1 : find(w.bill.rows, before);
|
||||
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, e);
|
||||
});
|
||||
}
|
||||
|
||||
let shared: MapContextMenu | null = null;
|
||||
/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */
|
||||
let lastCell: Element | null = null;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Level.ts
|
||||
* B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄.
|
||||
*
|
||||
* 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음
|
||||
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로)
|
||||
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
|
||||
* 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄)
|
||||
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { attachDrag } from "./B09_Estimation_UI_Tabs_Bill_Drag";
|
||||
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */
|
||||
const ACTS: [string, string][] = [
|
||||
["→ 들여쓰기", "들여쓰기 →"],
|
||||
["← 내어쓰기", "내어쓰기 ←"],
|
||||
["↑ 위로", "위로 ↑"],
|
||||
["↓ 아래로", "아래로 ↓"],
|
||||
];
|
||||
|
||||
const GUIDE =
|
||||
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기";
|
||||
const GUIDE_MORE =
|
||||
"레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " +
|
||||
"들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " +
|
||||
"내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " +
|
||||
"위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " +
|
||||
"끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " +
|
||||
"키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " +
|
||||
"묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭";
|
||||
|
||||
let picked: string | null = null;
|
||||
/** 지금 그린 도구 줄 — 키가 부름 */
|
||||
let live: {
|
||||
root: HTMLElement;
|
||||
bar: HTMLElement;
|
||||
run: (label: string) => void;
|
||||
sync: () => void;
|
||||
} | null = null;
|
||||
let keysOn = false;
|
||||
|
||||
/** 고른 줄 class — Shell 줄 class 곁값 */
|
||||
export const pickedClass = (row: DataRow | undefined): string | null =>
|
||||
row && picked !== null && String(row.key) === picked ? "is-focus" : null;
|
||||
|
||||
/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */
|
||||
export function menuActs(table: BillTable, index: number): Map<string, () => void> {
|
||||
const items = index >= 0 && table.menu ? table.menu(index) : [];
|
||||
return new Map(items.filter((x): x is [string, () => void] => Array.isArray(x)));
|
||||
}
|
||||
|
||||
/** 키 → 메뉴 글 · 편집 중 · 다른 키 = null */
|
||||
export function keyAct(
|
||||
e: { key: string; shiftKey: boolean; altKey: boolean; ctrlKey?: boolean },
|
||||
editing: boolean,
|
||||
): string | null {
|
||||
if (editing || e.ctrlKey) return null;
|
||||
if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1];
|
||||
if (e.shiftKey) return null;
|
||||
const arrow = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].indexOf(e.key);
|
||||
// 방향키(204) — 그냥 · Alt 같음(Alt+←→ = 브라우저 뒤로 · 앞으로라 안 받음)
|
||||
if (arrow < 0 || (e.altKey && arrow < 2)) return null;
|
||||
return ACTS[arrow][1];
|
||||
}
|
||||
|
||||
const editing = (at: Element | null): boolean =>
|
||||
/^(INPUT|TEXTAREA|SELECT)$/.test(at?.tagName ?? "") ||
|
||||
!!(at as HTMLElement | null)?.isContentEditable;
|
||||
|
||||
function listenKeys(): void {
|
||||
if (keysOn) return;
|
||||
keysOn = true;
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!live?.root.isConnected || picked === null) return;
|
||||
const at = document.activeElement;
|
||||
if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return;
|
||||
// Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂
|
||||
if (event.key === "Escape") {
|
||||
if (!editing(at)) {
|
||||
picked = null;
|
||||
return live.sync();
|
||||
}
|
||||
(at as HTMLElement).blur();
|
||||
return live.root.focus();
|
||||
}
|
||||
const label = keyAct(event, editing(at));
|
||||
if (!label) return;
|
||||
event.preventDefault();
|
||||
live.run(label);
|
||||
});
|
||||
}
|
||||
|
||||
/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */
|
||||
export function levelBar(root: HTMLElement, table: BillTable): HTMLElement {
|
||||
const index = (): number =>
|
||||
picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked);
|
||||
const run = (label: string): void => menuActs(table, index()).get(label)?.();
|
||||
const buttons = ACTS.map(([text, label]) => {
|
||||
const button = createButton({
|
||||
label: text,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => run(label),
|
||||
});
|
||||
button.dataset.b09Level = label;
|
||||
return button;
|
||||
});
|
||||
const guide = document.createElement("span");
|
||||
guide.className = "b09-estimation__hint";
|
||||
guide.textContent = GUIDE;
|
||||
attachHoverInfo(guide, GUIDE_MORE);
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b09-estimation__level";
|
||||
bar.append(...buttons, guide);
|
||||
const sync = (): void => {
|
||||
const acts = menuActs(table, index());
|
||||
ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label)));
|
||||
for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus");
|
||||
root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus");
|
||||
};
|
||||
root.addEventListener("click", (event) => {
|
||||
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
const row = tr ? table.rows[Number(tr.dataset.i)] : undefined;
|
||||
if (!row) return;
|
||||
picked = String(row.key);
|
||||
sync();
|
||||
});
|
||||
attachDrag(root, table, (key) => (picked = key));
|
||||
sync();
|
||||
live = { root, bar, run, sync };
|
||||
listenKeys();
|
||||
return bar;
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Link.ts
|
||||
* B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) ·
|
||||
* M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기.
|
||||
* 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새
|
||||
* 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 ·
|
||||
* 연결 풀기.
|
||||
*
|
||||
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
|
||||
* 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 ·
|
||||
* 층 읽기 권한 = 서버(`M02_Layer_Access`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openItemPicker } from "@ui/ui_template_item_picker";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
|
||||
@@ -86,6 +91,63 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void
|
||||
);
|
||||
}
|
||||
|
||||
/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만
|
||||
* 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */
|
||||
async function importLib(
|
||||
ctx: TabContext,
|
||||
apply: ApplyRef,
|
||||
pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string },
|
||||
): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) return;
|
||||
showToast("서고에서 들여오는 중…", "info");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pick),
|
||||
});
|
||||
const got = (await response.json().catch(() => null)) as Row | null;
|
||||
if (!response.ok || !got) {
|
||||
const detail = got?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦");
|
||||
const { user: _user, ...rest } = got.payload as Row;
|
||||
Object.assign(ctx.payload, rest);
|
||||
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
|
||||
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
|
||||
showToast(
|
||||
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */
|
||||
function pickCombo(ctx: TabContext, apply: ApplyRef): void {
|
||||
void openItemPicker({ current: null, canCopy: false }).then((got) => {
|
||||
if (got.action !== "apply") return;
|
||||
const { layer, owner, key } = got.item;
|
||||
void importLib(ctx, apply, { kind: "combo", layer, owner, key });
|
||||
});
|
||||
}
|
||||
|
||||
/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */
|
||||
function pickPrecombo(ctx: TabContext, apply: ApplyRef): void {
|
||||
openLogicPicker(() => undefined, {
|
||||
layer: "personal",
|
||||
owner: null,
|
||||
libOnly: true,
|
||||
title: "일위대가 사전조합 고르기",
|
||||
pick: (ref) =>
|
||||
void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */
|
||||
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
|
||||
const more: Leaf[] = [];
|
||||
@@ -95,6 +157,8 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L
|
||||
const picks: Leaf[] = [
|
||||
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
|
||||
["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)],
|
||||
["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)],
|
||||
[
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
() => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)),
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
|
||||
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
|
||||
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
|
||||
* 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래
|
||||
* 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow, DataTable } from "@ui/ui_template_data_table";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import {
|
||||
clampColumns,
|
||||
createGroupCards,
|
||||
@@ -174,10 +177,18 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine
|
||||
|
||||
/* ── 그리기 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
|
||||
function lineHint(hint: HTMLElement | null): HTMLElement | null {
|
||||
if (!hint) return null;
|
||||
hint.classList.add("b09-estimation__line-hint");
|
||||
attachHoverInfo(hint, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null));
|
||||
return hint;
|
||||
}
|
||||
|
||||
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
|
||||
const chosen = new Map<TabId, GroupView>();
|
||||
|
||||
/** 묶음 탭 — 카드 = 안내 + 카드 판(보기 단추는 가로 열 수 줄) · 표 = 머리(안내 + 보기 단추) + 표 */
|
||||
/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */
|
||||
export function renderGroupPanel(o: {
|
||||
tab: TabId;
|
||||
spec: TabTable;
|
||||
@@ -186,8 +197,10 @@ export function renderGroupPanel(o: {
|
||||
goto: (link: Link | null) => void;
|
||||
makeTable: (rowClass?: (index: number) => string | null) => DataTable;
|
||||
redraw: () => void;
|
||||
setTools: (tools: HTMLElement[]) => void;
|
||||
}): HTMLElement[] {
|
||||
const view = chosen.get(o.tab) ?? readGroupView(o.tab);
|
||||
const lead = lineHint(o.hint);
|
||||
const toggle = createButton({
|
||||
label: viewLabel(view),
|
||||
variant: "ghost",
|
||||
@@ -210,16 +223,15 @@ export function renderGroupPanel(o: {
|
||||
focus: at,
|
||||
money: numberFormat(o.spec, "sum"),
|
||||
qty: numberFormat(o.spec, "qty"),
|
||||
actions: [toggle], // 185-2 가로 열 수 단추와 한 줄
|
||||
actions: [toggle],
|
||||
placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽
|
||||
});
|
||||
cards.dataset.b09Table = o.tab;
|
||||
return [...(o.hint ? [o.hint] : []), cards];
|
||||
return [cards, ...(lead ? [lead] : [])];
|
||||
}
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b09-estimation__bar";
|
||||
bar.append(...(o.hint ? [o.hint] : []), toggle);
|
||||
o.setTools([toggle]);
|
||||
const range = pickRange(o.spec.rows, o.focus);
|
||||
const table = o.makeTable((i) => pickClass(range, i));
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
|
||||
return [bar, table.root];
|
||||
return [table.root, ...(lead ? [lead] : [])];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
blockLevel,
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
@@ -25,7 +26,7 @@ import { raw } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
const EMPTY =
|
||||
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
|
||||
/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
|
||||
const CHILD = " ㄴ ";
|
||||
const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1)
|
||||
const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0";
|
||||
const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김";
|
||||
/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */
|
||||
@@ -122,7 +123,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
|
||||
titles.add(rows.length);
|
||||
rows.push({
|
||||
key: `${b.id}|합계`,
|
||||
name: " 합 계",
|
||||
name: "합 계",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
@@ -154,6 +155,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
|
||||
hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`,
|
||||
link: (i: number) => links[i] ?? null,
|
||||
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
|
||||
level: blockLevel("name", (i) => titles.has(i)),
|
||||
tip: (i: number, key: string) =>
|
||||
key === "spec"
|
||||
? raw(rows[i]?.spec, rows[i]?.spec_raw)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Sangeun.ts
|
||||
* B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun`
|
||||
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈).
|
||||
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [업데이트] 때 서버가 다시 셈).
|
||||
*
|
||||
* 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록
|
||||
* 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 ·
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
blockLevel,
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
@@ -99,7 +100,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
null,
|
||||
true,
|
||||
);
|
||||
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` }));
|
||||
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: line }));
|
||||
(b.lines as Row[]).forEach((ln, n) => {
|
||||
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null;
|
||||
const kind =
|
||||
@@ -115,8 +116,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
push(
|
||||
{
|
||||
key: `${b.key}|${n}${part ? `|${part}` : ""}`,
|
||||
kind: k === 0 ? kind : "",
|
||||
text: ` ${texts[k] ?? fallback}`,
|
||||
kind, // 같은 공종 줄 = 세로 병합(201-1)
|
||||
text: texts[k] ?? fallback,
|
||||
sum: PARTS.some((p) => at(p) === null)
|
||||
? null
|
||||
: PARTS.reduce((s, p) => s + (at(p) as number), 0),
|
||||
@@ -162,6 +163,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
notes(sheets),
|
||||
link: (i: number) => links[i] ?? null,
|
||||
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
|
||||
level: blockLevel("text", (i) => titles.has(i)),
|
||||
mergeSame: ["kind"],
|
||||
tip: (i: number, key: string) =>
|
||||
key === "note" ? raw(rows[i]?.note, rows[i]?.note_raw) : null,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ const HINT =
|
||||
/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */
|
||||
const hint = (payload: Row, extra = ""): string => {
|
||||
const n = (payload.summary || {}).unlinked || 0;
|
||||
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(연결 점검 탭)` : ""}`;
|
||||
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`;
|
||||
};
|
||||
|
||||
/** 줄이 없을 때 표 위 맨 앞 안내 */
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
|
||||
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
|
||||
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
|
||||
* 레벨(185-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → 182 is-lvN 글자 크기 ·
|
||||
* 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 · 1단 = 굵게(is-title)
|
||||
* 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) →
|
||||
* 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title)
|
||||
* 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -77,13 +77,12 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un
|
||||
ctx.setUser({ ...ctx.user, estimate });
|
||||
}
|
||||
|
||||
/** 185-1 레벨 꼴 — 줄 class · 칸 class(들여쓰기 = 명칭 칸 · 앞 칸 그대로) */
|
||||
const levelClass = (r: DataRow | undefined): string | null =>
|
||||
r ? `is-lv${r.level}${r.level === 1 ? " is-title" : ""}` : null;
|
||||
const indentClass =
|
||||
(first: string, name: string) =>
|
||||
(_i: number, key: string): string | null =>
|
||||
key === name ? "is-indent" : key === first ? "is-flat" : null;
|
||||
/** 185-1 레벨 꼴 — 1단 = 굵게 · 들여쓰기 = 공용 표 level(명칭 칸) */
|
||||
const levelClass = (r: DataRow | undefined): string | null => (r?.level === 1 ? "is-title" : null);
|
||||
const nameLevel = (rows: DataRow[]) => ({
|
||||
key: "name",
|
||||
of: (i: number) => rows[i]?.level as number | undefined,
|
||||
});
|
||||
|
||||
function rateText(formula: string, rate: Num): string {
|
||||
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
|
||||
@@ -210,7 +209,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
rows,
|
||||
frozen: 2,
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
cellClass: indentClass("part", "name"),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["part"],
|
||||
hint:
|
||||
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
|
||||
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
|
||||
@@ -290,7 +290,8 @@ export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
rows,
|
||||
frozen: 3,
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
cellClass: indentClass("group", "name"),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["group", "part"],
|
||||
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
|
||||
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
|
||||
@@ -26,6 +26,8 @@ import master_keys as mk # noqa: E402
|
||||
import master_text as mtx # noqa: E402
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store_Cache as cache # noqa: E402
|
||||
from M01_MasterData.M01_MasterData_Store_Find import hit as _hit # noqa: E402
|
||||
from M01_MasterData.M01_MasterData_Store_Find import squash as _squash # noqa: E402
|
||||
from M01_MasterData.M01_MasterData_Store_Shape import dump, keep_shape # noqa: E402
|
||||
|
||||
_COPY = os.environ.get("AISLO_MASTER_DIR") # 화면 저장 시험 사본 폴더(정함 13) · 없으면 정본
|
||||
@@ -88,11 +90,6 @@ def chapter_of(file: str, data: dict | None = None) -> str:
|
||||
return f"{division} {m.group(1)}장 {m.group(2).replace('_', ' ')}".strip()
|
||||
|
||||
|
||||
def _hit(row: dict, q: str) -> bool:
|
||||
q = _squash(q) # 띄어쓰기 무시 — 「골 함 석」 ↔ 「함석」
|
||||
return not q or any(q in _squash(row.get(k, "")) for k in ("키", "원문번호", "이름", "옛이름"))
|
||||
|
||||
|
||||
def _book(data: dict) -> str | None:
|
||||
"""머리 원문 이름 — 한 테이블 파일(기계)은 머리 「원문」 이 갈래 묶음이라 이름이 묶음 안에."""
|
||||
return data.get("원문") if isinstance(data.get("원문"), str) else None
|
||||
@@ -367,6 +364,7 @@ def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "")
|
||||
"소유",
|
||||
)
|
||||
},
|
||||
"별칭": row.get("별칭") or "", # 없으면 빈칸(208-1)
|
||||
"blocked": bool(reasons),
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -449,17 +447,14 @@ def elements(group: str, q: str, limit: int) -> dict:
|
||||
continue
|
||||
for row in data.get(items_key(data)) or []:
|
||||
if _hit(row, q):
|
||||
hits.append({**_brief(str(row.get("키")), row), "file": name})
|
||||
more = {"별칭": row.get("별칭") or ""} if group == "로직" else {} # 208-1
|
||||
hits.append({**_brief(str(row.get("키")), row), "file": name, **more})
|
||||
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
|
||||
|
||||
|
||||
_PICK = {"price": "재료_자재품목.json", "job": "인력.json"}
|
||||
|
||||
|
||||
def _squash(text) -> str:
|
||||
return "".join(str(text or "").lower().split())
|
||||
|
||||
|
||||
def _lowest(row: dict) -> object:
|
||||
"""자재품목 줄 — 값 열 다섯 가운데 낮은 값(시험 계산용) · 값 칸이 있는 줄은 그 값 그대로."""
|
||||
if "값" in row:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""찾기 맞춤 — 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 옛이름 · 별칭 가운데 하나에 들면 맞음.
|
||||
|
||||
띄어쓰기 · 대소문자 무시 · 별칭 = 설계자가 로직을 찾는 다른 이름(쉼표로 여럿 · 208-1).
|
||||
짝: `ui_template/ui_template_logic_find.ts` `matchLogic`(화면 쪽) ·
|
||||
거울 시험 `test_208_1_logic_alias.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
FIELDS = ("키", "원문번호", "이름", "옛이름", "별칭")
|
||||
|
||||
|
||||
def squash(text) -> str:
|
||||
return "".join(str(text or "").lower().split())
|
||||
|
||||
|
||||
def hit(row: dict, q: str) -> bool:
|
||||
words = [squash(w) for w in str(q or "").split()]
|
||||
fields = [squash(row.get(k)) for k in FIELDS]
|
||||
return all(any(w in f for f in fields) for w in words)
|
||||
@@ -135,7 +135,10 @@ export async function mountM01LogicLab(
|
||||
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
|
||||
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
|
||||
const pending = el("span", { className: "m01-logic__muted" });
|
||||
const list = buildList((item) => void open(item.id, item.sub, item.key));
|
||||
const list = buildList(
|
||||
(item) => void open(item.id, item.sub, item.key),
|
||||
(item, alias) => void onAlias(item, alias),
|
||||
);
|
||||
const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) });
|
||||
|
||||
const persist = (): void => {
|
||||
@@ -146,8 +149,10 @@ export async function mountM01LogicLab(
|
||||
}
|
||||
const marks = new Map<string, ListMark>();
|
||||
const extra: ListItem[] = [];
|
||||
const aliases = new Map<string, string>();
|
||||
for (const [id, d] of Object.entries(drafts)) {
|
||||
marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited");
|
||||
if (d.row) aliases.set(id, d.row.별칭 ?? "");
|
||||
if (d.origKey === null && d.row) {
|
||||
extra.push({
|
||||
id,
|
||||
@@ -156,6 +161,7 @@ export async function mountM01LogicLab(
|
||||
key: d.row.키,
|
||||
number: d.row.원문번호,
|
||||
name: d.row.이름,
|
||||
alias: d.row.별칭 ?? "",
|
||||
unit: d.row.결과단위,
|
||||
source: d.row.출처,
|
||||
blocked: false,
|
||||
@@ -163,7 +169,7 @@ export async function mountM01LogicLab(
|
||||
});
|
||||
}
|
||||
}
|
||||
list.setMarks(marks, extra);
|
||||
list.setMarks(marks, extra, aliases);
|
||||
const n = Object.keys(drafts).length;
|
||||
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
|
||||
saveButton.disabled = discardButton.disabled = n === 0;
|
||||
@@ -423,6 +429,26 @@ export async function mountM01LogicLab(
|
||||
persist();
|
||||
};
|
||||
|
||||
/** 목록 「별칭」 칸 고침(208-1) — 그 줄의 별칭만 바꿔 캐시에 · [저장] 때 다른 고침과 함께 나감 */
|
||||
const onAlias = async (item: ListItem, alias: string): Promise<void> => {
|
||||
const draft = drafts[item.id];
|
||||
if (draft && !draft.row) return; // 지운 줄
|
||||
try {
|
||||
const one = draft?.origKey === null ? null : await fetchLogic(item.key);
|
||||
const row = clone((draft?.row ?? one?.logic) as LogicRow);
|
||||
if (alias) row.별칭 = alias;
|
||||
else delete row.별칭;
|
||||
if (one && JSON.stringify(row) === JSON.stringify(one.logic)) delete drafts[item.id];
|
||||
else
|
||||
drafts[item.id] = draft
|
||||
? { ...draft, row }
|
||||
: { sub: item.sub, file: one!.file, version: one!.version, origKey: item.key, row };
|
||||
persist();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (): Promise<void> => {
|
||||
if (!opened?.row) return;
|
||||
if (
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface LogicRow {
|
||||
덧줄?: NamedFormula[];
|
||||
끝수?: string | null;
|
||||
비고?: string;
|
||||
/** 설계자가 찾는 다른 이름 — 쉼표로 여럿 · 없으면 칸 없음(208-1) */
|
||||
별칭?: string;
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -54,6 +56,8 @@ export interface LogicSummary {
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
출처: string;
|
||||
/** 없으면 빈칸(208-1) */
|
||||
별칭?: string;
|
||||
blocked: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
@@ -66,6 +70,8 @@ export interface ElementBrief {
|
||||
단위?: unknown;
|
||||
값?: unknown;
|
||||
값칸?: Record<string, string> | null;
|
||||
/** 로직 그룹만 — 별칭(208-1) */
|
||||
별칭?: string;
|
||||
/** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */
|
||||
값들?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* M01_MasterData_UI_Logic_List.ts
|
||||
* 로직 목록 — 가운데 표 · 왼쪽 「전체」 + 구분 → 상세구분 거름이 고른 범위 + 이름 찾기
|
||||
* 파일을 가로질러 한 목록 · 쪽 나눔 · 막힘(⛔)·고침(●) 표시 · 거르기는 화면에서(목록은 한 번 받음)
|
||||
* 「별칭」 열(208-1) — 이름 옆 · 고치면 `onAlias`(캐시에 쌓고 [저장] 때 나감) · 찾기는 공용 `matchLogic`
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, el } from "@ui/ui_template_elements";
|
||||
import { aliasText, matchLogic } from "@ui/ui_template_logic_find";
|
||||
import { buildPager } from "./M01_MasterData_UI_Cells";
|
||||
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
@@ -19,6 +21,8 @@ export interface ListItem {
|
||||
key: string;
|
||||
number: string;
|
||||
name: string;
|
||||
/** 쉼표로 여럿 · 없으면 빈칸 */
|
||||
alias: string;
|
||||
unit: string;
|
||||
source: string;
|
||||
blocked: boolean;
|
||||
@@ -28,8 +32,8 @@ export interface ListItem {
|
||||
export interface ListHandle {
|
||||
root: HTMLElement;
|
||||
setItems: (items: LogicSummary[]) => void;
|
||||
/** 저장 안 한 새 로직도 목록 맨 위에 */
|
||||
setMarks: (marks: Map<string, ListMark>, extra: ListItem[]) => void;
|
||||
/** 저장 안 한 새 로직도 목록 맨 위에 · aliases = 캐시에 고친 별칭(id → 글) */
|
||||
setMarks: (marks: Map<string, ListMark>, extra: ListItem[], aliases: Map<string, string>) => void;
|
||||
/** 왼쪽 거름이 고른 범위 — 비면 전체 */
|
||||
setPick: (sub: string, detail: string) => void;
|
||||
total: () => number;
|
||||
@@ -38,12 +42,27 @@ export interface ListHandle {
|
||||
export const logicId = (sub: string, key: string): string => `${sub}\n${key}`;
|
||||
|
||||
const SIZE = 30;
|
||||
const COLS = ["키", "원문번호", "구분", "상세구분", "이름", "결과 단위", "출처", ""] as const;
|
||||
const COLS = [
|
||||
"키",
|
||||
"원문번호",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"별칭",
|
||||
"결과 단위",
|
||||
"출처",
|
||||
"",
|
||||
] as const;
|
||||
|
||||
export function buildList(onOpen: (item: ListItem) => void): ListHandle {
|
||||
export function buildList(
|
||||
onOpen: (item: ListItem) => void,
|
||||
onAlias: (item: ListItem, alias: string) => void,
|
||||
): ListHandle {
|
||||
let items: ListItem[] = [];
|
||||
let extra: ListItem[] = [];
|
||||
let marks = new Map<string, ListMark>();
|
||||
let aliases = new Map<string, string>();
|
||||
const aliasOf = (x: ListItem): string => aliases.get(x.id) ?? x.alias;
|
||||
let page = 1;
|
||||
const pick = { sub: "", detail: "" };
|
||||
const searchField = createInputField({ type: "search", placeholder: tx("List_Search") });
|
||||
@@ -51,12 +70,19 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
|
||||
const grid = el("div", { className: "m01-master__grid-wrap" });
|
||||
|
||||
const draw = (): void => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const q = search.value;
|
||||
const shown = [...extra, ...items].filter(
|
||||
(x) =>
|
||||
(!pick.sub || x.sub === pick.sub) &&
|
||||
(!pick.detail || x.detail === pick.detail) &&
|
||||
(!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))),
|
||||
matchLogic(q, {
|
||||
키: x.key,
|
||||
원문번호: x.number,
|
||||
이름: x.name,
|
||||
구분: x.sub,
|
||||
상세구분: x.detail,
|
||||
별칭: aliasOf(x),
|
||||
}).ok,
|
||||
);
|
||||
page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE)));
|
||||
const rows = shown.slice((page - 1) * SIZE, page * SIZE).map(line);
|
||||
@@ -87,24 +113,36 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const cell = (v: string): HTMLElement => el("td", { text: v, attrs: { title: v } });
|
||||
const tr = el("tr", {
|
||||
className: `m01-master__table-row${mark ? " is-changed" : ""}`,
|
||||
children: [
|
||||
item.key,
|
||||
item.number,
|
||||
item.sub,
|
||||
item.detail,
|
||||
item.name,
|
||||
item.unit,
|
||||
item.source,
|
||||
state,
|
||||
].map((v) => el("td", { text: v, attrs: { title: v } })),
|
||||
...[item.key, item.number, item.sub, item.detail, item.name].map(cell),
|
||||
aliasCell(item, mark === "deleted"),
|
||||
...[item.unit, item.source, state].map(cell),
|
||||
],
|
||||
});
|
||||
if (item.blocked) tr.title = item.reasons.join("\n");
|
||||
tr.addEventListener("click", () => onOpen(item));
|
||||
return tr;
|
||||
};
|
||||
|
||||
/** 별칭 칸 — 눌러도 로직이 안 열림 · 고친 글은 「a, b」 꼴로 */
|
||||
const aliasCell = (item: ListItem, locked: boolean): HTMLElement => {
|
||||
const input = el("input", {
|
||||
className: "m01-logic__alias",
|
||||
attrs: { type: "text", value: aliasOf(item), placeholder: "—", title: tx("List_Alias") },
|
||||
}) as HTMLInputElement;
|
||||
input.disabled = locked;
|
||||
input.addEventListener("click", (e) => e.stopPropagation());
|
||||
input.addEventListener("change", () => {
|
||||
const next = aliasText(input.value);
|
||||
input.value = next;
|
||||
if (next !== aliasOf(item)) onAlias(item, next);
|
||||
});
|
||||
return el("td", { children: [input] });
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => go(1));
|
||||
let pager = buildPager(0, SIZE, 1, go);
|
||||
|
||||
@@ -128,6 +166,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
|
||||
key: x.키,
|
||||
number: x.원문번호,
|
||||
name: x.이름,
|
||||
alias: x.별칭 ?? "",
|
||||
unit: x.결과단위,
|
||||
source: x.출처,
|
||||
blocked: x.blocked,
|
||||
@@ -135,9 +174,10 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
|
||||
}));
|
||||
draw();
|
||||
},
|
||||
setMarks: (next, more) => {
|
||||
setMarks: (next, more, edited) => {
|
||||
marks = next;
|
||||
extra = more;
|
||||
aliases = edited;
|
||||
draw();
|
||||
},
|
||||
setPick: (sub, detail) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* M01_MasterData_UI_Logic_Pick.ts
|
||||
* 고르기 두 가지
|
||||
* ① 요소 찾기 창 — 그룹 전체에서 이름·키·원문번호로 찾아 호표 줄에 넣음(`GET /elements`)
|
||||
* 로직 그룹은 별칭으로도 찾음(서버) · 별칭으로 맞은 줄은 그 별칭을 작게(공용 `matchLogic` · 208-1)
|
||||
* 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음
|
||||
* ② 재료·기계 바꿔 고르기 — 구분 → 상세구분 → 후보 순으로 좁힘
|
||||
* 바꾼 것은 시험 계산용 복사본에만 · 정본 로직은 그대로
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
|
||||
import {
|
||||
fetchMaterials,
|
||||
searchElements,
|
||||
@@ -77,7 +79,11 @@ export function openPicker(mode: PickMode, group: string, done: PickDone): void
|
||||
const found = await searchElements(select.value, search.value.trim());
|
||||
if (mine !== seq) return; // 최신 응답만
|
||||
count.textContent = tx("Pick_Total", { n: found.total });
|
||||
list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose)));
|
||||
const note = (item: ElementBrief): string =>
|
||||
select.value === "로직"
|
||||
? aliasNote(matchLogic(search.value, { ...item, 키: item.ref, 이름: item.이름 }).alias)
|
||||
: "";
|
||||
list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose, note(item))));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
@@ -97,12 +103,16 @@ function itemRow(
|
||||
item: ElementBrief,
|
||||
mode: PickMode,
|
||||
choose: (item: ElementBrief, column?: string) => void,
|
||||
note = "",
|
||||
): HTMLElement {
|
||||
const text = el("div", {
|
||||
className: "m01-logic__stack",
|
||||
children: [
|
||||
el("strong", { text: `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() }),
|
||||
el("span", { className: "m01-logic__muted", text: item.ref }),
|
||||
el("span", {
|
||||
className: "m01-logic__muted",
|
||||
text: [item.ref, note].filter(Boolean).join(" · "),
|
||||
}),
|
||||
],
|
||||
});
|
||||
if (mode === "table") {
|
||||
|
||||
@@ -266,6 +266,23 @@ input.m01-logic__input {
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 목록 「별칭」 칸(208-1) — 칸 안 글 상자 · 테두리는 올렸을 때만 */
|
||||
.m01-logic__alias {
|
||||
width: 160px;
|
||||
padding: 2px var(--spacing-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-buttons);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.m01-logic__alias:hover,
|
||||
.m01-logic__alias:focus {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.m01-logic__bad {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user