Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_5
This commit is contained in:
@@ -103,6 +103,8 @@ export const STATE_REGISTRY = {
|
||||
"cross-columns": { bucket: "pref", scope: "global" },
|
||||
/** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */
|
||||
"mass-haul-badge-pos": { bucket: "pref", scope: "global" },
|
||||
/** ☰ [설정] 마우스 · 단축키(332) — 읽기는 `input_prefs.ts`(같은 키 글자) · [저장] 때만 씀. */
|
||||
"input-prefs": { bucket: "pref", scope: "global" },
|
||||
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
|
||||
"b05-3d-projection": { bucket: "pref", scope: "global" },
|
||||
"section-panel-height": {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { wheelEventIn } from "./input_prefs";
|
||||
|
||||
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
||||
export interface ZoomPanState {
|
||||
scale: number;
|
||||
@@ -213,8 +215,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
|
||||
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
|
||||
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
|
||||
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
|
||||
// ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐.
|
||||
zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* =============================================================================
|
||||
* input_prefs.ts
|
||||
* 마우스 · 단축키 설정 읽기 층(PLAN 332) — ☰ [설정] 창이 [저장] 때 계정 pref 로 씀.
|
||||
*
|
||||
* - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름)
|
||||
* - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import)
|
||||
* - 휠 확대 방향 하나로 모든 화면 일체 — 당김 = 휠을 몸 쪽으로(deltaY > 0) 굴리면 확대
|
||||
* - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0)
|
||||
* ========================================================================== */
|
||||
|
||||
export type WheelZoom = "pull" | "push";
|
||||
|
||||
export interface InputPrefs {
|
||||
/** 휠 확대 방향 — pull = 당기면 확대 · push = 밀면 확대 */
|
||||
wheelZoom: WheelZoom;
|
||||
/** CAD 내 별칭 `{명령: [별칭]}`(대문자 · acad.pgp 식 — 명령 이름 > 내 별칭 > 기본 별칭) */
|
||||
cadAliases: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */
|
||||
export const INPUT_PREFS_KEY = "aislo:pref:input-prefs";
|
||||
|
||||
export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "pull", cadAliases: {} };
|
||||
|
||||
const cleanAliases = (raw: unknown): Record<string, string[]> => {
|
||||
const out: Record<string, string[]> = {};
|
||||
if (!raw || typeof raw !== "object") return out;
|
||||
for (const [id, list] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
const aliases = list
|
||||
.filter((a): a is string => typeof a === "string")
|
||||
.map((a) => a.toUpperCase());
|
||||
if (aliases.length) out[id.toUpperCase()] = aliases;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** 지금 설정 — 부를 때마다 저장소에서 읽음 */
|
||||
export function inputPrefs(): InputPrefs {
|
||||
try {
|
||||
const raw = globalThis.localStorage?.getItem(INPUT_PREFS_KEY);
|
||||
if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
|
||||
const value = JSON.parse(raw) as Partial<InputPrefs> | null;
|
||||
return {
|
||||
wheelZoom: value?.wheelZoom === "push" ? "push" : "pull",
|
||||
cadAliases: cleanAliases(value?.cadAliases),
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/** 휠 한 번이 확대인가 — 설정 방향을 따름 */
|
||||
export const wheelIn = (deltaY: number): boolean =>
|
||||
inputPrefs().wheelZoom === "push" ? deltaY < 0 : deltaY > 0;
|
||||
|
||||
/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄
|
||||
* ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */
|
||||
export const isTrackpadPinch = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">): boolean =>
|
||||
e.ctrlKey && Math.abs(e.deltaY) < 50;
|
||||
|
||||
/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */
|
||||
export const wheelEventIn = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">, pinch = false): boolean =>
|
||||
pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY);
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import "./spreadsheet_grid.css";
|
||||
import { colIndex, toA1 } from "./spreadsheet_address";
|
||||
import { wheelZoomsIn } from "../touch_bridge";
|
||||
import {
|
||||
buildColGeometry,
|
||||
buildOutlineRuns,
|
||||
@@ -271,7 +272,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
const before = zoom;
|
||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
|
||||
const step = (wheelZoomsIn(ev) ? 1 : -1) * Math.abs(ev.deltaY) * 0.0015; // ☰ [설정] 방향(332)
|
||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(step)));
|
||||
if (next === before) return;
|
||||
zoom = next;
|
||||
colGeo = scaledGeometry(baseColGeo, zoom);
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* 마우스 · 펜 이벤트는 건드리지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { wheelEventIn } from "./input_prefs";
|
||||
|
||||
const LONG_MS = 500;
|
||||
const SLOP_PX = 10;
|
||||
const DOUBLE_MS = 300;
|
||||
@@ -31,9 +33,8 @@ let contactPx = 0;
|
||||
/** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */
|
||||
export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px);
|
||||
|
||||
/** 「당기면 확대」 휠 화면(B04 · B05 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */
|
||||
export const wheelZoomsIn = (e: WheelEvent): boolean =>
|
||||
isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0;
|
||||
/** 휠 화면(B04 · B05 지도 · M02 그림 · 시트)의 확대 판정 — ☰ [설정] 방향(332) · 손가락 벌림은 늘 벌리면 확대 */
|
||||
export const wheelZoomsIn = (e: WheelEvent): boolean => wheelEventIn(e, isPinchWheel(e));
|
||||
|
||||
const ownsTouch = (t: EventTarget | null): boolean =>
|
||||
t instanceof Element && !!t.closest("[data-touch-own]");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch";
|
||||
import { wheelEventIn } from "../A00_Common/input_prefs";
|
||||
|
||||
export const SURFACE_CAMERA_FOV = 50;
|
||||
const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9;
|
||||
@@ -246,11 +247,11 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
}
|
||||
|
||||
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
|
||||
* 휠을 위로 올리면 멀어진다(사용자 지시). */
|
||||
* 방향 = ☰ [설정](332 · 기본 당기면 가까워짐 · 위로 올리면 멀어짐). */
|
||||
function onWheel(event: WheelEvent): void {
|
||||
if (!controls.enabled || options.blocked?.()) return;
|
||||
event.preventDefault();
|
||||
zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP);
|
||||
zoomAround(event, wheelEventIn(event) ? ZOOM_STEP : 1 / ZOOM_STEP);
|
||||
}
|
||||
|
||||
/** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
|
||||
|
||||
@@ -211,6 +211,8 @@ export interface StationTickOptions {
|
||||
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
|
||||
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
|
||||
onLabel?: (box: StationLabelBox) => void;
|
||||
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */
|
||||
chainages?: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
|
||||
@@ -253,7 +255,10 @@ export function drawStationTicks(
|
||||
// 이미 그린 라벨과 겹치는 자리는 비켜 놓는다.
|
||||
const drawn: Array<{ x: number; y: number; half: number }> = [];
|
||||
let cursor = 1;
|
||||
for (let chainage = 0; chainage <= total; chainage += interval) {
|
||||
const marks: number[] = [];
|
||||
if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total));
|
||||
else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage);
|
||||
for (const chainage of marks) {
|
||||
const stationNo = Math.round(chainage / interval);
|
||||
if (stationNo % step !== 0) continue;
|
||||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface RoutePlanResponse {
|
||||
violation_count: number;
|
||||
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
|
||||
edited: boolean;
|
||||
/** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */
|
||||
station_snap?: boolean;
|
||||
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
|
||||
current_initial?: { logic: string; weights: Record<string, number> | null };
|
||||
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
|
||||
@@ -220,6 +222,8 @@ export interface CrossPreviewRequest {
|
||||
station_interval_m: number;
|
||||
/** 횡단 반폭(m) — 없으면 서버 기본값. */
|
||||
cross_half_width_m?: number;
|
||||
/** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */
|
||||
station_snap?: boolean;
|
||||
}
|
||||
|
||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
viewPxPerMeter,
|
||||
} from "./B05_Profile_UI_RouteEdit_View";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import { curveSpans, snapCurveStations, stationsAlong } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
import {
|
||||
lockColumn,
|
||||
readRouteLocks,
|
||||
@@ -109,7 +110,7 @@ export async function openRouteEditModal(
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
const chrome = createRouteEditChrome();
|
||||
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
|
||||
const { minorContourButton, terrainSelect } = chrome;
|
||||
const { minorContourButton, terrainSelect, stationSnapButton } = chrome;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
@@ -138,6 +139,24 @@ export async function openRouteEditModal(
|
||||
let apexLock: boolean[] = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
/** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */
|
||||
let stationSnap = false;
|
||||
let savedStationSnap = false;
|
||||
/** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로.
|
||||
* 그리기 · 누름 · 기울기가 이것을 봄 · 셈은 서버 짝(`_Frame`). */
|
||||
const stations = (): number[] | null => {
|
||||
if (!stationSnap) return null;
|
||||
const line = plannedLine.length ? plannedLine : planned;
|
||||
return snapCurveStations(
|
||||
stationsAlong(line, stationIntervalM, false),
|
||||
curveSpans(line, curveInfo),
|
||||
);
|
||||
};
|
||||
const setStationSnap = (on: boolean): void => {
|
||||
stationSnap = on;
|
||||
stationSnapButton.classList.toggle("is-active", on);
|
||||
stationSnapButton.setAttribute("aria-pressed", String(on));
|
||||
};
|
||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||
let history: RouteEditHistory | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
@@ -165,6 +184,7 @@ export async function openRouteEditModal(
|
||||
})),
|
||||
min_radius_m: minRadiusM || 12,
|
||||
station_interval_m: stationIntervalM,
|
||||
station_snap: stationSnap,
|
||||
}),
|
||||
onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다.
|
||||
});
|
||||
@@ -217,6 +237,11 @@ export async function openRouteEditModal(
|
||||
const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() });
|
||||
/** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */
|
||||
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
|
||||
stationSnapButton.addEventListener("click", () => {
|
||||
setStationSnap(!stationSnap);
|
||||
void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로.
|
||||
draw();
|
||||
});
|
||||
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
|
||||
let viewReady = false;
|
||||
const viewMemory = createViewMemory(projectId, () =>
|
||||
@@ -307,6 +332,7 @@ export async function openRouteEditModal(
|
||||
toScreen,
|
||||
uprightRad: rotation.uprightRad(),
|
||||
dragging: pointer.dragging(),
|
||||
chainages: stations(),
|
||||
}),
|
||||
measure: measure.marks(),
|
||||
crossWidth: crossPreview.watched(),
|
||||
@@ -319,6 +345,7 @@ export async function openRouteEditModal(
|
||||
locked: apexLock,
|
||||
picked,
|
||||
stationIntervalM,
|
||||
stations: stations(),
|
||||
});
|
||||
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
|
||||
syncCurveBar();
|
||||
@@ -448,6 +475,7 @@ export async function openRouteEditModal(
|
||||
canvas,
|
||||
status,
|
||||
stationIntervalM,
|
||||
stations,
|
||||
state: () => ({
|
||||
planned,
|
||||
plannedLine,
|
||||
@@ -530,8 +558,12 @@ export async function openRouteEditModal(
|
||||
projectId,
|
||||
nodes: () => ({ planned, curveOn, curveRadius }),
|
||||
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
|
||||
changed: () => (history?.isDirty() ?? false) || pickedTerrain() !== designTerrain,
|
||||
changed: () =>
|
||||
(history?.isDirty() ?? false) ||
|
||||
pickedTerrain() !== designTerrain ||
|
||||
stationSnap !== savedStationSnap,
|
||||
terrain: pickedTerrain,
|
||||
stationSnap: () => stationSnap,
|
||||
initial,
|
||||
profileShown: strip.settle,
|
||||
close,
|
||||
@@ -554,6 +586,8 @@ export async function openRouteEditModal(
|
||||
minRadiusM = plan.min_radius_m ?? 0;
|
||||
limitRadiusM = plan.limit_radius_m ?? 0;
|
||||
limitArcM = plan.limit_curve_length_m ?? 0;
|
||||
savedStationSnap = plan.station_snap === true;
|
||||
setStationSnap(savedStationSnap);
|
||||
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
||||
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface RouteApplyParams {
|
||||
changed: () => boolean;
|
||||
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
|
||||
terrain: () => string | null;
|
||||
/** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */
|
||||
stationSnap: () => boolean;
|
||||
/** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
initial: {
|
||||
current: () => unknown;
|
||||
@@ -97,6 +99,7 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
||||
// 띠 응답 전에 눌렀으면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다.
|
||||
balanced_profile: await params.profileShown(),
|
||||
terrain: params.terrain(),
|
||||
station_snap: params.stationSnap(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface RouteEditChrome {
|
||||
measureText: HTMLElement;
|
||||
measureButton: HTMLButtonElement;
|
||||
minorContourButton: HTMLButtonElement;
|
||||
/** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */
|
||||
stationSnapButton: HTMLButtonElement;
|
||||
/** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */
|
||||
terrainSelect: HTMLSelectElement;
|
||||
}
|
||||
@@ -65,6 +67,9 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
|
||||
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [확인] 때 저장">측점 스냅</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
|
||||
@@ -136,6 +141,7 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
'[data-act="contour-minor"]',
|
||||
)!;
|
||||
const terrainSelect = overlay.querySelector<HTMLSelectElement>('[data-act="terrain"]')!;
|
||||
const stationSnapButton = overlay.querySelector<HTMLButtonElement>('[data-act="station-snap"]')!;
|
||||
|
||||
return {
|
||||
overlay,
|
||||
@@ -146,6 +152,7 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
measureText,
|
||||
measureButton,
|
||||
minorContourButton,
|
||||
stationSnapButton,
|
||||
terrainSelect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface CrossPreviewParams {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
station_snap?: boolean;
|
||||
};
|
||||
/** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */
|
||||
onShown?: () => void;
|
||||
|
||||
@@ -321,13 +321,15 @@ export function stationAtScreen(
|
||||
py: number,
|
||||
maxPx: number,
|
||||
labels: ReadonlyArray<StationLabelBox> = [],
|
||||
/** 측점 누가거리 — 안 주면 간격마다(측점 스냅 · 요구 224-2). */
|
||||
chainages: ReadonlyArray<number> | null = null,
|
||||
): number | null {
|
||||
// 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이
|
||||
// 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다.
|
||||
const shown = labels.length
|
||||
? new Set(labels.map((box) => Math.round(box.chainage / intervalM)))
|
||||
: null;
|
||||
const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown);
|
||||
const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown, chainages);
|
||||
// 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다.
|
||||
return tick ?? stationLabelAtScreen(labels, px, py);
|
||||
}
|
||||
@@ -373,6 +375,7 @@ function stationTickAtScreen(
|
||||
py: number,
|
||||
maxPx: number,
|
||||
shown: ReadonlySet<number> | null,
|
||||
chainages: ReadonlyArray<number> | null,
|
||||
): number | null {
|
||||
if (line.length < 2 || !(intervalM > 0)) return null;
|
||||
const cumulative: number[] = [0];
|
||||
@@ -386,7 +389,10 @@ function stationTickAtScreen(
|
||||
let best: number | null = null;
|
||||
let bestDistance = maxPx;
|
||||
let cursor = 1;
|
||||
for (let chainage = 0; chainage <= total; chainage += intervalM) {
|
||||
const marks: number[] = [];
|
||||
if (chainages) marks.push(...chainages.filter((value) => value <= total));
|
||||
else for (let chainage = 0; chainage <= total; chainage += intervalM) marks.push(chainage);
|
||||
for (const chainage of marks) {
|
||||
if (shown && !shown.has(Math.round(chainage / intervalM))) continue;
|
||||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||||
const back = line[cursor - 1];
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface RouteEditPointerParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
status: HTMLElement;
|
||||
stationIntervalM: number;
|
||||
/** 편집 창 측점 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
|
||||
stations: () => ReadonlyArray<number> | null;
|
||||
state: () => RouteEditPointerState;
|
||||
setPicked: (index: number) => void;
|
||||
setPickedContour: (index: number) => void;
|
||||
@@ -194,6 +196,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
py,
|
||||
pickPx(STATION_HIT_PX),
|
||||
stationLabelBoxes(canvas),
|
||||
params.stations(),
|
||||
);
|
||||
if (chainage === null) return false;
|
||||
void params.crossPreview.open(chainage);
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface RouteEditScene {
|
||||
picked: number;
|
||||
/** 규칙 측점 간격(m). */
|
||||
stationIntervalM: number;
|
||||
/** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
|
||||
stations: ReadonlyArray<number> | null;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<MeasureMark>;
|
||||
/** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */
|
||||
@@ -478,6 +480,7 @@ function drawStationMarks(
|
||||
toScreen: (x, y) => scene.toScreen([x, y]),
|
||||
uprightRad: scene.uprightRad,
|
||||
onLabel: (box) => labels.push(box),
|
||||
chainages: scene.stations ?? undefined,
|
||||
},
|
||||
);
|
||||
const total = polylineLengthM(line);
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface SlopeDrawArgs {
|
||||
uprightRad: number;
|
||||
/** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */
|
||||
dragging: boolean;
|
||||
/** 측점 누가거리(종점 뺌) — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
|
||||
chainages?: ReadonlyArray<number> | null;
|
||||
}
|
||||
|
||||
export interface SlopeLabels {
|
||||
@@ -91,6 +93,8 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
|
||||
/** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */
|
||||
let cachedLine: ReadonlyArray<Vertex> | null = null;
|
||||
let cachedInterval = 0;
|
||||
/** 받아 둔 측점 목록 열쇠 — 스냅을 켜고 끄면 같은 노선이어도 다시 묻는다. */
|
||||
let cachedKey = "";
|
||||
let chainages: number[] = [];
|
||||
let heights: Array<number | null> = [];
|
||||
/** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */
|
||||
@@ -100,19 +104,27 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
|
||||
let asking: ReadonlyArray<Vertex> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function schedule(line: ReadonlyArray<Vertex>, intervalM: number): void {
|
||||
function schedule(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
intervalM: number,
|
||||
marks: ReadonlyArray<number> | null,
|
||||
): void {
|
||||
if (asking === line) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
asking = line;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const wanted = stationChainages(lengthOf(line), intervalM);
|
||||
const total = lengthOf(line);
|
||||
const wanted = marks
|
||||
? [...marks.filter((value) => value < total - 1e-6), total]
|
||||
: stationChainages(total, intervalM);
|
||||
const points = pointsAt(line, wanted);
|
||||
void fetchRouteElevations(params.projectId, points)
|
||||
.then((z) => {
|
||||
if (params.isClosed() || asking !== line) return; // 그 사이 노선이 또 바뀌었다.
|
||||
cachedLine = line;
|
||||
cachedInterval = intervalM;
|
||||
cachedKey = marks?.join() ?? "";
|
||||
chainages = wanted;
|
||||
heights = z;
|
||||
stationPoints = points;
|
||||
@@ -160,9 +172,11 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
|
||||
const { line, intervalM } = args;
|
||||
if (line.length < 2 || !(intervalM > 0)) return 0;
|
||||
if (intervalM * args.pxPerMeter < SLOPE_MIN_GAP_PX) return 0; // 멀리 보면 숨긴다.
|
||||
const fresh = cachedLine === line && cachedInterval === intervalM;
|
||||
const marks = args.chainages ?? null;
|
||||
const fresh =
|
||||
cachedLine === line && cachedInterval === intervalM && cachedKey === (marks?.join() ?? "");
|
||||
if (!fresh) {
|
||||
if (!args.dragging) schedule(line, intervalM);
|
||||
if (!args.dragging) schedule(line, intervalM, marks);
|
||||
return 0; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다.
|
||||
}
|
||||
// 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 —
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
@@ -147,6 +148,10 @@ export const copyToolStateMachine = createMachine(
|
||||
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: CopyAction.DESELECT_ENTITIES,
|
||||
target: CopyState.INIT,
|
||||
@@ -166,6 +171,18 @@ export const copyToolStateMachine = createMachine(
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: CopyAction.DESELECT_ENTITIES,
|
||||
target: CopyState.INIT,
|
||||
@@ -191,10 +208,9 @@ export const copyToolStateMachine = createMachine(
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[CopyAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
startPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return { startPoint };
|
||||
}),
|
||||
[CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
@@ -208,7 +224,9 @@ export const copyToolStateMachine = createMachine(
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity))),
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map((entity) => cloneKeepingLook(entity))
|
||||
),
|
||||
copiedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
@@ -217,11 +235,13 @@ export const copyToolStateMachine = createMachine(
|
||||
throw new Error('[COPY] Calling draw temp copy line without a start point');
|
||||
}
|
||||
|
||||
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent);
|
||||
|
||||
// Copy the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied;
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) =>
|
||||
cloneKeepingLook(entity)
|
||||
);
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
@@ -245,8 +265,11 @@ export const copyToolStateMachine = createMachine(
|
||||
}
|
||||
|
||||
// Copy the entities one final time
|
||||
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
|
||||
// 클릭 · 거리 · 절대 · 상대 좌표(키보드)
|
||||
const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
const copiedEntities = context.originalSelectedEntities.map((entity) =>
|
||||
cloneKeepingLook(entity)
|
||||
);
|
||||
moveEntities(
|
||||
copiedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
@@ -148,6 +149,10 @@ export const moveToolStateMachine = createMachine(
|
||||
actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE],
|
||||
target: MoveState.WAITING_FOR_END_MOVE_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE],
|
||||
target: MoveState.WAITING_FOR_END_MOVE_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: MoveAction.DESELECT_ENTITIES,
|
||||
target: MoveState.INIT,
|
||||
@@ -167,6 +172,18 @@ export const moveToolStateMachine = createMachine(
|
||||
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
|
||||
target: MoveState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: MoveAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: MoveState.INIT,
|
||||
@@ -192,10 +209,9 @@ export const moveToolStateMachine = createMachine(
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[MoveAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
startPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return { startPoint };
|
||||
}),
|
||||
[MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
@@ -211,7 +227,9 @@ export const moveToolStateMachine = createMachine(
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
),
|
||||
movedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
@@ -220,11 +238,13 @@ export const moveToolStateMachine = createMachine(
|
||||
throw new Error('[MOVE] Calling draw temp move line without a start point');
|
||||
}
|
||||
|
||||
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent);
|
||||
|
||||
// Move the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true));
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) =>
|
||||
cloneKeepingLook(entity, true)
|
||||
);
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
@@ -248,7 +268,8 @@ export const moveToolStateMachine = createMachine(
|
||||
}
|
||||
|
||||
// Move the entities one final time
|
||||
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
// 클릭 · 거리 · 절대 · 상대 좌표(키보드)
|
||||
const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
moveEntities(
|
||||
context.originalSelectedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import type {
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
PointInputEvent,
|
||||
StateEvent,
|
||||
ToolContext,
|
||||
} from './tool.types';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
@@ -41,6 +49,7 @@ export enum RotateAction {
|
||||
COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE',
|
||||
DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES',
|
||||
ROTATE_SELECTION = 'ROTATE_SELECTION',
|
||||
ROTATE_BY_ANGLE = 'ROTATE_BY_ANGLE',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
}
|
||||
@@ -147,6 +156,10 @@ export const rotateToolStateMachine = createMachine(
|
||||
actions: [RotateAction.RECORD_ROTATION_ORIGIN],
|
||||
target: RotateState.WAITING_FOR_ANGLE_START_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [RotateAction.RECORD_ROTATION_ORIGIN],
|
||||
target: RotateState.WAITING_FOR_ANGLE_START_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.DESELECT_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
@@ -166,6 +179,15 @@ export const rotateToolStateMachine = createMachine(
|
||||
],
|
||||
target: RotateState.WAITING_FOR_ANGLE_END_POINT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
// 각도(도) 바로 — 반시계 +
|
||||
actions: [
|
||||
RotateAction.COPY_SELECTION_BEFORE_ROTATE,
|
||||
RotateAction.ROTATE_BY_ANGLE,
|
||||
RotateAction.DESELECT_ENTITIES,
|
||||
],
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
@@ -204,11 +226,9 @@ export const rotateToolStateMachine = createMachine(
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(({ context, event }): RotateContext => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
const rotationOrigin = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(rotationOrigin);
|
||||
return { ...context, rotationOrigin };
|
||||
}),
|
||||
[RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign(
|
||||
({ context, event }): RotateContext => {
|
||||
@@ -232,7 +252,9 @@ export const rotateToolStateMachine = createMachine(
|
||||
return {
|
||||
...context,
|
||||
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
|
||||
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
),
|
||||
};
|
||||
}),
|
||||
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
|
||||
@@ -279,6 +301,19 @@ export const rotateToolStateMachine = createMachine(
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[RotateAction.ROTATE_BY_ANGLE]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin) {
|
||||
throw new Error('[ROTATE] Calling rotate by angle without a rotation origin');
|
||||
}
|
||||
const radians = ((event as NumberInputEvent).value * Math.PI) / 180;
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
for (const entity of rotatedEntities) entity.rotate(context.rotationOrigin, radians);
|
||||
addEntities(rotatedEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { wheelZoomsIn } from "../../A00_Common/touch_bridge";
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model";
|
||||
import {
|
||||
@@ -454,7 +455,7 @@ export function renderDiagram(
|
||||
"wheel",
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY);
|
||||
setZoom(st.zoom * (wheelZoomsIn(ev) ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); // ☰ [설정] 방향(332)
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import "./M02_MasterTemplete_Labor_Diagram.css";
|
||||
@@ -313,7 +314,7 @@ function mountShell(host: HTMLElement): State {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
const r = viewport.getBoundingClientRect();
|
||||
zoomAt(st, e.deltaY < 0 ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top);
|
||||
zoomAt(st, wheelZoomsIn(e) ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top); // ☰ [설정] 방향(332)
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
@@ -148,3 +148,52 @@ describe("331-1 각도 고정 → 길이", () => {
|
||||
expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
/** 선 (x1,y1)-(x2,y2) 하나를 키보드로 그리고 고름 */
|
||||
const drawAndSelect = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
type(controller, "l");
|
||||
type(controller, `${x1},${y1}`);
|
||||
type(controller, `${x2},${y2}`);
|
||||
key(controller, "Escape");
|
||||
key(controller, "Escape");
|
||||
setSelectedEntityIds(getEntities().map((entity) => entity.id));
|
||||
};
|
||||
|
||||
describe("331-2 이동 · 복사 · 회전 키보드 값", () => {
|
||||
it("MOVE · 0,0 · @10,0 → 오른쪽 10", () => {
|
||||
drawAndSelect(0, 0, 20, 0);
|
||||
type(controller, "move");
|
||||
type(controller, "0,0");
|
||||
type(controller, "@10,0");
|
||||
const drawn = lines();
|
||||
expect(drawn).toHaveLength(1);
|
||||
expectXy(drawn[0].start, 10, 0);
|
||||
expectXy(drawn[0].end, 30, 0);
|
||||
});
|
||||
|
||||
it("COPY · 0,0 · <0 · 5 → 원본 + 오른쪽 5", () => {
|
||||
drawAndSelect(0, 0, 20, 0);
|
||||
type(controller, "copy");
|
||||
type(controller, "0,0");
|
||||
type(controller, "<0");
|
||||
type(controller, "5");
|
||||
const starts = lines()
|
||||
.map((segment) => segment.start.x)
|
||||
.sort((a, b) => a - b);
|
||||
expect(starts).toHaveLength(2);
|
||||
expect(starts[0]).toBeCloseTo(0, 3);
|
||||
expect(starts[1]).toBeCloseTo(5, 3);
|
||||
});
|
||||
|
||||
it("ROTATE · 0,0 · 90 → 반시계 90°", () => {
|
||||
drawAndSelect(10, 0, 20, 0);
|
||||
type(controller, "rotate");
|
||||
type(controller, "0,0");
|
||||
type(controller, "90");
|
||||
const drawn = lines();
|
||||
expect(drawn).toHaveLength(1);
|
||||
expectXy(drawn[0].start, 0, 10);
|
||||
expectXy(drawn[0].end, 0, 20);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,9 @@ function load(file) {
|
||||
? {}
|
||||
: p.endsWith("key_shortcut") // 공용 키 판정(214-2)도 그 자리에서 트랜스파일
|
||||
? load(path.join(ROOT, "A00_Common", "key_shortcut.ts"))
|
||||
: require(p);
|
||||
: p.endsWith("input_prefs") // 휠 방향(332 · Node = 기본 당김)
|
||||
? load(path.join(ROOT, "A00_Common", "input_prefs.ts"))
|
||||
: require(p);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
return box.exports;
|
||||
}
|
||||
@@ -100,30 +102,30 @@ r.consts = {
|
||||
r.listeners = win.calls.map((c) => [c.type, c.capture]);
|
||||
}
|
||||
{
|
||||
// 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배
|
||||
// 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 · 확대 = 당김(deltaY > 0 · 332 기본)
|
||||
const { win, view } = setup();
|
||||
const wheel = (deltaY) => win.fire("wheel", { deltaY });
|
||||
const zoomLog = () => view.log.filter((l) => l[0] === "zoom").map((l) => l[1]);
|
||||
const offEv = wheel(-100);
|
||||
const offEv = wheel(100);
|
||||
const offState = { prevented: offEv.prevented, stopped: offEv.stopped, log: view.log.length };
|
||||
win.fire("keydown", { code: "KeyZ" });
|
||||
const up = wheel(-100);
|
||||
const up = wheel(100);
|
||||
const upState = { prevented: up.prevented, stopped: up.stopped, zoom: zoomLog().at(-1) };
|
||||
wheel(100);
|
||||
wheel(100);
|
||||
wheel(-100);
|
||||
wheel(-100);
|
||||
const down = zoomLog().at(-1);
|
||||
wheel(0); // 가로 굴림 — 배율 그대로 · 스크롤은 막음
|
||||
const flat = zoomLog().at(-1);
|
||||
for (let i = 0; i < 30; i++) wheel(-50);
|
||||
for (let i = 0; i < 30; i++) wheel(50);
|
||||
const max = zoomLog().at(-1);
|
||||
for (let i = 0; i < 30; i++) wheel(120);
|
||||
for (let i = 0; i < 30; i++) wheel(-120);
|
||||
const min = zoomLog().at(-1);
|
||||
win.fire("keyup", { code: "KeyZ" });
|
||||
const n = zoomLog().length;
|
||||
const afterOff = wheel(-100);
|
||||
const afterOff = wheel(100);
|
||||
const afterOffZoomed = zoomLog().length !== n;
|
||||
win.fire("keydown", { code: "KeyZ" });
|
||||
wheel(-100);
|
||||
wheel(100);
|
||||
r.wheel = {
|
||||
off: offState,
|
||||
up: upState,
|
||||
@@ -134,17 +136,17 @@ r.consts = {
|
||||
afterOff: { prevented: afterOff.prevented, stopped: afterOff.stopped, zoomed: afterOffZoomed },
|
||||
reopen: zoomLog().at(-1), // 다시 켜면 2배에서 한 칸
|
||||
pure: [
|
||||
m.nextLoupeZoom(2, -1),
|
||||
m.nextLoupeZoom(2, 1),
|
||||
m.nextLoupeZoom(6, -1),
|
||||
m.nextLoupeZoom(1.5, 1),
|
||||
m.nextLoupeZoom(2, -1),
|
||||
m.nextLoupeZoom(6, 1),
|
||||
m.nextLoupeZoom(1.5, -1),
|
||||
],
|
||||
};
|
||||
// 길게 누름으로 켠 동안도 같음
|
||||
const b = setup();
|
||||
b.win.fire("mousedown", { button: 2, buttons: 2 });
|
||||
b.win.tick(400);
|
||||
const press = b.win.fire("wheel", { deltaY: -1 });
|
||||
const press = b.win.fire("wheel", { deltaY: 1 });
|
||||
r.wheel.press = { prevented: press.prevented, zoom: b.view.log.at(-1) };
|
||||
}
|
||||
{
|
||||
@@ -298,7 +300,7 @@ r.origin = {
|
||||
// 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임
|
||||
const { win, view } = setup();
|
||||
win.fire("keydown", { code: "KeyZ" });
|
||||
win.fire("wheel", { deltaY: -100 }); // 2.25
|
||||
win.fire("wheel", { deltaY: 100 }); // 2.25
|
||||
win.fire("mouseover", { target: cad });
|
||||
const hidden = last(view);
|
||||
const n = view.log.length;
|
||||
|
||||
@@ -28,6 +28,7 @@ function loadTs(full) {
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
STUBS["./input_prefs"] = loadTs(path.join(ROOT, "A00_Common", "input_prefs.ts")); // 휠 방향(332 · Node = 기본 당김)
|
||||
const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts"));
|
||||
const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* PLAN 332-1 단위검증 헬퍼 — 휠 방향 읽기 층(`A00_Common/input_prefs.ts`) · 손가락 다리(`touch_bridge.ts`)
|
||||
* · 등록표 키(`b_page_state.ts`)를 그 자리에서 트랜스파일해 Node 로 돌림. 가짜 localStorage 로 설정을 바꿔 봄.
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_input_prefs.py`. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const A00 = path.join(ROOT, "A00_Common");
|
||||
function load(file) {
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const req = (p) => (p === "./input_prefs" ? prefs : {});
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
// ① Node(저장소 없음) — 불러올 때 window 를 안 만지고 기본값
|
||||
const prefs = load(path.join(A00, "input_prefs.ts"));
|
||||
const bridge = load(path.join(A00, "touch_bridge.ts"));
|
||||
const page = load(path.join(A00, "b_page_state.ts"));
|
||||
const sample = () => ({
|
||||
prefs: prefs.inputPrefs(),
|
||||
pull: prefs.wheelIn(100),
|
||||
push: prefs.wheelIn(-100),
|
||||
mouseCtrl: prefs.wheelEventIn({ ctrlKey: true, deltaY: 100 }),
|
||||
padSpread: prefs.wheelEventIn({ ctrlKey: true, deltaY: -3.5 }),
|
||||
padPinch: prefs.wheelEventIn({ ctrlKey: true, deltaY: 3.5 }),
|
||||
fingerSpread: prefs.wheelEventIn({ ctrlKey: true, deltaY: -9.5 }, true),
|
||||
bridgePull: bridge.wheelZoomsIn({ ctrlKey: false, deltaY: 100 }),
|
||||
});
|
||||
out.node = { key: prefs.INPUT_PREFS_KEY, default: prefs.DEFAULT_INPUT_PREFS, ...sample() };
|
||||
out.registryKey = page.stateKey("input-prefs");
|
||||
out.registryEntry = page.STATE_REGISTRY["input-prefs"];
|
||||
|
||||
// ② 브라우저 저장소 흉내 — 값을 바꾸면 다음 부름부터 바로 따름
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
const set = (v) => store.set(prefs.INPUT_PREFS_KEY, v);
|
||||
set(JSON.stringify({ wheelZoom: "push", cadAliases: { line: ["ll", 3], ARC: [] } }));
|
||||
out.push = sample();
|
||||
set("{깨짐");
|
||||
out.broken = sample();
|
||||
set(JSON.stringify({ wheelZoom: "sideways" }));
|
||||
out.unknown = sample();
|
||||
store.delete(prefs.INPUT_PREFS_KEY);
|
||||
out.empty = sample();
|
||||
// ③ 불러오는 동안 저장소가 던지는 브라우저(사생활 보호) — 기본값
|
||||
globalThis.localStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
};
|
||||
out.blocked = sample();
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -34,10 +34,10 @@ def test_지름_360_배율_한도(r):
|
||||
assert (c["zoom"], c["zoomMin"], c["zoomMax"], c["zoomStep"]) == (2, 1.5, 6, 0.25)
|
||||
|
||||
|
||||
def test_휠_위_크게_아래_작게_한도(r):
|
||||
def test_휠_당김_크게_밀기_작게_한도(r): # 332 기본 = 당김 확대
|
||||
w = r["wheel"]
|
||||
assert w["up"]["zoom"] == 2.25 # 위로 한 칸
|
||||
assert w["down"] == 1.75 # 아래로 두 칸
|
||||
assert w["up"]["zoom"] == 2.25 # 당겨 한 칸
|
||||
assert w["down"] == 1.75 # 밀어 두 칸
|
||||
assert w["flat"] == 1.75 # 가로 굴림은 그대로
|
||||
assert w["max"] == 6 and w["min"] == 1.5
|
||||
assert w["pure"] == [2.25, 1.75, 6, 1.5]
|
||||
|
||||
@@ -20,7 +20,10 @@ def _read(rel):
|
||||
|
||||
|
||||
def _node(script):
|
||||
src = IMPORTS.sub("", _read("A00_Common/b_svg_zoom_pan.ts"))
|
||||
# 휠 방향 읽기 층(332) 을 앞에 붙임 — Node 엔 저장소가 없어 기본(당기면 확대)
|
||||
src = _read("A00_Common/input_prefs.ts") + IMPORTS.sub(
|
||||
"", _read("A00_Common/b_svg_zoom_pan.ts")
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src + "\n" + script],
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""PLAN 332-1 — 휠 확대 방향 한 설정(`A00_Common/input_prefs.ts`) · 모든 휠 화면이 그 판정을 거침.
|
||||
|
||||
· 기본 = 당김(휠을 몸 쪽으로 = 확대) · 밀기 저장 뒤 뒤집힘 · 깨짐 · 모르는 값 · 막힌 저장소 · Node = 기본
|
||||
· 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대
|
||||
· 키 = 등록표 `input-prefs`(pref · global) 와 같은 글자
|
||||
짝: `helper_input_prefs.cjs`(실제 TS 를 Node 로 돌림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_input_prefs.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
PULL = {"pull": True, "push": False, "mouseCtrl": True, "bridgePull": True}
|
||||
PINCH = {"padSpread": True, "padPinch": False, "fingerSpread": True}
|
||||
|
||||
|
||||
def _pick(case: dict, keys: dict) -> dict:
|
||||
return {k: case[k] for k in keys}
|
||||
|
||||
|
||||
def test_Node_기본_당김(r):
|
||||
n = r["node"]
|
||||
assert n["default"] == {"wheelZoom": "pull", "cadAliases": {}}
|
||||
assert n["prefs"] == n["default"]
|
||||
assert _pick(n, PULL) == PULL and _pick(n, PINCH) == PINCH
|
||||
|
||||
|
||||
def test_밀기_저장_뒤_뒤집힘_핀치는_그대로(r):
|
||||
p = r["push"]
|
||||
assert p["prefs"]["wheelZoom"] == "push"
|
||||
assert _pick(p, PULL) == {"pull": False, "push": True, "mouseCtrl": False, "bridgePull": False}
|
||||
assert _pick(p, PINCH) == PINCH # 벌리면 확대는 설정과 상관없음
|
||||
# 별칭 = 대문자 · 글자 아닌 것 · 빈 목록 버림
|
||||
assert p["prefs"]["cadAliases"] == {"LINE": ["LL"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["broken", "unknown", "empty", "blocked"])
|
||||
def test_깨짐_모르는_값_없음_막힘_기본(r, case):
|
||||
c = r[case]
|
||||
assert c["prefs"] == {"wheelZoom": "pull", "cadAliases": {}}
|
||||
assert _pick(c, PULL) == PULL and _pick(c, PINCH) == PINCH
|
||||
|
||||
|
||||
def test_등록표_키(r):
|
||||
assert r["node"]["key"] == r["registryKey"] == "aislo:pref:input-prefs"
|
||||
assert r["registryEntry"] == {"bucket": "pref", "scope": "global"}
|
||||
|
||||
|
||||
def test_읽기_층은_의존_없음_불러올_때_window_안_만짐():
|
||||
src = _read("A00_Common/input_prefs.ts")
|
||||
assert not re.search(r"^import ", src, re.M)
|
||||
assert "window." not in src
|
||||
|
||||
|
||||
# 휠 줌 자리 — 직접 박은 방향(`deltaY < 0 ?` · `deltaY > 0 ?` · `-ev.deltaY`)이 없고 공용 판정을 부름
|
||||
WHEEL_SITES = {
|
||||
"A00_Common/touch_bridge.ts": "wheelEventIn(e, isPinchWheel(e))",
|
||||
"A00_Common/b_svg_zoom_pan.ts": "wheelEventIn(event)",
|
||||
"B04_PreProcess/B04_PreProcess_UI_Camera.ts": "wheelEventIn(event)",
|
||||
"M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts": "wheelZoomsIn(ev)",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts": "wheelZoomsIn(e)",
|
||||
"ui_template/ui_template_loupe.ts": "wheelIn(deltaY)",
|
||||
"A00_Common/spreadsheet/spreadsheet_grid.ts": "wheelZoomsIn(ev)",
|
||||
# wheelZoomsIn 을 거쳐 따라오는 셋
|
||||
"B04_PreProcess/B04_PreProcess_UI_MapViewer.ts": "wheelZoomsIn(event)",
|
||||
"B05_Profile/B05_Profile_UI_Drainage_Interact.ts": "wheelZoomsIn(event)",
|
||||
"B05_Profile/B05_Profile_UI_RouteEdit_Input.ts": "wheelZoomsIn(event)",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel", sorted(WHEEL_SITES))
|
||||
def test_휠_자리_공용_판정(rel):
|
||||
src = _read(rel)
|
||||
assert WHEEL_SITES[rel] in src
|
||||
assert not re.search(r"deltaY [<>] 0 \?", src), rel
|
||||
assert "-ev.deltaY" not in src and "Math.sign(-deltaY)" not in src
|
||||
|
||||
|
||||
def test_700줄_안():
|
||||
for rel in WHEEL_SITES:
|
||||
assert _read(rel).count("\n") <= 700, rel
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import "./ui_template_loupe.css";
|
||||
import { keyLetter } from "../A00_Common/key_shortcut";
|
||||
import { wheelIn } from "../A00_Common/input_prefs";
|
||||
|
||||
/** 원 지름(px) */
|
||||
export const LOUPE_DIAMETER = 360;
|
||||
@@ -86,9 +87,9 @@ export function isCadTarget(target: unknown): boolean {
|
||||
return !!(target as Partial<Element> | null)?.closest?.(LOUPE_SKIP);
|
||||
}
|
||||
|
||||
/** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */
|
||||
/** 휠 한 번 뒤 배율 — ☰ [설정] 방향(332 · 기본 당김 = deltaY > 0 = 크게) · 한도 안 */
|
||||
export function nextLoupeZoom(zoom: number, deltaY: number): number {
|
||||
const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP;
|
||||
const step = deltaY ? (wheelIn(deltaY) ? 1 : -1) * LOUPE_ZOOM_STEP : 0;
|
||||
return Math.max(LOUPE_ZOOM_MIN, Math.min(LOUPE_ZOOM_MAX, zoom + step));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user