260719_10

This commit is contained in:
2026-07-19 19:30:47 +09:00
parent 6ec8fca60e
commit dd13c4e253
5 changed files with 118 additions and 63 deletions
@@ -52,6 +52,36 @@ const CAD_CHANGED_MESSAGE = "aislo:b07:drawing-changed";
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b07:save-request";
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b07:save-response";
/** 측점 간격을 연속 chainage 차이의 최빈값으로 추정한다 (B06 그래프와 동일 방식). */
function inferStationInterval(chainages: number[]): number {
const counts = new Map<number, number>();
const sorted = [...chainages].sort((a, b) => a - b);
for (let index = 1; index < sorted.length; index += 1) {
const difference = sorted[index] - sorted[index - 1];
if (difference <= 0) continue;
const rounded = Math.round(difference * 10) / 10;
counts.set(rounded, (counts.get(rounded) ?? 0) + 1);
}
return (
[...counts.entries()].sort(
([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA,
)[0]?.[0] ?? 1
);
}
/** 측점 번호+나머지 표기 (B06 그래프 영역 횡단도 라벨과 동일 형식, 예: "2+0.0"). */
function stationLabel(chainage: number, interval: number): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
if (Math.abs(remainder) < 0.05) remainder = 0;
if (remainder >= safeInterval - 0.05) {
stationNumber += 1;
remainder = 0;
}
return `${stationNumber}+${remainder.toFixed(1)}`;
}
/** B06 확정 산출물 기반 도면 목록 패널. */
function buildDrawingSidePanel(
drawings: DesignDrawingItem[],
@@ -77,14 +107,20 @@ function buildDrawingSidePanel(
return panel;
}
const groups: [string, DesignDrawingItem[]][] = [
["종단도", drawings.filter((item) => item.kind === "longitudinal")],
["횡단도", drawings.filter((item) => item.kind === "cross")],
const crossChainages = drawings
.filter((item) => item.kind === "cross" && typeof item.chainage_m === "number")
.map((item) => item.chainage_m as number);
const stationInterval = inferStationInterval(crossChainages);
const groups: [string, DesignDrawingItem["kind"], DesignDrawingItem[]][] = [
["종단도", "longitudinal", drawings.filter((item) => item.kind === "longitudinal")],
["횡단도", "cross", drawings.filter((item) => item.kind === "cross")],
];
for (const [label, items] of groups) {
for (const [label, kind, items] of groups) {
if (!items.length) continue;
const section = document.createElement("section");
section.className = "b07-drawing-group";
section.dataset.kind = kind;
const sectionTitle = document.createElement("h3");
sectionTitle.textContent = `${label} ${items.length}`;
section.append(sectionTitle);
@@ -95,14 +131,12 @@ function buildDrawingSidePanel(
button.dataset.drawingId = drawing.id;
button.dataset.confirmed = String(drawing.confirmed);
const name = document.createElement("span");
name.textContent = drawing.label;
const kind = document.createElement("small");
kind.textContent = drawing.confirmed
? "확정"
: drawing.kind === "longitudinal"
? "PROFILE"
: "SECTION";
button.append(name, kind);
name.className = "b07-drawing-button__name";
name.textContent =
drawing.kind === "cross" && typeof drawing.chainage_m === "number"
? stationLabel(drawing.chainage_m, stationInterval)
: drawing.label;
button.append(name);
button.addEventListener("click", () => void onSelect(drawing, button));
section.append(button);
}
@@ -216,11 +250,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentConfirmed = true;
currentDrawing.confirmed = true;
confirmButton.disabled = true;
if (currentButton) {
currentButton.dataset.confirmed = "true";
const status = currentButton.querySelector("small");
if (status) status.textContent = "확정";
}
if (currentButton) currentButton.dataset.confirmed = "true";
showToast("현재 도면을 확정하고 저장했습니다.", "success");
if (result.all_confirmed) {
allDrawingsConfirmed = true;
@@ -243,12 +273,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
allDrawingsConfirmed = false;
currentDrawing.confirmed = false;
confirmButton.disabled = false;
if (currentButton) {
currentButton.dataset.confirmed = "false";
const status = currentButton.querySelector("small");
if (status)
status.textContent = currentDrawing.kind === "longitudinal" ? "PROFILE" : "SECTION";
}
if (currentButton) currentButton.dataset.confirmed = "false";
if (wasConfirmed) {
try {
await invalidateDesignDrawing(projectId, currentDrawing.id);
@@ -46,6 +46,13 @@
gap: var(--spacing-4);
}
/* 횡단도는 2열 배치 */
.b07-drawing-group[data-kind="cross"] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-4);
}
.b07-drawing-group h3 {
margin: var(--spacing-8) 0 var(--spacing-4);
color: var(--color-text-muted);
@@ -53,19 +60,33 @@
font-weight: 600;
}
/* 그리드 제목은 두 열을 가로지른다 */
.b07-drawing-group[data-kind="cross"] h3 {
grid-column: 1 / -1;
}
.b07-drawing-button {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
width: 100%;
min-height: 38px;
padding: var(--spacing-8) var(--spacing-12);
min-height: 34px;
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid transparent;
/* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */
border-left: 3px solid transparent;
border-radius: var(--radius-buttons);
background: transparent;
color: var(--color-text);
cursor: pointer;
text-align: left;
text-align: center;
}
.b07-drawing-button__name {
overflow: hidden;
font-size: var(--text-body-sm);
white-space: nowrap;
text-overflow: ellipsis;
}
.b07-drawing-button:hover,
@@ -78,20 +99,13 @@
color: var(--color-primary);
}
.b07-drawing-button small {
color: var(--color-text-muted);
font-size: 9px;
}
.b07-drawing-button[data-loading="true"] small {
visibility: hidden;
}
/* 확정: 좌측 띠 + 측점 글자색을 함께 성공색으로 반영 */
.b07-drawing-button[data-confirmed="true"] {
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
border-left-color: var(--color-success);
}
.b07-drawing-button[data-confirmed="true"] small {
.b07-drawing-button[data-confirmed="true"] .b07-drawing-button__name {
color: var(--color-success);
}
+7 -3
View File
@@ -164,7 +164,7 @@ body > canvas[data-id="canvas"] {
text-align: center;
}
.cad-prop select,
.cad-prop input[type='number'] {
.cad-prop input[type="number"] {
height: 24px;
min-width: 64px;
padding: 0 4px;
@@ -174,11 +174,11 @@ body > canvas[data-id="canvas"] {
border-radius: 4px;
font-size: 11px;
}
.cad-prop input[type='number'] {
.cad-prop input[type="number"] {
min-width: 48px;
width: 48px;
}
.cad-prop input[type='color'] {
.cad-prop input[type="color"] {
height: 24px;
width: 40px;
padding: 1px;
@@ -332,6 +332,10 @@ body > canvas[data-id="canvas"] {
.cad-view-controls button:hover {
background: #31516b;
}
.cad-view-controls__fit {
color: #7fd0ff;
font-size: 15px;
}
.cad-view-controls span {
min-width: 48px;
color: #9fb0c1;
@@ -425,13 +425,14 @@ export const Toolbar: FC = () => {
<div className="cad-view-controls controls">
<button
type="button"
className="cad-view-controls__fit"
onClick={() => {
getScreenCanvasDrawController().zoomToFitScreen();
refresh();
}}
title="화면에 맞춤"
title="전체 보기 (도면을 화면 중심에 맞춤)"
>
</button>
<button type="button" onClick={() => changeZoom(1.2)} title="확대">
@@ -1,16 +1,10 @@
import { Point, type Vector } from '@flatten-js/core';
import { CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER } from '../App.consts';
import { containRectangle } from '../helpers/contain-rect.ts';
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts';
import { mapNumberRange } from '../helpers/map-number-range.ts';
import { StateVariable } from '../helpers/undo-stack.ts';
import {
getEntities,
getGridEnabled,
getScreenCanvasDrawController,
triggerReactUpdate,
} from '../state.ts';
import { getEntities, getGridEnabled, triggerReactUpdate } from '../state.ts';
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
/**
@@ -122,23 +116,40 @@ export class ScreenCanvasDrawController implements DrawController {
);
}
/**
* 전체 도면(모든 엔티티)을 화면 중심에 여백을 두고 배치한다.
* 가로/세로 중 더 제약이 큰 축에 맞춰 배율을 정하고, 도면 중심이 화면 중심에
* 오도록 screenOffset(월드 좌표)을 역산한다. (기존 구현은 화면 픽셀 여백값을
* 월드 좌표 offset에 그대로 대입해 중심 배치가 어긋나는 문제가 있었다.)
*/
public zoomToFitScreen() {
const boundingBox = getBoundingBoxOfMultipleEntities(getEntities());
const entities = getEntities();
if (!entities.length) return;
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
const boundingWidth = boundingBox.maxX - boundingBox.minX;
const fittedRect = containRectangle(
boundingBox.minX,
boundingBox.minY,
boundingBox.maxX,
boundingBox.maxY,
0,
0,
getScreenCanvasDrawController().getCanvasSize().x,
getScreenCanvasDrawController().getCanvasSize().y
const boundingHeight = boundingBox.maxY - boundingBox.minY;
const canvasSize = this.getCanvasSize();
// 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지)
const FIT_MARGIN = 0.9;
const scaleX =
boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY;
const scaleY =
boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY;
let zoomLevel = Math.min(scaleX, scaleY);
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1;
this.setScreenScale(zoomLevel);
// screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면
// offset = worldCenter - (화면 절반 픽셀) / zoom
const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2;
const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2;
this.setScreenOffset(
new Point(
worldCenterX - canvasSize.x / 2 / zoomLevel,
worldCenterY - canvasSize.y / 2 / zoomLevel
)
);
const fittedWidth = fittedRect.maxX - fittedRect.minX;
const zoomLevel = fittedWidth / boundingWidth;
getScreenCanvasDrawController().setScreenScale(zoomLevel);
getScreenCanvasDrawController().setScreenOffset(new Point(fittedRect.minX, fittedRect.minY));
}
/**