Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
||||
import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
|
||||
import { getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
|
||||
import { drawEntities } from './draw-functions';
|
||||
import { getSceneVersion } from './scene-version';
|
||||
import { queryEntitiesInBox } from './spatial-index';
|
||||
@@ -78,7 +78,8 @@ function rebuildScene(drawController: ScreenCanvasDrawController): void {
|
||||
const savedHighlight = getHighlightedEntityIds();
|
||||
if (savedHighlight.length) setHighlightedEntityIds([]);
|
||||
drawController.withContext(offscreenContext, () => {
|
||||
drawController.clear();
|
||||
// 투명 바탕 — 배경 · 모눈은 화면 clear() 가 그림(모눈이 비트맵에 박히면 매 프레임 다시 그려야 함)
|
||||
offscreenContext.clearRect(0, 0, size.x, size.y);
|
||||
// Style-run batching + sub-pixel decimation: one stroke per style run
|
||||
drawController.beginBatch();
|
||||
drawEntities(drawController, sceneEntitiesForViewport(drawController));
|
||||
@@ -124,13 +125,7 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe
|
||||
const offsetChanged =
|
||||
!!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y);
|
||||
|
||||
// Grid lines are screen-fixed (drawn in clear()), so blitting a shifted
|
||||
// bitmap would drag the grid along — always re-render while grid is on.
|
||||
if (
|
||||
paramsChanged ||
|
||||
getGridEnabled() ||
|
||||
(offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)
|
||||
) {
|
||||
if (paramsChanged || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) {
|
||||
rebuildScene(drawController);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,12 @@ let gridMinX = 0;
|
||||
let gridMinY = 0;
|
||||
let gridCols = 1;
|
||||
|
||||
/** 칸 번호 범위 — 엔티티가 놓인 칸(0~GRID_CELLS_PER_AXIS) 밖은 비었으니 잘라 냄(줌아웃 때 빈 칸 수억 개를 돌지 않게). */
|
||||
function cellRange(min: number, max: number, gridMin: number): [number, number] {
|
||||
return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)];
|
||||
return [
|
||||
Math.max(0, Math.floor((min - gridMin) / cellSize)),
|
||||
Math.min(GRID_CELLS_PER_AXIS, Math.floor((max - gridMin) / cellSize)),
|
||||
];
|
||||
}
|
||||
|
||||
function ensureIndex(): void {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** 줌아웃 느려짐(227-1) — 가짜 캔버스로 한 화면 그리기 비용을 잰다. */
|
||||
const counts = vi.hoisted(() => {
|
||||
const tally = { segments: 0, arcs: 0, strokes: 0, fills: 0, texts: 0 };
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.window = { dispatchEvent: () => true, matchMedia: () => ({ matches: true }) };
|
||||
g.Path2D = class {
|
||||
moveTo() {}
|
||||
lineTo() {
|
||||
tally.segments++;
|
||||
}
|
||||
arc() {
|
||||
tally.arcs++;
|
||||
}
|
||||
};
|
||||
const noop = () => {};
|
||||
g.__fakeContext = {
|
||||
fillRect: noop,
|
||||
clearRect: noop,
|
||||
setLineDash: noop,
|
||||
beginPath: noop,
|
||||
moveTo: noop,
|
||||
lineTo: () => tally.segments++,
|
||||
arc: () => tally.arcs++,
|
||||
closePath: noop,
|
||||
stroke: () => tally.strokes++,
|
||||
fill: () => tally.fills++,
|
||||
save: noop,
|
||||
restore: noop,
|
||||
translate: noop,
|
||||
rotate: noop,
|
||||
fillText: () => tally.texts++,
|
||||
measureText: (label: string) => ({ width: label.length * 7 }),
|
||||
drawImage: noop,
|
||||
};
|
||||
return tally;
|
||||
});
|
||||
|
||||
import type { Layer } from '../App.types';
|
||||
import { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { HatchEntity } from '../entities/HatchEntity';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { MeasurementEntity } from '../entities/MeasurementEntity';
|
||||
import { PolyLineEntity } from '../entities/PolyLineEntity';
|
||||
import { TextEntity } from '../entities/TextEntity';
|
||||
import { setEntities, setGridEnabled, setLayers } from '../state';
|
||||
import { drawScene, invalidateSceneCache, scenePerf } from './scene-cache';
|
||||
import { queryEntitiesInBox } from './spatial-index';
|
||||
|
||||
const LAYER = 'perf';
|
||||
|
||||
function polyline(points: Point[]): PolyLineEntity {
|
||||
const segments = points
|
||||
.slice(1)
|
||||
.map((point, index) => new LineEntity(LAYER, points[index], point));
|
||||
return new PolyLineEntity(LAYER, segments);
|
||||
}
|
||||
|
||||
/** B07 횡단 장 꼴 — 횡단 40 개(지반선 · 계획선 · 절토 · 성토 해치 · 글자 · 치수 · 모눈) + 등고선 */
|
||||
function crossSectionSheet(): Entity[] {
|
||||
const entities: Entity[] = [];
|
||||
for (let index = 0; index < 40; index++) {
|
||||
const ox = (index % 4) * 80;
|
||||
const oy = Math.floor(index / 4) * 30;
|
||||
const ground = Array.from(
|
||||
{ length: 61 },
|
||||
(_, k) => new Point(ox + k, oy + 10 + 3 * Math.sin(k / 7 + index))
|
||||
);
|
||||
entities.push(polyline(ground));
|
||||
const design = [0, 15, 20, 25, 40, 45, 60].map(
|
||||
(x, k) => new Point(ox + x, oy + 9 + (k % 2 ? 1 : 0))
|
||||
);
|
||||
entities.push(polyline(design));
|
||||
const hatchOptions = { style: 'pattern', spacing: 0.3 } as const;
|
||||
entities.push(
|
||||
new HatchEntity(
|
||||
LAYER,
|
||||
[new Point(ox + 15, oy + 9), new Point(ox + 25, oy + 9), new Point(ox + 20, oy + 13)],
|
||||
hatchOptions
|
||||
),
|
||||
new HatchEntity(
|
||||
LAYER,
|
||||
[new Point(ox + 40, oy + 9), new Point(ox + 60, oy + 9), new Point(ox + 50, oy + 6)],
|
||||
hatchOptions
|
||||
)
|
||||
);
|
||||
for (let k = 0; k < 25; k++) {
|
||||
entities.push(
|
||||
new TextEntity(LAYER, `${index}+${k}.00`, new Point(ox + 2 * k, oy + 2), { fontSize: 0.5 })
|
||||
);
|
||||
}
|
||||
for (let k = 0; k < 6; k++) {
|
||||
entities.push(
|
||||
new MeasurementEntity(
|
||||
LAYER,
|
||||
new Point(ox + 10 * k, oy + 8),
|
||||
new Point(ox + 10 * k + 8, oy + 8),
|
||||
new Point(ox + 10 * k, oy + 7)
|
||||
)
|
||||
);
|
||||
}
|
||||
for (let k = 0; k <= 12; k++) {
|
||||
entities.push(
|
||||
new LineEntity(LAYER, new Point(ox + 5 * k, oy), new Point(ox + 5 * k, oy + 20))
|
||||
);
|
||||
}
|
||||
}
|
||||
for (let c = 0; c < 178; c++) {
|
||||
const contour = Array.from(
|
||||
{ length: 85 },
|
||||
(_, k) => new Point(k * 3.7, c * 1.7 + 2 * Math.sin(k / 5 + c))
|
||||
);
|
||||
entities.push(polyline(contour));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
function viewAt(controller: ScreenCanvasDrawController, factor: number, fitScale: number) {
|
||||
const size = controller.getCanvasSize();
|
||||
const center = controller.targetToWorld(new Point(size.x / 2, size.y / 2));
|
||||
const scale = fitScale * factor;
|
||||
controller.setScreenScale(scale);
|
||||
controller.setScreenOffset(
|
||||
new Point(center.x - size.x / 2 / scale, center.y - size.y / 2 / scale)
|
||||
);
|
||||
}
|
||||
|
||||
describe('줌아웃 그리기 비용(227-1)', () => {
|
||||
const controller = new ScreenCanvasDrawController(
|
||||
(globalThis as unknown as { __fakeContext: CanvasRenderingContext2D }).__fakeContext
|
||||
);
|
||||
let fitScale = 1;
|
||||
|
||||
beforeAll(() => {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.document = {
|
||||
documentElement: { hasAttribute: () => false, getAttribute: () => null },
|
||||
createElement: () => ({ width: 0, height: 0, getContext: () => g.__fakeContext }),
|
||||
};
|
||||
g.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
setLayers(
|
||||
[{ id: LAYER, name: LAYER, isVisible: true, isLocked: false } as unknown as Layer],
|
||||
false
|
||||
);
|
||||
setEntities(crossSectionSheet(), false);
|
||||
controller.setCanvasSize(new Point(1600, 900));
|
||||
controller.zoomToFitScreen();
|
||||
fitScale = controller.getScreenScale();
|
||||
});
|
||||
|
||||
it('배율별 한 화면 다시 그리기 — 시간 · 그린 수(보고용 표)', () => {
|
||||
const rows = [];
|
||||
for (const factor of [10, 1, 0.1, 0.01]) {
|
||||
viewAt(controller, factor, fitScale);
|
||||
for (const key of Object.keys(counts)) counts[key as keyof typeof counts] = 0;
|
||||
invalidateSceneCache();
|
||||
const startedAt = performance.now();
|
||||
drawScene(controller, 0);
|
||||
const ms = performance.now() - startedAt;
|
||||
rows.push({ factor, ms: Number(ms.toFixed(1)), ...counts });
|
||||
}
|
||||
console.table(rows);
|
||||
expect(rows).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('화면보다 아주 큰 영역 조회도 빈 칸을 돌지 않음 — 0.001배 상자 50ms 안', () => {
|
||||
viewAt(controller, 1, fitScale);
|
||||
const all = queryEntitiesInBox(-1e9, -1e9, 1e9, 1e9).length;
|
||||
const size = controller.getCanvasSize();
|
||||
const half = (Math.max(size.x, size.y) / (fitScale * 0.001)) * 1.5;
|
||||
const startedAt = performance.now();
|
||||
const found = queryEntitiesInBox(-half, -half, half, half);
|
||||
const ms = performance.now() - startedAt;
|
||||
expect(found.length).toBe(all);
|
||||
expect(ms).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('모눈을 켜도 화면이 그대로면 장면을 다시 그리지 않음', () => {
|
||||
viewAt(controller, 1, fitScale);
|
||||
setGridEnabled(true);
|
||||
drawScene(controller, 0);
|
||||
const rebuilds = scenePerf.sceneRebuilds;
|
||||
drawScene(controller, 1000);
|
||||
drawScene(controller, 2000);
|
||||
setGridEnabled(false);
|
||||
expect(scenePerf.sceneRebuilds).toBe(rebuilds);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createActor } from 'xstate';
|
||||
import { LineEntity } from '../entities/LineEntity.ts';
|
||||
import {
|
||||
getEntities,
|
||||
resetUndoBaseline,
|
||||
setEntities,
|
||||
setSelectedEntityIds,
|
||||
undo,
|
||||
} from '../state.ts';
|
||||
import { ScaleState, scaleToolStateMachine } from './scale-tool.ts';
|
||||
import { ActorEvent } from './tool.types.ts';
|
||||
|
||||
const ends = (index: number) => {
|
||||
const line = getEntities()[index] as LineEntity;
|
||||
const a = line.getStartPoint();
|
||||
const b = line.getEndPoint();
|
||||
return [a.x, a.y, b.x, b.y];
|
||||
};
|
||||
|
||||
/** 기준점 (2,0) 클릭까지 — 선 A(0,0)-(10,0) 고름 · 선 B(0,5)-(4,5) 는 그대로 둘 것 */
|
||||
function startAtBasePoint() {
|
||||
const a = new LineEntity('layer1', new Point(0, 0), new Point(10, 0));
|
||||
const b = new LineEntity('layer1', new Point(0, 5), new Point(4, 5));
|
||||
setEntities([a, b]);
|
||||
resetUndoBaseline();
|
||||
setSelectedEntityIds([a.id]);
|
||||
const actor = createActor(scaleToolStateMachine).start();
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT);
|
||||
actor.send({
|
||||
type: ActorEvent.MOUSE_CLICK,
|
||||
worldMouseLocation: new Point(2, 0),
|
||||
screenMouseLocation: new Point(0, 0),
|
||||
holdingCtrl: false,
|
||||
holdingShift: false,
|
||||
});
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT);
|
||||
return { actor, a, b };
|
||||
}
|
||||
|
||||
const typeNumber = (actor: ReturnType<typeof startAtBasePoint>['actor'], value: number) =>
|
||||
actor.send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: new Point(99, 99) });
|
||||
|
||||
describe('축척 — 기준점 + 숫자 배율 (226-1)', () => {
|
||||
beforeEach(() => setSelectedEntityIds([]));
|
||||
|
||||
it('배율 2 = 기준점 고정 두 배 · 다른 객체 · 순서 그대로', () => {
|
||||
const { actor, a, b } = startAtBasePoint();
|
||||
typeNumber(actor, 2);
|
||||
// (0,0)→(-2,0) · (10,0)→(18,0) — 기준점 (2,0) 은 그대로
|
||||
expect(ends(0)).toEqual([-2, 0, 18, 0]);
|
||||
expect(getEntities()[0].id).toBe(a.id);
|
||||
expect(getEntities()[1]).toBe(b);
|
||||
expect(ends(1)).toEqual([0, 5, 4, 5]);
|
||||
// 끝나면 선택 풀고 다음 고르기로
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_SELECTION);
|
||||
});
|
||||
|
||||
it('배율 0.5 = 절반', () => {
|
||||
const { actor } = startAtBasePoint();
|
||||
typeNumber(actor, 0.5);
|
||||
expect(ends(0)).toEqual([1, 0, 6, 0]);
|
||||
});
|
||||
|
||||
it('되돌리기 한 번에 원래 자리', () => {
|
||||
const { actor } = startAtBasePoint();
|
||||
typeNumber(actor, 2);
|
||||
undo();
|
||||
expect(ends(0)).toEqual([0, 0, 10, 0]);
|
||||
expect(getEntities()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Esc = 취소 — 객체 그대로', () => {
|
||||
const { actor, a, b } = startAtBasePoint();
|
||||
actor.send({ type: ActorEvent.ESC });
|
||||
expect(getEntities()).toEqual([a, b]);
|
||||
expect(ends(0)).toEqual([0, 0, 10, 0]);
|
||||
typeNumber(actor, 2);
|
||||
expect(ends(0)).toEqual([0, 0, 10, 0]);
|
||||
});
|
||||
|
||||
it('배율 0 은 받지 않음', () => {
|
||||
const { actor } = startAtBasePoint();
|
||||
typeNumber(actor, 0);
|
||||
expect(ends(0)).toEqual([0, 0, 10, 0]);
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT);
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,23 @@ import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import type {
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
StateEvent,
|
||||
ToolContext,
|
||||
} from './tool.types';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
@@ -42,6 +50,7 @@ export enum ScaleAction {
|
||||
COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE',
|
||||
DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES',
|
||||
SCALE_SELECTION = 'SCALE_SELECTION',
|
||||
SCALE_SELECTION_BY_FACTOR = 'SCALE_SELECTION_BY_FACTOR',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
}
|
||||
@@ -98,7 +107,7 @@ export const scaleToolStateMachine = createMachine(
|
||||
[ScaleState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to scale',
|
||||
meta: {
|
||||
instructions: 'Select what you want to scale, then ENTER',
|
||||
instructions: '축척할 객체를 선택한 뒤 ENTER.',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheScaleTool',
|
||||
@@ -142,7 +151,7 @@ export const scaleToolStateMachine = createMachine(
|
||||
[ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: {
|
||||
description: 'Select the origin of the scale operation',
|
||||
meta: {
|
||||
instructions: 'Select the origin of the scale operation',
|
||||
instructions: '기준점을 지정하십시오. (Esc 취소)',
|
||||
},
|
||||
always: {
|
||||
actions: ScaleAction.ENABLE_HELPERS,
|
||||
@@ -161,9 +170,17 @@ export const scaleToolStateMachine = createMachine(
|
||||
[ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: {
|
||||
description: 'Select the end of the base scale line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the base scale line',
|
||||
instructions:
|
||||
'배율을 입력하고 ENTER — 또는 끌어서 맞출 기준선 끝을 클릭하십시오. (Esc 취소)',
|
||||
},
|
||||
on: {
|
||||
// 오토캐드 SCALE — 기준점 뒤 숫자 배율(2 = 두 배 · 0.5 = 절반)
|
||||
NUMBER_INPUT: {
|
||||
guard: ({ event }) =>
|
||||
(event as NumberInputEvent).value > 0 && getSelectedEntityIds().length > 0,
|
||||
actions: [ScaleAction.SCALE_SELECTION_BY_FACTOR, ScaleAction.DESELECT_ENTITIES],
|
||||
target: ScaleState.INIT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
ScaleAction.RECORD_BASE_VECTOR_END_POINT,
|
||||
@@ -180,7 +197,7 @@ export const scaleToolStateMachine = createMachine(
|
||||
[ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: {
|
||||
description: 'Select the end of the scale line',
|
||||
meta: {
|
||||
instructions: 'Select the end of the scale line',
|
||||
instructions: '새 길이의 끝을 클릭하십시오. (Esc 취소)',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
@@ -235,7 +252,9 @@ export const scaleToolStateMachine = createMachine(
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
),
|
||||
};
|
||||
}),
|
||||
[ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => {
|
||||
@@ -282,6 +301,24 @@ export const scaleToolStateMachine = createMachine(
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[ScaleAction.SCALE_SELECTION_BY_FACTOR]: ({ context, event }) => {
|
||||
const origin = context.baseVectorStartPoint;
|
||||
if (!origin) {
|
||||
throw new Error('[SCALE] Calling scale by factor without a base point');
|
||||
}
|
||||
const factor = (event as NumberInputEvent).value;
|
||||
const selectedIds = new Set(getSelectedEntityIds());
|
||||
// 제자리 교체 — 복사본을 키워 같은 순서에 넣고 되돌리기 한 칸으로 남긴다
|
||||
setEntities(
|
||||
getEntities().map((entity) => {
|
||||
if (!selectedIds.has(entity.id)) return entity;
|
||||
const scaled = cloneKeepingLook(entity, true);
|
||||
scaled.scale(origin, factor);
|
||||
return scaled;
|
||||
}),
|
||||
true
|
||||
);
|
||||
},
|
||||
[ScaleAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
|
||||
@@ -32,7 +32,7 @@ const GUIDE_MORE =
|
||||
"위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " +
|
||||
"끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " +
|
||||
"키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " +
|
||||
"묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭";
|
||||
"묶음 줄로 바꾸기 · 추가 · 지우기 = 줄 우클릭";
|
||||
|
||||
let picked: string | null = null;
|
||||
/** 지금 그린 도구 줄 — 키가 부름 */
|
||||
|
||||
@@ -243,6 +243,13 @@ export interface StructureNumbers {
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>> =>
|
||||
scoped("/structures/numbers");
|
||||
|
||||
/** 같은 번호 목록을 **고른 층**(+ 주인)에서 — 지금 층 길을 안 바꾸고 읽음(고르기 모달 상단 표 · 224-1) */
|
||||
export const fetchStructureNumbersAt = (
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
): Promise<Record<string, StructureNumbers>> =>
|
||||
call(owned(layer, `/layers/${layer}/structures/numbers`, owner));
|
||||
|
||||
/** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */
|
||||
export interface StructureCombo extends StructureNumbers {
|
||||
키: string;
|
||||
|
||||
@@ -86,6 +86,17 @@ export function laborSumState(
|
||||
return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined;
|
||||
}
|
||||
|
||||
/** 구조물 조합 표 번호 · 합계 줄 글(86-3 · 101-1) — 관리 화면과 고르기 모달 상단 표(224-1)가 같은 한 벌 */
|
||||
export function numberLabels(numbers: () => ColumnNumbers) {
|
||||
return {
|
||||
laborLabel: (colId: string) => numbers()[colId]?.일위대가번 ?? "",
|
||||
drawingLabel: (colId: string) => numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => numbers()[colId]?.면적번 ?? "",
|
||||
laborSumLabel: (colId: string) => laborSumText(numbers()[colId]),
|
||||
laborSumState: (colId: string) => laborSumState(numbers()[colId]),
|
||||
};
|
||||
}
|
||||
|
||||
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
|
||||
export interface StructureColumn {
|
||||
id: string;
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
comboMerge,
|
||||
comboView,
|
||||
createStructurePanel,
|
||||
laborSumState,
|
||||
laborSumText,
|
||||
numberLabels,
|
||||
pillsFor,
|
||||
STRUCTURE_TABLE,
|
||||
type StructureColumn,
|
||||
@@ -381,11 +380,7 @@ export function createMain(
|
||||
onChange: () => (dirty = !readOnly),
|
||||
...(struct
|
||||
? {
|
||||
laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "",
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
|
||||
laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1
|
||||
...numberLabels(struct.numbers), // 번호 · 일위대가 합계 줄(86-3 · 101-1) — 고르기 모달 상단 표와 한 벌
|
||||
combo: {
|
||||
...(readOnly || !extras.onHeadRename
|
||||
? {}
|
||||
|
||||
@@ -120,3 +120,4 @@
|
||||
94. 캐드 프로그램에서 스케일 기능은 잘되지만 추가로 오토캐드처럼 오리진을 지정하고 숫자로 스케일 값을 입력을 받게 추가
|
||||
95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문)
|
||||
96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선
|
||||
97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율) 단위검증 헬퍼 —
|
||||
/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기 · 221-2 스크롤) 단위검증 헬퍼 —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -37,6 +37,8 @@ function fakeWindow(doc) {
|
||||
(win.calls = win.calls.filter((c) => c.type !== type || c.fn !== fn));
|
||||
win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq);
|
||||
win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id));
|
||||
win.requestAnimationFrame = (fn) => win.setTimeout(fn, 16); // 한 화면 틀
|
||||
win.cancelAnimationFrame = win.clearTimeout;
|
||||
win.tick = (ms) => {
|
||||
win.now += ms;
|
||||
for (const t of win.timers.filter((t) => t.at <= win.now)) {
|
||||
@@ -271,6 +273,98 @@ r.origin = {
|
||||
bottomRight: m.loupeOrigin(995, 795, 1000, 800),
|
||||
};
|
||||
|
||||
{
|
||||
// 221-1 CAD 영역 — 위에선 안 켬 · 켜진 채 들어가면 숨김 · 나오면 보임 · 휠은 CAD 로
|
||||
const cad = { tagName: "IFRAME", closest: (sel) => (sel === m.LOUPE_SKIP ? {} : null) };
|
||||
const plain = { tagName: "DIV", closest: () => null };
|
||||
const kinds = (v) => v.log.map((l) => l[0]);
|
||||
r.cad = {
|
||||
isCad: [m.isCadTarget(cad), m.isCadTarget(plain), m.isCadTarget(null), m.isCadTarget({})],
|
||||
skip: m.LOUPE_SKIP,
|
||||
};
|
||||
{
|
||||
// Z 를 CAD 위에서 누름 — 안 켬 · 나오면 보임 · 떼면 끔
|
||||
const { win, view } = setup();
|
||||
win.fire("mouseover", { target: cad });
|
||||
win.fire("keydown", { code: "KeyZ", target: cad });
|
||||
const onCad = view.log.length;
|
||||
const wheel = win.fire("wheel", { deltaY: -100, target: cad });
|
||||
win.fire("mouseover", { target: plain, clientX: 50, clientY: 60 });
|
||||
const out = view.log[0];
|
||||
win.fire("keyup", { code: "KeyZ" });
|
||||
r.cad.keyOnCad = { onCad, wheelPrevented: wheel.prevented, out, off: last(view) };
|
||||
}
|
||||
{
|
||||
// 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임
|
||||
const { win, view } = setup();
|
||||
win.fire("keydown", { code: "KeyZ" });
|
||||
win.fire("wheel", { deltaY: -100 }); // 2.25
|
||||
win.fire("mouseover", { target: cad });
|
||||
const hidden = last(view);
|
||||
const n = view.log.length;
|
||||
const wheel = win.fire("wheel", { deltaY: -100, target: cad });
|
||||
win.fire("mousemove", { target: cad, clientX: 5, clientY: 5 });
|
||||
const quiet = view.log.length === n; // 숨긴 동안 move · 배율 안 부름
|
||||
win.fire("mouseover", { target: plain, clientX: 70, clientY: 80 });
|
||||
const back = view.log.slice(n);
|
||||
win.fire("mousemove", { target: cad, clientX: 6, clientY: 6 });
|
||||
const moveHide = last(view);
|
||||
win.fire("mousemove", { target: plain, clientX: 7, clientY: 7 });
|
||||
r.cad.enter = {
|
||||
hidden,
|
||||
wheel: { prevented: wheel.prevented, stopped: wheel.stopped },
|
||||
quiet,
|
||||
back,
|
||||
moveHide,
|
||||
moveBack: kinds(view).slice(-3),
|
||||
};
|
||||
}
|
||||
{
|
||||
// 우클릭 길게 — CAD 위면 안 켜고 메뉴 그대로
|
||||
const { win, view } = setup();
|
||||
win.fire("mousedown", { button: 2, buttons: 2, target: cad });
|
||||
win.tick(1000);
|
||||
win.fire("mouseup", { button: 2, target: cad });
|
||||
const menu = win.fire("contextmenu", { target: cad });
|
||||
r.cad.press = { log: view.log.length, menuPrevented: menu.prevented };
|
||||
}
|
||||
{
|
||||
// 초점이 CAD iframe 으로 넘어감(cad_host) — 끄지 않고 숨김 · Z 떼기는 그쪽 창에서
|
||||
const { win, view } = setup();
|
||||
const frameCalls = [];
|
||||
const frame = {
|
||||
...cad,
|
||||
contentWindow: { addEventListener: (type, fn, cap) => frameCalls.push({ type, fn, cap }) },
|
||||
};
|
||||
win.document = { activeElement: frame };
|
||||
win.fire("keydown", { code: "KeyZ" });
|
||||
win.fire("blur", { target: win });
|
||||
const blurHidden = kinds(view);
|
||||
win.document = { activeElement: plain };
|
||||
const up = frameCalls.find((c) => c.type === "keyup");
|
||||
up.fn({ code: "KeyZ" });
|
||||
win.fire("mouseover", { target: plain });
|
||||
const afterRelease = kinds(view);
|
||||
// 우클릭 누른 채 들어감 — 나오면 다시 보임
|
||||
const b = setup();
|
||||
b.win.fire("mousedown", { button: 2, buttons: 2 });
|
||||
b.win.tick(400);
|
||||
b.win.document = { activeElement: frame };
|
||||
b.win.fire("blur", { target: b.win });
|
||||
b.win.document = { activeElement: plain };
|
||||
b.win.fire("mousemove", { buttons: 2, target: plain });
|
||||
const pressBack = kinds(b.view);
|
||||
b.win.fire("mouseup", { button: 2 });
|
||||
r.cad.blur = {
|
||||
blurHidden,
|
||||
frameKeyup: up && up.cap,
|
||||
afterRelease,
|
||||
pressBack,
|
||||
pressOff: last(b.view),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 가짜 요소 ----
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
class El {
|
||||
@@ -282,7 +376,7 @@ class El {
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
this.attrs = {};
|
||||
this.scrollTop = opts.scrollTop || 0;
|
||||
this.st = opts.scrollTop || 0;
|
||||
this.scrollLeft = 0;
|
||||
this.width = opts.width || 0;
|
||||
this.height = opts.height || 0;
|
||||
@@ -299,6 +393,17 @@ class El {
|
||||
getBoundingClientRect() {
|
||||
return this.rect;
|
||||
}
|
||||
// 실제 DOM 처럼 숨긴(hidden) 조상 아래에선 스크롤을 적어도 안 먹음
|
||||
get scrollTop() {
|
||||
return this.st;
|
||||
}
|
||||
set scrollTop(v) {
|
||||
for (let e = this; e; e = e.parent) if (e.hidden) return;
|
||||
this.st = v;
|
||||
}
|
||||
get classList() {
|
||||
return { contains: (k) => this.className.split(" ").includes(k) };
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attrs[k] = v;
|
||||
}
|
||||
@@ -370,8 +475,15 @@ function makePage() {
|
||||
contentDocument: frameDoc,
|
||||
rect: { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 },
|
||||
});
|
||||
const cadBox = new El("DIV", {
|
||||
rect: { left: 0, top: 300, right: 400, bottom: 600, width: 400, height: 300 },
|
||||
});
|
||||
cadBox.className = "cad-host";
|
||||
cadBox.append(new El("IFRAME"), new El("A"));
|
||||
scroller.scrollHeight = 900; // 스크롤될 수 있는 칸
|
||||
scroller.clientHeight = 300;
|
||||
scroller.append(canvas, blank, input, check);
|
||||
app.append(scroller, offscreen, table, svg, frame);
|
||||
app.append(scroller, offscreen, table, svg, frame, cadBox);
|
||||
return app;
|
||||
}
|
||||
function fakeDocument(app) {
|
||||
@@ -406,7 +518,7 @@ function fakeDocument(app) {
|
||||
const stage = root.children[0];
|
||||
const page = stage.children[0];
|
||||
const copy = page.children[0];
|
||||
const [scroller, offscreen, table, svg, frame] = copy.children;
|
||||
const [scroller, offscreen, table, svg, frame, cadBox] = copy.children;
|
||||
const [canvas, blank, input, check] = scroller.children;
|
||||
r.capture = {
|
||||
rootClass: root.className,
|
||||
@@ -429,7 +541,10 @@ function fakeDocument(app) {
|
||||
svgKept: svg.children.length,
|
||||
frameTag: frame.tagName,
|
||||
frameDrawn: frame.drawn,
|
||||
frameSize: [frame.width, frame.height],
|
||||
frameSize: [frame.style.width, frame.style.height],
|
||||
frameEmpty: frame.children.length,
|
||||
cadBoxEmpty: cadBox.children.length,
|
||||
cadBoxSize: [cadBox.style.width, cadBox.style.height],
|
||||
watching: observers[0].watching === app,
|
||||
observeOpts: observers[0].opts,
|
||||
scrollListen: win.calls.some((c) => c.type === "scroll" && c.capture),
|
||||
@@ -478,4 +593,46 @@ function fakeDocument(app) {
|
||||
r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform];
|
||||
r.hide.reuseRoot = doc.body.children.length;
|
||||
}
|
||||
{
|
||||
// 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서)
|
||||
const app = makePage();
|
||||
const scroller = app.children[0];
|
||||
const doc = fakeDocument(app);
|
||||
const win = fakeWindow(doc);
|
||||
win.MutationObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
const view = m.createLoupeView(win);
|
||||
const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0];
|
||||
const page = () => doc.body.children[0].children[0].children[0];
|
||||
view.show(500, 400);
|
||||
view.hide();
|
||||
scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘
|
||||
view.show(500, 400);
|
||||
const zero = cloneScroller().scrollTop;
|
||||
view.hide();
|
||||
scroller.scrollTop = 260; // 꺼진 동안 스크롤해 둠 → 다시 켬(숨긴 원을 다시 씀)
|
||||
view.show(500, 400);
|
||||
const before = cloneScroller().scrollTop;
|
||||
// 켠 채 스크롤 — 다음 화면 틀에 위치만 · 같은 복제
|
||||
const first = cloneScroller();
|
||||
scroller.scrollTop = 400;
|
||||
app.rect = { ...app.rect, top: -50, bottom: 750 }; // 문서도 50 내려감
|
||||
const scroll = () => win.calls.filter((c) => c.type === "scroll").forEach((c) => c.fn({}));
|
||||
scroll();
|
||||
scroll(); // 한 틀에 한 번으로 묶음
|
||||
const rafs = win.timers.filter((t) => t.at === win.now + 16).length;
|
||||
win.tick(16);
|
||||
const during = {
|
||||
top: cloneScroller().scrollTop,
|
||||
same: cloneScroller() === first,
|
||||
pageTop: page().style.top,
|
||||
};
|
||||
win.tick(484); // 0.5 초 뒤 다시 뜸 — 비워 둔 칸을 채움 · 스크롤은 그대로
|
||||
const recaptured = { same: cloneScroller() === first, top: cloneScroller().scrollTop };
|
||||
view.hide();
|
||||
const n = win.timers.length;
|
||||
r.scroll = { zero, before, rafs, during, recaptured, timersAfterHide: n };
|
||||
}
|
||||
console.log(JSON.stringify(r));
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/* 고르기 모달 상단 표(224-1) 시험 헬퍼 — `ui_template_item_picker_table.ts` 를 트랜스파일해
|
||||
* 표 부품 · 집계표 모듈 · 서버 읽기를 대역으로 갈아 끼우고 돌려 부름 기록을 JSON 한 벌로 냄.
|
||||
* 동적 `import()` 도 CommonJS 변환에서 같은 `require` 길을 타 대역이 먹음.
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const UI = path.join(ROOT, "ui_template");
|
||||
const log = [];
|
||||
const state = { numbersFail: false, tableFail: null };
|
||||
|
||||
const node = (props = {}) => ({
|
||||
className: props.className ?? "",
|
||||
hidden: "attrs" in props && props.attrs && "hidden" in props.attrs,
|
||||
text: props.text ?? "",
|
||||
children: props.children ?? [],
|
||||
replaceChildren(...kids) {
|
||||
this.children = kids;
|
||||
},
|
||||
});
|
||||
|
||||
const STUBS = {
|
||||
"./ui_template_elements": { el: (_tag, props) => node(props) },
|
||||
"./ui_template_locale": { t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중") },
|
||||
"../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch": {
|
||||
readTemplate: async (...args) => {
|
||||
log.push(["readTemplate", ...args]);
|
||||
if (state.tableFail) throw new Error(state.tableFail);
|
||||
return { 문서: { 열: [], tag: `${args[0]}|${args[4]}` } };
|
||||
},
|
||||
fetchStructureNumbersAt: async (...args) => {
|
||||
log.push(["numbers", ...args]);
|
||||
if (state.numbersFail) throw new Error("numbers down");
|
||||
return { rv1: { 도번: "SD1" } };
|
||||
},
|
||||
},
|
||||
"../M02_MasterTemplete/M02_MasterTemplete_Structure": {
|
||||
STRUCTURE_TABLE: "구조물집계표",
|
||||
comboView: (doc) => ({ ...doc, comboView: true }),
|
||||
numberLabels: (numbers) => ({ laborLabel: () => "", seenNumbers: numbers() }),
|
||||
},
|
||||
"./sheet/ui_template_sheet": {
|
||||
createSheet: (host, doc, opts) => {
|
||||
const entry = { doc, opts, selected: [], destroyed: false, host };
|
||||
log.push(["createSheet", entry]);
|
||||
host.replaceChildren({ sheet: true });
|
||||
return {
|
||||
selectColumn: (id) => entry.selected.push(id),
|
||||
destroy: () => (entry.destroyed = true),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const row = (key, 열, layer, owner) => ({ key, 열, layer, owner });
|
||||
const names = () => log.map((entry) => entry[0]);
|
||||
const creates = () => log.filter((entry) => entry[0] === "createSheet").map((entry) => entry[1]);
|
||||
|
||||
(async () => {
|
||||
const { createComboTable } = loadTs(path.join(UI, "ui_template_item_picker_table.ts"));
|
||||
const data = loadTs(path.join(UI, "ui_template_item_picker_data.ts"));
|
||||
const out = {};
|
||||
const mine = row("SS1", "rv1", "personal", "1/3");
|
||||
const sameLayer = row("SS2", "rv2", "personal", "1/3");
|
||||
const master = row("SS9", "rv9", "system", null);
|
||||
|
||||
const table = createComboTable();
|
||||
out.startHidden = table.root.hidden;
|
||||
table.show(null);
|
||||
out.nullHidden = table.root.hidden;
|
||||
out.nullCalls = names();
|
||||
|
||||
table.show(mine);
|
||||
out.shownHidden = table.root.hidden;
|
||||
await flush();
|
||||
out.firstReads = log.filter((e) => e[0] !== "createSheet");
|
||||
const first = creates()[0];
|
||||
out.first = {
|
||||
count: creates().length,
|
||||
readOnly: first.opts.readOnly,
|
||||
lockHead: first.opts.lockHead,
|
||||
mode: first.opts.mode,
|
||||
combo: !!first.opts.combo,
|
||||
comboDoc: first.doc.comboView,
|
||||
docTag: first.doc.tag,
|
||||
numbersSeen: first.opts.seenNumbers,
|
||||
selected: first.selected,
|
||||
};
|
||||
|
||||
log.length = 0;
|
||||
table.show(sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만
|
||||
await flush();
|
||||
out.sameLayer = { calls: names(), selected: first.selected, destroyed: first.destroyed };
|
||||
|
||||
log.length = 0;
|
||||
table.show(master); // 다른 층 — 그 층 표로 바뀜
|
||||
await flush();
|
||||
out.otherLayer = {
|
||||
reads: log.filter((e) => e[0] === "readTemplate"),
|
||||
creates: creates().length,
|
||||
oldDestroyed: first.destroyed,
|
||||
selected: creates()[0].selected,
|
||||
docTag: creates()[0].doc.tag,
|
||||
};
|
||||
|
||||
log.length = 0;
|
||||
table.show(null);
|
||||
out.hideAgain = table.root.hidden;
|
||||
|
||||
// 읽는 사이에 다른 조합을 고름 — 마지막 것만 그려짐
|
||||
log.length = 0;
|
||||
table.show(mine);
|
||||
table.show(master);
|
||||
await flush();
|
||||
out.race = { creates: creates().length, docTag: creates()[0]?.doc.tag };
|
||||
|
||||
// 번호 읽기 실패 — 표는 그려짐
|
||||
log.length = 0;
|
||||
state.numbersFail = true;
|
||||
const lone = createComboTable();
|
||||
lone.show(mine);
|
||||
await flush();
|
||||
out.numbersFail = { creates: creates().length, seen: creates()[0]?.opts.seenNumbers };
|
||||
state.numbersFail = false;
|
||||
|
||||
// 표 읽기 실패 — 까닭 글 · 표 없음
|
||||
log.length = 0;
|
||||
state.tableFail = "그 층은 아직 만든 것이 없음";
|
||||
const broken = createComboTable();
|
||||
broken.show(mine);
|
||||
await flush();
|
||||
out.tableFail = { creates: creates().length, text: broken.root.children[0].text };
|
||||
|
||||
out.keys = {
|
||||
mineSame: data.tableKey(mine) === data.tableKey(sameLayer),
|
||||
layerDiffers: data.tableKey(mine) !== data.tableKey(master),
|
||||
ownerDiffers: data.tableKey(mine) !== data.tableKey(row("SS1", "rv1", "personal", "1/4")),
|
||||
mine: data.tableKey(mine),
|
||||
};
|
||||
console.log(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -49,7 +49,16 @@ def test_크기_배율_상수(r):
|
||||
|
||||
def test_창_캡처_단계로_들음(r):
|
||||
assert r["listeners"] == [
|
||||
[t, True] for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup")
|
||||
[t, True]
|
||||
for t in (
|
||||
"mousemove",
|
||||
"mouseover",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
"contextmenu",
|
||||
"keydown",
|
||||
"keyup",
|
||||
)
|
||||
] + [["wheel", {"capture": True, "passive": False}], ["blur", True]]
|
||||
|
||||
|
||||
@@ -139,8 +148,9 @@ def test_화면_뜨기(r):
|
||||
assert c["offscreenChildren"] == 0 and c["offscreenSize"] == ["100px", "100px"] # 보이는 영역만
|
||||
assert c["rowKept"] == 1 # 표 줄은 칸 너비 때문에 그대로
|
||||
assert c["svgKept"] == 1
|
||||
assert c["frameTag"] == "CANVAS" and c["frameDrawn"] == [["cad", 5, 6, 300, 200]]
|
||||
assert c["frameSize"] == [320, 240]
|
||||
# iframe(CAD) 은 복제하지 않음 — 같은 크기 빈 상자(221-1)
|
||||
assert c["frameTag"] == "DIV" and c["frameDrawn"] == [] and c["frameEmpty"] == 0
|
||||
assert c["frameSize"] == ["320px", "240px"]
|
||||
assert c["watching"] and c["observeOpts"]["subtree"] and c["scrollListen"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""돋보기 CAD 영역 빼기(계획서 221-1) 회귀검증 — CAD(cad_host 상자 · iframe) 위에선 안 켬 ·
|
||||
켜진 채 들어가면 숨김 · 나오면 다시 보임(배율 그대로) · CAD 위 휠은 안 막음 · 복제에서 CAD 는 빈칸.
|
||||
|
||||
218-1 헬퍼(`helper_218_1_loupe.cjs`)의 CAD 장면을 판정함.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_218_1_loupe.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_CAD_판정(r):
|
||||
c = r["cad"]
|
||||
assert c["skip"] == ".cad-host, iframe"
|
||||
assert c["isCad"] == [True, False, False, False]
|
||||
|
||||
|
||||
def test_CAD_위_Z_안_켬_나오면_보임(r):
|
||||
k = r["cad"]["keyOnCad"]
|
||||
assert k["onCad"] == 0 and k["wheelPrevented"] is False
|
||||
assert k["out"] == ["show", 50, 60] # 나온 자리에 뜸
|
||||
assert k["off"] == "hide"
|
||||
|
||||
|
||||
def test_CAD_위_우클릭_길게_안_켬_메뉴_그대로(r):
|
||||
assert r["cad"]["press"] == {"log": 0, "menuPrevented": False}
|
||||
|
||||
|
||||
def test_켜진_채_CAD_위_숨김_휠_안_막음_나오면_배율_그대로(r):
|
||||
e = r["cad"]["enter"]
|
||||
assert e["hidden"] == "hide"
|
||||
assert e["wheel"] == {"prevented": False, "stopped": False} # CAD 휠 줌 그대로
|
||||
assert e["quiet"] # 숨긴 동안 원을 안 움직임
|
||||
assert e["back"] == [["show", 70, 80], ["zoom", 2.25]]
|
||||
assert e["moveHide"] == "hide" and e["moveBack"] == ["show", "zoom", "move"]
|
||||
|
||||
|
||||
def test_초점이_CAD_로_넘어가도_끄지_않고_숨김(r):
|
||||
b = r["cad"]["blur"]
|
||||
assert b["blurHidden"] == ["show", "hide"]
|
||||
assert b["frameKeyup"] is True # Z 떼기는 CAD 창에서 들음
|
||||
assert b["afterRelease"] == ["show", "hide"] # 뗀 뒤 나와도 안 뜸
|
||||
assert b["pressBack"] == ["show", "hide", "show", "move"] # 우클릭 누른 채 나오면 다시
|
||||
assert b["pressOff"] == "hide"
|
||||
|
||||
|
||||
def test_복제에서_CAD_빈칸(r):
|
||||
c = r["capture"]
|
||||
assert c["frameTag"] == "DIV" and c["frameEmpty"] == 0
|
||||
assert c["frameSize"] == ["320px", "240px"]
|
||||
assert c["cadBoxEmpty"] == 0 and c["cadBoxSize"] == ["400px", "300px"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""돋보기 스크롤 따라감(계획서 221-2) 회귀검증 — 스크롤해 둔 뒤 켜도 원 = 지금 화면 ·
|
||||
켠 채 스크롤하면 다음 화면 틀(rAF 한 번)에 위치만 맞춤 · 0.5 초 뒤 다시 뜸 · 끄면 예약 없음.
|
||||
|
||||
218-1 헬퍼(`helper_218_1_loupe.cjs`)의 스크롤 장면을 판정함(숨긴 원 안에선 스크롤이 안 먹는 실제 동작 흉내).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def s():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_218_1_loupe.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)["scroll"]
|
||||
|
||||
|
||||
def test_스크롤한_뒤_켬(s):
|
||||
assert s["zero"] == 0
|
||||
assert s["before"] == 260 # 다시 켤 때도 지금 스크롤 그대로
|
||||
|
||||
|
||||
def test_켠_채_스크롤_위치만_맞춤(s):
|
||||
assert s["rafs"] == 1 # 스크롤 두 번 → 한 틀에 한 번
|
||||
assert s["during"] == {"top": 400, "same": True, "pageTop": "-50px"} # 칸 · 문서 스크롤
|
||||
|
||||
|
||||
def test_켠_채_스크롤_0_5초_뒤_다시_뜸(s):
|
||||
assert s["recaptured"] == {"same": False, "top": 400}
|
||||
assert s["timersAfterHide"] == 0
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 224-1 — B05/B06 구조물 조합 고르기 모달 맨 위 = M02 구조물조합 상단 표(구조물집계표).
|
||||
|
||||
① 행동(Node · 대역): 모달 상단 표 부품(`ui_template_item_picker_table.ts`)을 표 부품 · 집계표 모듈 ·
|
||||
서버 읽기 대역 위에서 돌려 — 고른 항목의 층 표를 보기 전용으로 그리고, 같은 층 다른 조합 = 열만 옮김 ·
|
||||
다른 층 = 표가 바뀜 · 읽는 사이 바뀌면 마지막 것만 · 번호 읽기 실패해도 표 · 표 읽기 실패 = 까닭 글.
|
||||
② 같은 부품(소스): M02 관리 화면 · 모달이 같은 `createSheet` · 같은 줄 글 도우미(`numberLabels`) · 같은
|
||||
숨김 열 규칙(`comboView`)을 씀 · 모달이 상단 표를 배선(고르면 보임 · 편집 화면에선 숨김).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
UI = ROOT / "ui_template"
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_224_1_picker_top_table.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_처음엔_숨김_고르면_보임_비우면_다시_숨김(out):
|
||||
assert out["startHidden"] is True
|
||||
assert out["nullHidden"] is True
|
||||
assert out["nullCalls"] == [] # 고른 것 없으면 서버도 안 부름
|
||||
assert out["shownHidden"] is False
|
||||
assert out["hideAgain"] is True
|
||||
|
||||
|
||||
def test_고른_항목_층의_집계표를_보기_전용으로(out):
|
||||
# 그 층 · 그 주인의 구조물집계표 + 번호 목록
|
||||
assert out["firstReads"] == [
|
||||
["readTemplate", "personal", "table", "구조물집계표", None, "1/3"],
|
||||
["numbers", "personal", "1/3"],
|
||||
]
|
||||
first = out["first"]
|
||||
assert first["count"] == 1
|
||||
assert first["readOnly"] is True # 보기 전용 — 고침은 M02 에서
|
||||
assert first["lockHead"] is True
|
||||
assert first["combo"] is True # M02 「구조물 조합」 상단 표 꼴
|
||||
assert first["comboDoc"] is True # 숨김 열 규칙 = M02 와 같은 `comboView`
|
||||
assert first["docTag"] == "personal|1/3"
|
||||
assert first["numbersSeen"] == {"rv1": {"도번": "SD1"}} # 줄 글 도우미가 같은 번호 목록을 받음
|
||||
|
||||
|
||||
def test_같은_층_다른_조합은_열만_옮김_다른_층은_표가_바뀜(out):
|
||||
same = out["sameLayer"]
|
||||
assert same["calls"] == [] # 서버 · 표 다시 안 그림
|
||||
assert same["selected"] == ["rv1", "rv2"]
|
||||
assert same["destroyed"] is False
|
||||
other = out["otherLayer"]
|
||||
assert other["reads"] == [["readTemplate", "system", "table", "구조물집계표", None, None]]
|
||||
assert other["creates"] == 1
|
||||
assert other["oldDestroyed"] is True # 옛 표를 거둠
|
||||
assert other["selected"] == ["rv9"]
|
||||
assert other["docTag"] == "system|null"
|
||||
|
||||
|
||||
def test_읽는_사이_바뀌면_마지막_조합만(out):
|
||||
assert out["race"] == {"creates": 1, "docTag": "system|null"}
|
||||
|
||||
|
||||
def test_번호_읽기_실패해도_표는_그려지고_표_읽기_실패는_까닭_글(out):
|
||||
assert out["numbersFail"] == {"creates": 1, "seen": {}}
|
||||
assert out["tableFail"] == {"creates": 0, "text": "표 실패 그 층은 아직 만든 것이 없음"}
|
||||
|
||||
|
||||
def test_표_키_같은_층_주인이면_같고_층_주인이_다르면_다름(out):
|
||||
assert out["keys"] == {
|
||||
"mineSame": True,
|
||||
"layerDiffers": True,
|
||||
"ownerDiffers": True,
|
||||
"mine": "personal|1/3",
|
||||
}
|
||||
|
||||
|
||||
def test_모달과_M02_가_같은_표_부품_줄_글_도우미_숨김_열_규칙():
|
||||
table = _text(UI / "ui_template_item_picker_table.ts")
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
# 같은 표 부품(관리 화면은 이 파일을 glob 으로 받음)
|
||||
assert 'import("./sheet/ui_template_sheet")' in table
|
||||
assert '"../ui_template/sheet/ui_template_sheet.ts"' in main
|
||||
# 보기 전용 · 상단 표 꼴
|
||||
assert "readOnly: true," in table and "combo: {}," in table
|
||||
# 줄 글 도우미 한 벌 — 둘 다 같은 함수
|
||||
assert "export function numberLabels(" in struct
|
||||
assert "...struct.numberLabels(() => numbers)," in table
|
||||
assert "...numberLabels(struct.numbers)" in main
|
||||
# 숨김 열 규칙 한 벌
|
||||
assert "struct.comboView(got.문서)" in table
|
||||
assert "comboView(doc)" in main
|
||||
# 서버 읽기 = 고른 층 · 주인 · 같은 집계표 이름
|
||||
assert 'readTemplate(layer, "table", struct.STRUCTURE_TABLE, null, owner)' in table
|
||||
assert 'STRUCTURE_TABLE = "구조물집계표"' in struct
|
||||
|
||||
|
||||
def test_모달_배선_고르면_보이고_편집_화면에서는_숨김():
|
||||
ts = _text(UI / "ui_template_item_picker.ts")
|
||||
assert "createComboTable()" in ts
|
||||
assert "comboTable.show(chosen);" in ts # 고른 항목이 바뀔 때마다
|
||||
assert "body.append(tableWrap, cols, editHost," in ts # 맨 위
|
||||
assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때
|
||||
assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때
|
||||
assert "comboTable.destroy();" in ts # 닫을 때 표 거둠
|
||||
|
||||
|
||||
def test_표_높이_제한과_숨김_css():
|
||||
css = _text(UI / "ui_template_item_picker.css")
|
||||
assert ".ui-item-picker__table .ui-sheet__scroll {" in css
|
||||
assert "max-height: 26vh;" in css
|
||||
assert ".ui-item-picker__table-wrap[hidden]" in css
|
||||
|
||||
|
||||
def test_번호_읽기_층_길_추가_지금_층을_안_바꿈():
|
||||
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert "export const fetchStructureNumbersAt = (" in api
|
||||
assert "call(owned(layer, `/layers/${layer}/structures/numbers`, owner));" in api
|
||||
@@ -26,7 +26,7 @@ def test_틀_제목_줄_부제_상태():
|
||||
assert "loading: () => showLoading(host)," in shell
|
||||
assert "fail: (reason, retry) => host.replaceChildren(loadingError(reason, retry))," in shell
|
||||
assert "setDirty: (dirty) => (dot.hidden = !dirty)," in shell
|
||||
assert 'event.key.toLowerCase() !== "s"' in shell # 카드 안 Ctrl+S
|
||||
assert 'isCtrlKey(event, "s")' in shell # 카드 안 Ctrl+S
|
||||
|
||||
|
||||
def test_저장_단추_알림_한_벌():
|
||||
|
||||
@@ -163,8 +163,11 @@ def test_일위대가_합계_줄_표_부품과_연결():
|
||||
render = _text(SHEET / "ui_template_sheet_render.ts")
|
||||
assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main
|
||||
assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main
|
||||
# 줄 글은 도우미 한 벌(`numberLabels` · 고르기 모달 상단 표와 같음 · 224-1)
|
||||
assert "...numberLabels(struct.numbers)" in main
|
||||
helper = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
assert "laborSumLabel: (colId: string) => laborSumText(numbers()[colId])" in helper
|
||||
assert "laborSumState: (colId: string) => laborSumState(numbers()[colId])" in helper
|
||||
assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움
|
||||
assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text(
|
||||
SHEET / "ui_template_sheet.css"
|
||||
|
||||
@@ -52,6 +52,21 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 상단 표(224-1) — M02 구조물집계표 같은 부품 · 높이만 줄임(표 안에서 굴림) */
|
||||
.ui-item-picker__table-wrap {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__table .ui-sheet__scroll {
|
||||
max-height: 26vh;
|
||||
}
|
||||
|
||||
.ui-item-picker__table-wrap[hidden],
|
||||
.ui-item-picker__table[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-item-picker__cols[hidden],
|
||||
.ui-item-picker__actions[hidden],
|
||||
.ui-item-picker .ui-modal__actions[hidden],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*
|
||||
* 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준.
|
||||
* 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정).
|
||||
* 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐).
|
||||
* 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -43,6 +44,7 @@ import { askFields } from "./ui_template_item_picker_ask";
|
||||
import { projectItems, type CreatedItem } from "./ui_template_item_picker_project";
|
||||
import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail";
|
||||
import { createEditView, type EditView } from "./ui_template_item_picker_edit";
|
||||
import { createComboTable } from "./ui_template_item_picker_table";
|
||||
|
||||
export type { SummaryItem } from "./ui_template_item_picker_data";
|
||||
|
||||
@@ -146,6 +148,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
// 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸)
|
||||
onCreated: (created) => void load(created.key),
|
||||
});
|
||||
const comboTable = createComboTable();
|
||||
const cols = el("div", {
|
||||
className: "ui-item-picker__cols",
|
||||
children: [
|
||||
@@ -156,6 +159,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
detailView.root,
|
||||
],
|
||||
});
|
||||
const tableWrap = el("div", {
|
||||
className: "ui-item-picker__table-wrap",
|
||||
children: [comboTable.root],
|
||||
});
|
||||
const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } });
|
||||
let editView: EditView | null = null;
|
||||
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
|
||||
@@ -174,6 +181,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
|
||||
const paintDetail = (): void => {
|
||||
detailView.show(chosen);
|
||||
comboTable.show(chosen);
|
||||
const layer = chosen ? layerOf(chosen) : null;
|
||||
applyButton.disabled = !chosen;
|
||||
clearButton.disabled = !opts.current;
|
||||
@@ -369,6 +377,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
editView = view;
|
||||
editHost.replaceChildren(view.root);
|
||||
cols.hidden = true;
|
||||
tableWrap.hidden = true;
|
||||
actions.hidden = true;
|
||||
headButtons.hidden = true;
|
||||
editHost.hidden = false;
|
||||
@@ -380,6 +389,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
editView = null;
|
||||
editHost.hidden = true;
|
||||
cols.hidden = false;
|
||||
tableWrap.hidden = false;
|
||||
actions.hidden = false;
|
||||
headButtons.hidden = false;
|
||||
if (saved) detailView.markEdited();
|
||||
@@ -396,11 +406,12 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
beforeClose: async () => {
|
||||
if (editView && !(await editView.leave())) return false;
|
||||
editView?.destroy();
|
||||
comboTable.destroy();
|
||||
resolve({ action: "cancel" });
|
||||
return true;
|
||||
},
|
||||
mount: (body) => {
|
||||
body.append(cols, editHost, ...(opts.projectId ? [actions] : []));
|
||||
body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : []));
|
||||
paintDetail();
|
||||
void load(null);
|
||||
},
|
||||
|
||||
@@ -85,6 +85,9 @@ export const ownerOf = (row: PickerRow): string | null =>
|
||||
/** 같은 항목의 자리표 — 층 · 주인 · 키 */
|
||||
export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
|
||||
|
||||
/** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */
|
||||
export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`;
|
||||
|
||||
export function toSummary(row: PickerRow): SummaryItem {
|
||||
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_table.ts
|
||||
* 항목 고르기 모달 상단 표(PLAN 224-1) — M02 「구조물 조합」 페이지 상단의 구조물집계표를 **같은 부품**
|
||||
* (`ui_template/sheet` `createSheet` · 줄 글 = `numberLabels` · 숨김 열 = `comboView`)으로 보임.
|
||||
* 보기 전용(`readOnly` · 고침은 M02 에서) · 고른 항목이 속한 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐.
|
||||
* 같은 층의 다른 항목을 고르면 표는 그대로 두고 열만 옮김 · 다른 층 항목이면 그 층 표로 바뀜.
|
||||
* 표 부품 · 집계표 모듈은 처음 보일 때만 `import()`(B05 · B06 번들에 안 섞임).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements";
|
||||
import {
|
||||
fetchStructureNumbersAt,
|
||||
readTemplate,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { layerOf, ownerOf, tableKey, type PickerRow } from "./ui_template_item_picker_data";
|
||||
import { t } from "./ui_template_locale";
|
||||
import type { SheetDoc, SheetHandle } from "./sheet/ui_template_sheet";
|
||||
|
||||
export interface ComboTable {
|
||||
root: HTMLElement;
|
||||
/** 고른 항목 — `null` 이면 표를 숨김 */
|
||||
show: (row: PickerRow | null) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const failed = (error: unknown): string =>
|
||||
t("M02_PickerTableFailed").replace(
|
||||
"{value}",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
|
||||
export function createComboTable(): ComboTable {
|
||||
const root = el("div", { className: "ui-item-picker__table", attrs: { hidden: "" } });
|
||||
let seq = 0;
|
||||
let shownKey: string | null = null;
|
||||
let sheet: SheetHandle | null = null;
|
||||
/** 가장 나중에 고른 열 — 표를 읽는 사이에 바뀌면 읽은 뒤 이 열을 고름 */
|
||||
let column = "";
|
||||
|
||||
const drop = (): void => {
|
||||
sheet?.destroy();
|
||||
sheet = null;
|
||||
shownKey = null;
|
||||
};
|
||||
|
||||
async function paint(row: PickerRow, key: string, mine: number): Promise<void> {
|
||||
drop();
|
||||
root.replaceChildren(
|
||||
el("p", { className: "ui-item-picker__hint", text: t("M02_PickerLoading") }),
|
||||
);
|
||||
try {
|
||||
const layer = layerOf(row);
|
||||
const owner = ownerOf(row);
|
||||
const [{ createSheet }, struct] = await Promise.all([
|
||||
import("./sheet/ui_template_sheet"),
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_Structure"),
|
||||
]);
|
||||
const [got, numbers] = await Promise.all([
|
||||
readTemplate(layer, "table", struct.STRUCTURE_TABLE, null, owner),
|
||||
fetchStructureNumbersAt(layer, owner).catch(() => ({})),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
sheet = createSheet(root, struct.comboView(got.문서) as SheetDoc, {
|
||||
mode: "master",
|
||||
readOnly: true,
|
||||
lockHead: true,
|
||||
combo: {},
|
||||
...struct.numberLabels(() => numbers),
|
||||
});
|
||||
shownKey = key;
|
||||
sheet.selectColumn(column);
|
||||
} catch (error) {
|
||||
if (mine === seq)
|
||||
root.replaceChildren(el("p", { className: "ui-item-picker__hint", text: failed(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
show: (row) => {
|
||||
const mine = ++seq;
|
||||
root.hidden = !row;
|
||||
if (!row) return;
|
||||
column = row.열;
|
||||
const key = tableKey(row);
|
||||
if (sheet && key === shownKey) return sheet.selectColumn(column);
|
||||
void paint(row, key, mine);
|
||||
},
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
drop();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -80,6 +80,7 @@ export const ui_locales_m2 = {
|
||||
M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"],
|
||||
M02_PickerLoading: ["읽는 중…", "Loading…"],
|
||||
M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"],
|
||||
M02_PickerTableFailed: ["상단 표를 못 읽음 — {value}", "Failed to load the top table — {value}"],
|
||||
M02_PickerDrawing: ["도면", "Drawing"],
|
||||
M02_PickerArea: ["면적산출", "Area takeoff"],
|
||||
M02_PickerUnitPrice: ["일위대가", "Unit price"],
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로.
|
||||
* 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈.
|
||||
* 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움.
|
||||
* WebGL canvas 는 그림이 남아 있을 때만 옮겨짐 · 같은 출처 iframe(CAD)은 안의 canvas 만 옮김 — 못 옮기면 그 자리 빈칸.
|
||||
* 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 0.5 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움).
|
||||
* WebGL canvas 는 그림이 남아 있을 때만 옮겨짐.
|
||||
* CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_loupe.css";
|
||||
@@ -33,6 +35,8 @@ export const LOUPE_EDGE = 4;
|
||||
export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** CAD 영역 — cad_host 가 끼운 상자와 iframe */
|
||||
export const LOUPE_SKIP = ".cad-host, iframe";
|
||||
const NON_TEXT_INPUT = new Set([
|
||||
"checkbox",
|
||||
"radio",
|
||||
@@ -77,6 +81,11 @@ export function isTypingTarget(target: unknown): boolean {
|
||||
return tag === "INPUT" && !NON_TEXT_INPUT.has((el.type || "text").toLowerCase());
|
||||
}
|
||||
|
||||
/** CAD 영역 위인지 — 돋보기를 안 띄우는 자리(221-1) */
|
||||
export function isCadTarget(target: unknown): boolean {
|
||||
return !!(target as Partial<Element> | null)?.closest?.(LOUPE_SKIP);
|
||||
}
|
||||
|
||||
/** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */
|
||||
export function nextLoupeZoom(zoom: number, deltaY: number): number {
|
||||
const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP;
|
||||
@@ -115,18 +124,36 @@ export function initLoupe(
|
||||
let y = win.innerHeight / 2;
|
||||
let press = false; // 오른쪽 길게 누름
|
||||
let key = false; // Z 누름
|
||||
let shown = false;
|
||||
let shown = false; // 켜짐
|
||||
let seen = false; // 원이 보임 — 켜짐이어도 CAD 위면 숨김
|
||||
let overCad = false;
|
||||
let holdTimer = 0;
|
||||
let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음
|
||||
let zoom = LOUPE_ZOOM;
|
||||
|
||||
const update = (): void => {
|
||||
const want = (press || key) && isOn();
|
||||
if (want && !shown) {
|
||||
zoom = LOUPE_ZOOM; // 켤 때마다 2배
|
||||
if (want && !shown) zoom = LOUPE_ZOOM; // 켤 때마다 2배
|
||||
const see = want && !overCad;
|
||||
if (see && !seen) {
|
||||
view.show(x, y);
|
||||
} else if (!want && shown) view.hide();
|
||||
if (zoom !== LOUPE_ZOOM) view.setZoom(zoom); // CAD 에서 나옴 — 배율 그대로
|
||||
} else if (!see && seen) view.hide();
|
||||
shown = want;
|
||||
seen = see;
|
||||
};
|
||||
const track = (e: MouseEvent): void => {
|
||||
x = e.clientX;
|
||||
y = e.clientY;
|
||||
const cad = isCadTarget(e.target);
|
||||
if (cad === overCad) return;
|
||||
overCad = cad;
|
||||
update();
|
||||
};
|
||||
const releaseZ = (e: KeyboardEvent): void => {
|
||||
if (keyLetter(e) !== "z" || !key) return;
|
||||
key = false;
|
||||
update();
|
||||
};
|
||||
const stopHold = (): void => {
|
||||
win.clearTimeout(holdTimer);
|
||||
@@ -136,20 +163,21 @@ export function initLoupe(
|
||||
win.addEventListener(type, fn, true);
|
||||
|
||||
on("mousemove", (e) => {
|
||||
x = e.clientX;
|
||||
y = e.clientY;
|
||||
track(e);
|
||||
if (!(e.buttons & 2) && (holdTimer || press)) {
|
||||
stopHold(); // 창 밖에서 뗌 — mouseup 이 안 옴
|
||||
press = false;
|
||||
update();
|
||||
}
|
||||
if (shown) view.move(x, y);
|
||||
if (seen) view.move(x, y);
|
||||
});
|
||||
on("mouseover", track); // iframe 위로 들어가면 mousemove 가 안 옴 — 들어갈 때 한 번
|
||||
|
||||
on("mousedown", (e) => {
|
||||
if (e.button !== 2) return;
|
||||
swallowMenu = false;
|
||||
stopHold();
|
||||
if (!isOn()) return;
|
||||
if (!isOn() || isCadTarget(e.target)) return;
|
||||
x = e.clientX;
|
||||
y = e.clientY;
|
||||
holdTimer = win.setTimeout(() => {
|
||||
@@ -177,16 +205,12 @@ export function initLoupe(
|
||||
key = true;
|
||||
update();
|
||||
});
|
||||
on("keyup", (e) => {
|
||||
if (keyLetter(e) !== "z" || !key) return;
|
||||
key = false;
|
||||
update();
|
||||
});
|
||||
on("keyup", releaseZ);
|
||||
// 켜 있는 동안만 휠을 잡음 — 잡기 단계 첫 자리 · passive:false 여야 스크롤을 막음
|
||||
win.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
if (!shown) return;
|
||||
if (!seen) return; // CAD 위 — CAD 휠 줌 그대로
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation(); // 표 · 그래프 휠 줌도 안 받음
|
||||
zoom = nextLoupeZoom(zoom, e.deltaY);
|
||||
@@ -195,7 +219,19 @@ export function initLoupe(
|
||||
{ capture: true, passive: false },
|
||||
);
|
||||
on("blur", (e) => {
|
||||
if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만(CAD iframe 으로 넘어감 포함)
|
||||
if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만
|
||||
const active = win.document?.activeElement as HTMLIFrameElement | null | undefined;
|
||||
if (isCadTarget(active)) {
|
||||
// 커서가 CAD 로 들어가 초점이 넘어감(cad_host) — 끄지 않고 숨김만 · Z 떼기는 그쪽 창에서 들음
|
||||
try {
|
||||
active?.contentWindow?.addEventListener("keyup", releaseZ, true);
|
||||
} catch {
|
||||
// 다른 출처 — 나올 때 Z 를 다시 눌러야 꺼짐
|
||||
}
|
||||
overCad = true;
|
||||
update();
|
||||
return;
|
||||
}
|
||||
stopHold();
|
||||
press = key = false;
|
||||
update();
|
||||
@@ -215,6 +251,8 @@ export function createLoupeView(
|
||||
let at: [number, number] = [0, 0]; // 마지막 커서 — 배율만 바뀔 때 다시 놓음
|
||||
let observer: MutationObserver | null = null;
|
||||
let refreshTimer = 0;
|
||||
let sync = (): void => {}; // 스크롤 위치 맞춤 — 뜰 때마다 새로
|
||||
let syncFrame = 0;
|
||||
|
||||
const build = (): void => {
|
||||
root = doc.createElement("div");
|
||||
@@ -236,7 +274,7 @@ export function createLoupeView(
|
||||
const source = sourceOf();
|
||||
const t0 = win.performance?.now() ?? 0;
|
||||
stage!.replaceChildren();
|
||||
if (source) captureInto(source, stage!, win);
|
||||
if (source) sync = captureInto(source, stage!, win);
|
||||
root!.dataset.captureMs = String(Math.round((win.performance?.now() ?? 0) - t0));
|
||||
};
|
||||
const refresh = (): void => {
|
||||
@@ -246,11 +284,19 @@ export function createLoupeView(
|
||||
if (observer) capture();
|
||||
}, LOUPE_REFRESH_MS);
|
||||
};
|
||||
const onScroll = (): void => {
|
||||
if (!syncFrame)
|
||||
syncFrame = win.requestAnimationFrame(() => {
|
||||
syncFrame = 0;
|
||||
sync();
|
||||
});
|
||||
refresh();
|
||||
};
|
||||
const view: LoupeView = {
|
||||
show(x, y) {
|
||||
if (!root) build();
|
||||
root!.hidden = label!.hidden = false; // 숨긴 채 뜨면 복제의 스크롤이 안 먹음 — 먼저 보임
|
||||
capture();
|
||||
root!.hidden = label!.hidden = false;
|
||||
at = [x, y];
|
||||
view.setZoom(LOUPE_ZOOM); // 켤 때마다 2배 · 자리도 놓음
|
||||
const source = sourceOf();
|
||||
@@ -264,7 +310,7 @@ export function createLoupeView(
|
||||
characterData: true,
|
||||
});
|
||||
observer = watch;
|
||||
win.addEventListener("scroll", refresh, true);
|
||||
win.addEventListener("scroll", onScroll, true);
|
||||
},
|
||||
move(x, y) {
|
||||
if (!root || !stage || !label) return;
|
||||
@@ -286,7 +332,9 @@ export function createLoupeView(
|
||||
observer = null;
|
||||
win.clearTimeout(refreshTimer);
|
||||
refreshTimer = 0;
|
||||
win.removeEventListener("scroll", refresh, true);
|
||||
win.cancelAnimationFrame(syncFrame);
|
||||
syncFrame = 0;
|
||||
win.removeEventListener("scroll", onScroll, true);
|
||||
stage?.replaceChildren(); // 복제를 지움
|
||||
if (root) root.hidden = label!.hidden = true;
|
||||
},
|
||||
@@ -294,8 +342,8 @@ export function createLoupeView(
|
||||
return view;
|
||||
}
|
||||
|
||||
/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 */
|
||||
export function captureInto(source: Element, stage: HTMLElement, win: Window): void {
|
||||
/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 — 돌려주는 함수 = 지금 스크롤 · 자리로 다시 맞춤 */
|
||||
export function captureInto(source: Element, stage: HTMLElement, win: Window): () => void {
|
||||
const doc = win.document;
|
||||
const rect = source.getBoundingClientRect();
|
||||
const page = doc.createElement("div");
|
||||
@@ -307,14 +355,21 @@ export function captureInto(source: Element, stage: HTMLElement, win: Window): v
|
||||
height: `${rect.height}px`,
|
||||
});
|
||||
const copy = source.cloneNode(true) as Element;
|
||||
const scrolls: [Element, number, number][] = [];
|
||||
const scrolls: [Element, Element][] = []; // [원본, 복제] 스크롤 칸
|
||||
walk(source, copy, doc, win.innerWidth, win.innerHeight, scrolls);
|
||||
page.append(copy);
|
||||
stage.append(page);
|
||||
for (const [el, top, left] of scrolls) {
|
||||
el.scrollTop = top; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 되돌림
|
||||
el.scrollLeft = left;
|
||||
}
|
||||
const sync = (): void => {
|
||||
const now = source.getBoundingClientRect(); // 문서 스크롤 — 페이지 자리
|
||||
page.style.left = `${now.left}px`;
|
||||
page.style.top = `${now.top}px`;
|
||||
for (const [o, c] of scrolls) {
|
||||
c.scrollTop = o.scrollTop; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 옮김
|
||||
c.scrollLeft = o.scrollLeft;
|
||||
}
|
||||
};
|
||||
sync();
|
||||
return sync;
|
||||
}
|
||||
|
||||
function walk(
|
||||
@@ -323,24 +378,27 @@ function walk(
|
||||
doc: Document,
|
||||
vw: number,
|
||||
vh: number,
|
||||
scrolls: [Element, number, number][],
|
||||
scrolls: [Element, Element][],
|
||||
): void {
|
||||
if (o.namespaceURI === SVG_NS) return; // SVG 는 복제 그대로
|
||||
const tag = o.tagName.toUpperCase();
|
||||
if (tag === "CANVAS") return drawCanvas(o as HTMLCanvasElement, c as HTMLCanvasElement);
|
||||
if (tag === "IFRAME") return void c.replaceWith(frameShot(o as HTMLIFrameElement, doc));
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return copyValue(o, c);
|
||||
const r = o.getBoundingClientRect();
|
||||
if (!KEEP_INSIDE.has(tag) && (r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw)) {
|
||||
c.replaceChildren(); // 화면 밖 — 속을 비우고 크기만
|
||||
Object.assign((c as HTMLElement).style, {
|
||||
boxSizing: "border-box",
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
});
|
||||
return;
|
||||
if (tag === "IFRAME") {
|
||||
const box = doc.createElement("div"); // 복제 iframe 은 CAD 를 또 띄움 — 빈 상자로 바꿈
|
||||
c.replaceWith(box);
|
||||
return emptyBox(box, r);
|
||||
}
|
||||
if (o.scrollTop || o.scrollLeft) scrolls.push([c, o.scrollTop, o.scrollLeft]);
|
||||
const off = r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw;
|
||||
if (o.classList?.contains("cad-host") || (!KEEP_INSIDE.has(tag) && off)) return emptyBox(c, r); // CAD · 화면 밖
|
||||
if (
|
||||
o.scrollTop ||
|
||||
o.scrollLeft ||
|
||||
o.scrollHeight > o.clientHeight ||
|
||||
o.scrollWidth > o.clientWidth
|
||||
)
|
||||
scrolls.push([o, c]); // 지금 스크롤됐거나 스크롤될 수 있는 칸
|
||||
for (let i = 0; i < o.children.length; i++)
|
||||
walk(o.children[i], c.children[i], doc, vw, vh, scrolls);
|
||||
}
|
||||
@@ -364,23 +422,12 @@ function copyValue(o: Element, c: Element): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** iframe 자리 — 같은 출처면 안의 canvas 를 한 장에 모아 그림 · 아니면 빈칸 */
|
||||
function frameShot(frame: HTMLIFrameElement, doc: Document): HTMLCanvasElement {
|
||||
const r = frame.getBoundingClientRect();
|
||||
const shot = doc.createElement("canvas");
|
||||
shot.className = frame.className;
|
||||
shot.width = Math.max(1, Math.round(r.width));
|
||||
shot.height = Math.max(1, Math.round(r.height));
|
||||
Object.assign(shot.style, { display: "block", width: `${r.width}px`, height: `${r.height}px` });
|
||||
try {
|
||||
const ctx = shot.getContext("2d");
|
||||
for (const canvas of Array.from(frame.contentDocument?.querySelectorAll("canvas") ?? [])) {
|
||||
const cr = canvas.getBoundingClientRect();
|
||||
if (canvas.width && canvas.height)
|
||||
ctx?.drawImage(canvas, cr.left, cr.top, cr.width, cr.height);
|
||||
}
|
||||
} catch {
|
||||
// 다른 출처 · 그림 없음 — 빈칸
|
||||
}
|
||||
return shot;
|
||||
/** 속을 비우고 크기만 남김 — 화면 밖 · CAD 영역 */
|
||||
function emptyBox(c: Element, r: DOMRect): void {
|
||||
c.replaceChildren();
|
||||
Object.assign((c as HTMLElement).style, {
|
||||
boxSizing: "border-box",
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user