260719_10
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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="확대">
|
||||
+
|
||||
|
||||
+32
-21
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user