This commit is contained in:
2026-07-20 17:27:45 +09:00
parent 2649b7e325
commit ce82feda30
10 changed files with 572 additions and 349 deletions
+128 -10
View File
@@ -3,7 +3,8 @@
:root {
--cad-title-height: 42px;
--cad-ribbon-height: 82px;
--cad-command-height: 58px;
/* 하단 명령어 입력창 제거 → 높이 0으로 캔버스가 공간을 회수 (추후 사용성 개선 예정) */
--cad-command-height: 0px;
--cad-status-height: 28px;
--cad-panel-width: 248px;
font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
@@ -343,16 +344,9 @@ body > canvas[data-id="canvas"] {
text-align: center;
}
/* 하단 명령어 입력창은 숨김 처리 (마우스 커서 단축키 입력으로 대체) */
.cad-command-area {
position: fixed;
z-index: 3;
right: 0;
bottom: var(--cad-status-height);
left: var(--cad-panel-width);
height: var(--cad-command-height);
padding: 5px 10px;
background: #151c24f2;
border-top: 1px solid #3b4b5a;
display: none;
}
.cad-command-prompt {
display: flex;
@@ -435,6 +429,130 @@ body > canvas[data-id="canvas"] {
font-size: 10px;
}
/* -----------------------------------------------------------------------------
* 수량 산출표 패널 (CAD 화면 하단 중심, 접이식) — 첨부 양식
* -------------------------------------------------------------------------- */
.cad-qtable {
position: fixed;
z-index: 3;
bottom: calc(var(--cad-status-height) + 8px);
left: calc(var(--cad-panel-width) + (100vw - var(--cad-panel-width)) / 2);
transform: translateX(-50%);
max-width: min(720px, calc(100vw - var(--cad-panel-width) - 24px));
border: 1px solid #465565;
border-radius: 6px;
background: #202b37f2;
box-shadow: 0 4px 16px #0009;
color: #dce6f2;
}
.cad-qtable__header {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
}
.cad-qtable__title {
display: flex;
flex: 1;
align-items: baseline;
justify-content: center;
gap: 8px;
min-width: 0;
}
.cad-qtable__title strong {
color: #f7fbff;
font-size: 14px;
}
.cad-qtable__title span {
overflow: hidden;
color: #91a2b5;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cad-qtable__status {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-style: normal;
}
.cad-qtable__status[data-confirmed="true"] {
background: #1f4d33;
color: #7fdca4;
}
.cad-qtable__status[data-confirmed="false"] {
background: #4a3a1c;
color: #e6c07a;
}
.cad-qtable__nav,
.cad-qtable__collapse {
width: 26px;
height: 24px;
border: 1px solid #40505f;
border-radius: 4px;
background: #2a3745;
color: #cdd8e4;
font-size: 13px;
}
.cad-qtable__nav:hover:not(:disabled),
.cad-qtable__collapse:hover {
background: #31516b;
}
.cad-qtable__nav:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.cad-qtable__body {
padding: 0 8px 8px;
overflow-x: auto;
}
.cad-qtable table {
margin: 0 auto;
border-collapse: collapse;
}
.cad-qtable th,
.cad-qtable td {
padding: 0;
border: 1px solid #40505f;
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.cad-qtable__label {
padding: 2px 5px;
color: #a9bccd;
font-weight: 600;
white-space: nowrap;
background: #26333f;
}
.cad-qtable__label--vertical {
width: 18px;
writing-mode: vertical-rl;
text-orientation: upright;
letter-spacing: 1px;
}
.cad-qtable__value input {
width: 48px;
padding: 3px 2px;
border: 0;
background: transparent;
color: #eaf1f8;
font: inherit;
text-align: center;
}
.cad-qtable__value input:focus {
outline: 2px solid #4ca6e8;
outline-offset: -2px;
background: #12324a;
}
.cad-qtable__value--derived input {
color: #8fa3b5;
background: #1a242e;
cursor: not-allowed;
}
@media (max-width: 800px) {
:root {
--cad-panel-width: 200px;
@@ -1,11 +1,13 @@
import './App.css';
import { ToastContainer } from 'react-toastify';
import { QuantityPanel } from './components/QuantityPanel.tsx';
import { Toolbar } from './components/Toolbar.tsx';
function App() {
return (
<div className="cad-app">
<Toolbar />
<QuantityPanel />
<ToastContainer position="bottom-right" theme="light" />
</div>
);
@@ -38,6 +38,21 @@ export enum HtmlEvent {
DRAWING_CHANGED = 'DRAWING_CHANGED',
}
/** 부모(B07 페이지)가 도면과 함께 넘기는 설계 컨텍스트 (수량 패널 표시용). */
export interface DesignMeta {
kind: 'cross' | 'longitudinal';
/** 패널 제목 (측점 라벨, 예: "2+0.0" 또는 "종단도 전체") */
title: string;
/** 측점 부가 정보 (예: "STA.0+050.000") */
info: string;
confirmed: boolean;
/** 수량 산출표 값 (횡단도만). 미산정 항목은 null. */
quantityTable: Record<string, number | null> | null;
/** 이전/다음 도면 존재 여부 (경계에서 버튼 비활성화) */
hasPrev: boolean;
hasNext: boolean;
}
export interface StateMetaData {
instructions: string;
}
@@ -0,0 +1,277 @@
import { type FC, useCallback, useEffect, useRef, useState } from 'react';
import { HtmlEvent } from '../App.types';
import {
notifyDrawingChangedByTable,
requestDrawingNavigation,
} from '../integration/aislo-drawing-bridge';
import { getDesignMeta, setDesignQuantityTable } from '../state';
/** 편집 가능한 항목 키 (cut/fill 은 지반고·계획고에서 파생되는 읽기전용). */
const EDITABLE_KEYS = [
'ground',
'planned',
'cut_soil',
'cut_soft_rock',
'cut_rock',
'tree_removal',
'fill_slope_protection',
'cut_slope_protection',
'ditch_soil',
'ditch_soft_rock',
'ditch_rock',
'embankment',
'grubbing',
'surface_grading',
] as const;
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function formatValue(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value) ? String(round2(value)) : '';
}
function parseValue(raw: string): number | null {
const text = raw.trim();
if (!text) return null;
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}
/** 편집 값 문자열 → 저장용 수치 테이블 (파생 cut/fill 포함) */
function toNumericTable(values: Record<string, string>): Record<string, number | null> {
const table: Record<string, number | null> = {};
for (const key of EDITABLE_KEYS) table[key] = parseValue(values[key] ?? '');
const ground = table.ground;
const planned = table.planned;
if (typeof ground === 'number' && typeof planned === 'number') {
table.cut = Math.max(ground - planned, 0);
table.fill = Math.max(planned - ground, 0);
} else {
table.cut = null;
table.fill = null;
}
return table;
}
interface ValueInputProps {
fieldKey: (typeof EDITABLE_KEYS)[number];
colSpan: number;
values: Record<string, string>;
onEdit: (key: string, value: string) => void;
}
const ValueInput: FC<ValueInputProps> = ({ fieldKey, colSpan, values, onEdit }) => (
<td className="cad-qtable__value" colSpan={colSpan}>
<input
type="text"
inputMode="decimal"
autoComplete="off"
value={values[fieldKey] ?? ''}
onChange={(event) => onEdit(fieldKey, event.target.value)}
/>
</td>
);
const DerivedCell: FC<{ value: string }> = ({ value }) => (
<td className="cad-qtable__value cad-qtable__value--derived" colSpan={1}>
<input type="text" readOnly value={value} tabIndex={-1} />
</td>
);
/**
* 횡단도 수량 산출표 (CAD 화면 내부, 하단 중심 접이식 패널).
* 헤더(제목·측점정보·확정상태·이전/다음)는 접어도 항상 보인다.
*/
export const QuantityPanel: FC = () => {
const [meta, setMeta] = useState(getDesignMeta());
const [collapsed, setCollapsed] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
const identityRef = useRef<string>('');
const refresh = useCallback(() => setMeta(getDesignMeta()), []);
useEffect(() => {
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, [refresh]);
// 새 도면(측점)으로 바뀌면 입력값을 원본값으로 초기화한다.
const identity = meta ? `${meta.kind}:${meta.title}` : '';
useEffect(() => {
if (!meta) return;
if (identityRef.current === identity) return;
identityRef.current = identity;
const next: Record<string, string> = {};
for (const key of EDITABLE_KEYS) next[key] = formatValue(meta.quantityTable?.[key]);
setValues(next);
}, [identity, meta]);
if (!meta) return null;
const handleEdit = (key: string, value: string) => {
const nextValues = { ...values, [key]: value };
setValues(nextValues);
setDesignQuantityTable(toNumericTable(nextValues));
notifyDrawingChangedByTable();
};
const ground = parseValue(values.ground ?? '');
const planned = parseValue(values.planned ?? '');
const cutText =
ground !== null && planned !== null ? formatValue(Math.max(ground - planned, 0)) : '';
const fillText =
ground !== null && planned !== null ? formatValue(Math.max(planned - ground, 0)) : '';
return (
<section className="cad-qtable controls" data-collapsed={collapsed}>
<header className="cad-qtable__header">
<button
type="button"
className="cad-qtable__nav"
disabled={!meta.hasPrev}
title="이전 도면"
onClick={() => requestDrawingNavigation('prev')}
>
‹
</button>
<div className="cad-qtable__title">
<strong>{meta.title}</strong>
{meta.info && <span>{meta.info}</span>}
<em
className="cad-qtable__status"
data-confirmed={meta.confirmed}
title={meta.confirmed ? '설계 확정됨' : '미확정'}
>
{meta.confirmed ? '확정' : '미확정'}
</em>
</div>
<button
type="button"
className="cad-qtable__nav"
disabled={!meta.hasNext}
title="다음 도면"
onClick={() => requestDrawingNavigation('next')}
>
›
</button>
{meta.kind === 'cross' && (
<button
type="button"
className="cad-qtable__collapse"
title={collapsed ? '표 펼치기' : '표 접기'}
onClick={() => setCollapsed((value) => !value)}
>
{collapsed ? '▲' : '▼'}
</button>
)}
</header>
{meta.kind === 'cross' && !collapsed && (
<div className="cad-qtable__body">
<table>
<tbody>
<tr>
<th className="cad-qtable__label" colSpan={2}>
지반고
</th>
<ValueInput fieldKey="ground" colSpan={1} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
계획고
</th>
<ValueInput fieldKey="planned" colSpan={1} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
절토고
</th>
<DerivedCell value={cutText} />
<th className="cad-qtable__label" colSpan={2}>
성토고
</th>
<DerivedCell value={fillText} />
</tr>
<tr>
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={3}>
흙깎기
</th>
<th className="cad-qtable__label">토사</th>
<ValueInput fieldKey="cut_soil" colSpan={2} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
지장목제거
</th>
<ValueInput
fieldKey="tree_removal"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={3}>
옆도랑파기
</th>
<th className="cad-qtable__label">토사</th>
<ValueInput fieldKey="ditch_soil" colSpan={2} values={values} onEdit={handleEdit} />
</tr>
<tr>
<th className="cad-qtable__label">연암</th>
<ValueInput
fieldKey="cut_soft_rock"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={2}>
비탈보호공
</th>
<th className="cad-qtable__label">성토면</th>
<ValueInput
fieldKey="fill_slope_protection"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
<th className="cad-qtable__label">연암</th>
<ValueInput
fieldKey="ditch_soft_rock"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
</tr>
<tr>
<th className="cad-qtable__label">보통암</th>
<ValueInput fieldKey="cut_rock" colSpan={2} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label">절토면</th>
<ValueInput
fieldKey="cut_slope_protection"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
<th className="cad-qtable__label">보통암</th>
<ValueInput fieldKey="ditch_rock" colSpan={2} values={values} onEdit={handleEdit} />
</tr>
<tr>
<th className="cad-qtable__label" colSpan={2}>
흙쌓기
</th>
<ValueInput fieldKey="embankment" colSpan={2} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
제근
</th>
<ValueInput fieldKey="grubbing" colSpan={2} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
노면고르기
</th>
<ValueInput
fieldKey="surface_grading"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
</tr>
</tbody>
</table>
</div>
)}
</section>
);
};
@@ -28,6 +28,7 @@ export enum StateVariable {
activeLineWidth = 'activeLineWidth',
activeLineDash = 'activeLineDash',
activeTextStyle = 'activeTextStyle',
designMeta = 'designMeta',
layers = 'layers',
}
@@ -1,10 +1,12 @@
import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts';
import { HtmlEvent } from '../App.types.ts';
import { type DesignMeta, HtmlEvent } from '../App.types.ts';
import {
getDesignMeta,
getScreenCanvasDrawController,
setActiveLayerId,
setDesignMeta,
setEntities,
setLayers,
} from '../state.ts';
@@ -16,10 +18,12 @@ export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b07:drawing-error';
export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b07:drawing-changed';
export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b07:save-request';
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b07:save-response';
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b07:navigate';
interface DrawingLoadMessage {
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
drawing: JsonDrawingFileSerialized;
meta?: DesignMeta | null;
}
interface DrawingSaveRequestMessage {
@@ -37,6 +41,16 @@ function notifyParent(type: string, payload: Record<string, unknown> = {}) {
window.parent.postMessage({ type, ...payload }, window.location.origin);
}
/** 수량 패널에서 이전/다음 도면으로 이동 요청 (부모가 처리). */
export function requestDrawingNavigation(direction: 'prev' | 'next') {
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
}
/** 수량표 값 편집을 부모에 알린다 (확정 상태 롤백 연동). */
export function notifyDrawingChangedByTable() {
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
}
/**
* B07 parent page와 CAD 앱 사이의 same-origin JSON 경계다.
* DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다.
@@ -50,7 +64,9 @@ export function registerAisloDrawingBridge() {
const drawing = JSON.parse(
await exportEntitiesAndLayersToJsonString()
) as JsonDrawingFileSerialized;
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing });
// 편집된 수량표 값을 도면과 함께 부모로 돌려준다.
const quantityTable = getDesignMeta()?.quantityTable ?? null;
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable });
} catch (error) {
const detail = error instanceof Error ? error.message : 'Unable to serialize drawing';
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
@@ -64,6 +80,8 @@ export function registerAisloDrawingBridge() {
setEntities(drawing.entities, false);
setLayers(drawing.layers);
setActiveLayerId(drawing.layers[0].id);
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
setDesignMeta(event.data.meta ?? null);
getScreenCanvasDrawController().zoomToFitScreen();
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
} catch (error) {
@@ -3,6 +3,7 @@ import { isEqual } from 'es-toolkit';
import { toast } from 'react-toastify';
import type { Actor, MachineSnapshot } from 'xstate';
import {
type DesignMeta,
type HoverPoint,
HtmlEvent,
type Layer,
@@ -164,6 +165,12 @@ let activeLayerId: string = layers[0].id;
let snapEnabled = true;
let gridEnabled = false;
/**
* 부모(B07 페이지)에서 넘어온 설계 컨텍스트. 수량 산출 패널이 이 값을 읽어
* 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다.
*/
let designMeta: DesignMeta | null = null;
// getters
export const getCanvas = () => canvas;
export const getActiveToolActor = () => activeToolActor;
@@ -215,6 +222,7 @@ export const getActiveLayerId = (): string => {
};
export const getSnapEnabled = () => snapEnabled;
export const getGridEnabled = () => gridEnabled;
export const getDesignMeta = (): DesignMeta | null => designMeta;
// setters
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
@@ -392,6 +400,16 @@ export const setGridEnabled = (enabled: boolean) => {
gridEnabled = enabled;
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setDesignMeta = (newMeta: DesignMeta | null) => {
designMeta = newMeta;
triggerReactUpdate(StateVariable.designMeta);
};
/** 사용자가 편집한 수량표 전체를 반영하고 확정 상태를 롤백한다 (저장 대상). */
export const setDesignQuantityTable = (table: Record<string, number | null>) => {
if (!designMeta) return;
designMeta = { ...designMeta, quantityTable: table, confirmed: false };
triggerReactUpdate(StateVariable.designMeta);
};
// Computed setters
export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => {
@@ -414,6 +432,7 @@ const reactStateVariables: StateVariable[] = [
StateVariable.activeLineWidth,
StateVariable.activeLineDash,
StateVariable.activeTextStyle,
StateVariable.designMeta,
StateVariable.screenZoom,
StateVariable.layers,
];