Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6
This commit is contained in:
@@ -453,7 +453,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
zoom: profileZoom.state,
|
||||
holdElevationRange: elevationHold.hold,
|
||||
toolActive: () => tools.mode() !== "none",
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
tools,
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
setElevationWindowUpdater: (update) => {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_free.ts
|
||||
* 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1).
|
||||
*
|
||||
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
||||
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
|
||||
*
|
||||
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
||||
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
|
||||
* (R = L / |대수차|). 양 끝 세로선의 라운드는 직선화처럼 그대로 둔다.
|
||||
*
|
||||
* 화면(`design_profile_free_view.ts`)은 클릭 · 마우스 · 키만 넘기고 여기 표시 자료를 그린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
ProfileAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
controlElevationAt,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
baseElevationAt,
|
||||
defaultCurveLength,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Straighten";
|
||||
|
||||
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
|
||||
const SAME_M = 1e-6;
|
||||
/** 클릭 높이를 이 단위(m)로 끊는다. */
|
||||
const ELEVATION_STEP_M = 0.01;
|
||||
|
||||
export interface FreeVertex {
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
}
|
||||
|
||||
/** range = 범위 고르는 중 · armed = 시작 세로선을 기다림 · drawing = 점 찍는 중. */
|
||||
export type FreePhase = "range" | "armed" | "drawing";
|
||||
|
||||
/** 그래프 세로 창 — 그릴 때 기준(`data-yw-*`)과 지금 걸린 변환. */
|
||||
export interface ChartWindow {
|
||||
center: number;
|
||||
pxPerM: number;
|
||||
zero: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** 세로 창 변환 글(`translate(0 a) scale(1 b)`)을 읽는다 — 없으면 그릴 때 그대로. */
|
||||
export function parseWindowTransform(text: string | null): { scale: number; offset: number } {
|
||||
const num = "([-+0-9.eE]+)";
|
||||
const match = new RegExp(`translate\\(\\s*0[ ,]+${num}\\s*\\)\\s*scale\\(\\s*1[ ,]+${num}`).exec(
|
||||
text ?? "",
|
||||
);
|
||||
if (!match) return { scale: 1, offset: 0 };
|
||||
const offset = Number(match[1]);
|
||||
const scale = Number(match[2]);
|
||||
return Number.isFinite(offset) && Number.isFinite(scale) && scale > 0
|
||||
? { scale, offset }
|
||||
: { scale: 1, offset: 0 };
|
||||
}
|
||||
|
||||
/** 그래프 y(px) → 표고(m). 과장 배율은 `pxPerM` 에 들어 있다. */
|
||||
export function elevationAtChartY(view: ChartWindow, y: number): number {
|
||||
const baked = (y - view.offset) / view.scale;
|
||||
return view.center + (view.zero - baked) / view.pxPerM;
|
||||
}
|
||||
|
||||
/** 표고(m) → 그래프 y(px). `elevationAtChartY` 의 역. */
|
||||
export function chartYAtElevation(view: ChartWindow, elevationM: number): number {
|
||||
return view.scale * (view.zero - (elevationM - view.center) * view.pxPerM) + view.offset;
|
||||
}
|
||||
|
||||
/** 가장 가까운 측점 세로선. 범위를 주면 그 안(양 끝 포함)에서만 고른다. */
|
||||
export function snapStation(
|
||||
stations: number[],
|
||||
chainageM: number,
|
||||
range: [number, number] | null = null,
|
||||
): number | null {
|
||||
let best: number | null = null;
|
||||
for (const station of stations) {
|
||||
if (range && (station < range[0] - SAME_M || station > range[1] + SAME_M)) continue;
|
||||
if (best === null || Math.abs(station - chainageM) < Math.abs(best - chainageM)) {
|
||||
best = station;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 두 점 사이 기울기(%). */
|
||||
export function gradePct(from: FreeVertex, to: FreeVertex): number {
|
||||
const span = to.chainage_m - from.chainage_m;
|
||||
return Math.abs(span) > 1e-12 ? ((to.elevation_m - from.elevation_m) / span) * 100 : 0;
|
||||
}
|
||||
|
||||
/** 임도 기준 최대 기울기를 넘는가 — 판정은 선형 계산의 위반 판정(`violations`)과 같은 식. */
|
||||
export function overGrade(pct: number, maxPct: number): boolean {
|
||||
return Math.abs(pct) > maxPct + 1e-6;
|
||||
}
|
||||
|
||||
/** 점들을 잇는 꺾은선 위 표고(chainage 오름차순 꺾은선). */
|
||||
function elevationOnPath(path: FreeVertex[], chainageM: number): number {
|
||||
for (let index = 1; index < path.length; index += 1) {
|
||||
const left = path[index - 1];
|
||||
const right = path[index];
|
||||
if (chainageM > right.chainage_m + SAME_M) continue;
|
||||
const span = right.chainage_m - left.chainage_m;
|
||||
const ratio = span > 1e-12 ? (chainageM - left.chainage_m) / span : 0;
|
||||
return left.elevation_m + (right.elevation_m - left.elevation_m) * ratio;
|
||||
}
|
||||
return path[path.length - 1].elevation_m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
|
||||
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
|
||||
*/
|
||||
export function buildFreeEdits(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
vertices: FreeVertex[],
|
||||
): AlignmentEdits {
|
||||
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (path.length < 2) return edits;
|
||||
const fromM = path[0].chainage_m;
|
||||
const toM = path[path.length - 1].chainage_m;
|
||||
if (toM - fromM <= SAME_M) return edits;
|
||||
const inner = new Map<string, number>();
|
||||
buildAlignment(base, edits).pvi.forEach((node) => {
|
||||
if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) {
|
||||
inner.set(chainageKey(node.chainage_m), node.chainage_m);
|
||||
}
|
||||
});
|
||||
path
|
||||
.slice(1, -1)
|
||||
.forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m));
|
||||
const offsets = { ...edits.station_offsets };
|
||||
const radii = { ...edits.curve_radii };
|
||||
inner.forEach((chainage, key) => {
|
||||
const target = elevationOnPath(path, chainage);
|
||||
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
|
||||
delete radii[key];
|
||||
});
|
||||
for (let index = 1; index < path.length - 1; index += 1) {
|
||||
const gradeIn = gradePct(path[index - 1], path[index]) / 100;
|
||||
const gradeOut = gradePct(path[index], path[index + 1]) / 100;
|
||||
const deltaGrade = Math.abs(gradeOut - gradeIn);
|
||||
if (deltaGrade < 1e-9) continue;
|
||||
radii[chainageKey(path[index].chainage_m)] = Number(
|
||||
(defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6),
|
||||
);
|
||||
}
|
||||
return { ...edits, station_offsets: offsets, curve_radii: radii };
|
||||
}
|
||||
|
||||
export interface FreeSegment {
|
||||
from: FreeVertex;
|
||||
to: FreeVertex;
|
||||
grade_pct: number;
|
||||
over: boolean;
|
||||
/** 마지막 점 → 마우스 자리(아직 안 찍음). */
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
/** 오버레이 표시 자료 — 화면은 이것만 그린다. */
|
||||
export interface FreeMarks {
|
||||
/** 고른 범위(강조 띠) — 둘 다 고르기 전엔 null. */
|
||||
band: [number, number] | null;
|
||||
/** 세로 강조선 chainage(범위 첫 측점 또는 범위 양 끝). */
|
||||
edges: number[];
|
||||
segments: FreeSegment[];
|
||||
/** 찍힌 점(시작 점 포함). */
|
||||
dots: FreeVertex[];
|
||||
}
|
||||
|
||||
export interface FreeDrawDeps {
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
|
||||
apply: (next: AlignmentEdits) => void;
|
||||
/** 모드를 끈다(적용 뒤 · Esc). */
|
||||
close: () => void;
|
||||
/** 단계 · 안내가 바뀜. */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
export interface FreeDraw {
|
||||
phase: () => FreePhase;
|
||||
reset: () => void;
|
||||
/** 그래프 클릭 — 날 chainage · 표고와 그래프 측점선 목록. 먹었으면 true. */
|
||||
click: (chainageM: number, elevationM: number, stations: number[]) => boolean;
|
||||
/** 마우스 자리 — 미리 보기 대상. 그래프 밖이면 null. */
|
||||
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
|
||||
/** Enter · Escape · Backspace — 먹었으면 true. */
|
||||
key: (key: string) => boolean;
|
||||
marks: () => FreeMarks;
|
||||
hint: () => string;
|
||||
}
|
||||
|
||||
export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
let phase: FreePhase = "range";
|
||||
let pending: number | null = null;
|
||||
let range: [number, number] | null = null;
|
||||
let anchor: number | null = null;
|
||||
let points: FreeVertex[] = [];
|
||||
let target: FreeVertex | null = null;
|
||||
|
||||
function reset(): void {
|
||||
phase = "range";
|
||||
pending = null;
|
||||
range = null;
|
||||
anchor = null;
|
||||
points = [];
|
||||
target = null;
|
||||
}
|
||||
|
||||
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
|
||||
function heightAt(chainageM: number): number | null {
|
||||
const alignment = deps.alignment();
|
||||
return alignment ? controlElevationAt(alignment, chainageM) : null;
|
||||
}
|
||||
|
||||
const finishM = (): number | null =>
|
||||
range && anchor !== null ? (anchor === range[0] ? range[1] : range[0]) : null;
|
||||
const same = (a: number | null, b: number | null): boolean =>
|
||||
a !== null && b !== null && Math.abs(a - b) < SAME_M;
|
||||
|
||||
/** 지나온 점(시작 포함) — 마지막 것이 다음 선의 출발점. */
|
||||
function path(): FreeVertex[] {
|
||||
if (anchor === null) return [];
|
||||
const z = heightAt(anchor);
|
||||
return z === null ? [] : [{ chainage_m: anchor, elevation_m: z }, ...points];
|
||||
}
|
||||
|
||||
/** 클릭 · 마우스 자리 → 다음 점 후보. 끝 세로선이면 그 계획고, 뒤로 가면 null. */
|
||||
function candidate(chainageM: number, elevationM: number, stations: number[]): FreeVertex | null {
|
||||
if (phase !== "drawing" || !range) return null;
|
||||
const station = snapStation(stations, chainageM, range);
|
||||
const end = finishM();
|
||||
if (station === null || end === null) return null;
|
||||
if (same(station, end)) {
|
||||
const z = heightAt(end);
|
||||
return z === null ? null : { chainage_m: end, elevation_m: z };
|
||||
}
|
||||
const walked = path();
|
||||
const last = walked[walked.length - 1];
|
||||
if (!last || (station - last.chainage_m) * Math.sign(end - last.chainage_m) <= SAME_M) {
|
||||
return null;
|
||||
}
|
||||
const z = Math.round(elevationM / ELEVATION_STEP_M) * ELEVATION_STEP_M;
|
||||
return { chainage_m: station, elevation_m: Number(z.toFixed(3)) };
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
const base = deps.base();
|
||||
const end = finishM();
|
||||
const z = end === null ? null : heightAt(end);
|
||||
if (!base || end === null || z === null) return;
|
||||
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices);
|
||||
reset();
|
||||
deps.apply(next);
|
||||
deps.close();
|
||||
}
|
||||
|
||||
return {
|
||||
phase: () => phase,
|
||||
reset,
|
||||
click(chainageM, elevationM, stations) {
|
||||
if (phase === "range") {
|
||||
const station = snapStation(stations, chainageM);
|
||||
if (station === null) return true;
|
||||
if (pending === null || same(pending, station)) pending = station;
|
||||
else {
|
||||
range = [Math.min(pending, station), Math.max(pending, station)];
|
||||
pending = null;
|
||||
phase = "armed";
|
||||
}
|
||||
} else if (phase === "armed" && range) {
|
||||
const station = snapStation(stations, chainageM, range);
|
||||
if (same(station, range[0]) || same(station, range[1])) {
|
||||
anchor = station;
|
||||
points = [];
|
||||
phase = "drawing";
|
||||
}
|
||||
} else if (phase === "drawing") {
|
||||
const next = candidate(chainageM, elevationM, stations);
|
||||
if (!next) return true;
|
||||
if (same(next.chainage_m, finishM())) {
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
points.push(next);
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
},
|
||||
hover(pointer) {
|
||||
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
|
||||
},
|
||||
key(key) {
|
||||
if (key === "Escape") {
|
||||
reset();
|
||||
deps.close();
|
||||
return true;
|
||||
}
|
||||
if (key === "Enter") {
|
||||
if (phase !== "drawing") return false;
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
if (key === "Backspace") {
|
||||
if (phase !== "drawing") return false;
|
||||
if (points.length) points.pop();
|
||||
else {
|
||||
anchor = null;
|
||||
phase = "armed";
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
marks() {
|
||||
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
|
||||
const dots = path();
|
||||
const line = target ? [...dots, target] : dots;
|
||||
const segments: FreeSegment[] = [];
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const grade = gradePct(line[index - 1], line[index]);
|
||||
segments.push({
|
||||
from: line[index - 1],
|
||||
to: line[index],
|
||||
grade_pct: grade,
|
||||
over: overGrade(grade, maxPct),
|
||||
preview: target !== null && index === line.length - 1,
|
||||
});
|
||||
}
|
||||
return {
|
||||
band: range,
|
||||
edges: range ? [...range] : pending !== null ? [pending] : [],
|
||||
segments,
|
||||
dots,
|
||||
};
|
||||
},
|
||||
hint() {
|
||||
if (phase === "range") {
|
||||
return pending === null
|
||||
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
||||
}
|
||||
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_free_view.ts
|
||||
* 종단 [자유지정] 화면 — 그래프 클릭 · 마우스 · 오버레이(범위 강조 · 점선 · 기울기 글)(215-1).
|
||||
*
|
||||
* 계산 · 상태는 `design_profile_free.ts` 몫. 여기서는 마우스 자리를 (chainage, 표고)로 바꿔
|
||||
* 넘기고 돌려받은 표시 자료를 그린다. 표고 변환은 종단 그래프 세로 창(`data-yw-*` + 지금 걸린
|
||||
* 변환)을 그대로 되짚는다 — 스크롤로 창이 바뀌어도 같은 식이다.
|
||||
*
|
||||
* 그래프는 그릴 때마다 새로 만들어지므로 그릴 때마다 `mount` 를 다시 부른다.
|
||||
* 자유지정 중엔 그래프 클릭 · 누르기를 여기서 먹는다(측점 고르기 · 구조물 끌기로 새지 않게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import {
|
||||
chartYAtElevation,
|
||||
elevationAtChartY,
|
||||
parseWindowTransform,
|
||||
type ChartWindow,
|
||||
type FreeDraw,
|
||||
} from "./design_profile_free";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 종단 그래프 한 벌의 가로 매핑 — 렌더러가 그릴 때마다 넘긴다. */
|
||||
export interface FreeChartGeom {
|
||||
/** 그래프 래퍼(`b05-profile__chart`). */
|
||||
chart: HTMLElement;
|
||||
/** 가로 스크롤러 — 스크롤하면 세로 창이 바뀌어 다시 그린다. */
|
||||
scroller: HTMLElement;
|
||||
x: (chainageM: number) => number;
|
||||
toChainage: (px: number) => number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
/** 그래프 세로 측점선 chainage(정규 + 구조물). */
|
||||
stations: number[];
|
||||
}
|
||||
|
||||
export interface FreeView {
|
||||
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
|
||||
mount: (geom: FreeChartGeom | null, active: boolean) => void;
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
|
||||
const element = document.createElementNS(SVG_NS, tag);
|
||||
Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value)));
|
||||
return element;
|
||||
}
|
||||
|
||||
/** 지금 그래프의 세로 창 — 자동 창 SVG 가 없으면 null. */
|
||||
function readWindow(chart: HTMLElement): { view: ChartWindow; top: number } | null {
|
||||
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
|
||||
if (!svg) return null;
|
||||
const read = (name: string): number => Number(svg.getAttribute(name));
|
||||
const layer = svg.querySelector(`.${Y_WINDOW_CLASS}`);
|
||||
const view: ChartWindow = {
|
||||
center: read(Y_WINDOW_ATTRS.center),
|
||||
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
|
||||
zero: read(Y_WINDOW_ATTRS.zero),
|
||||
...parseWindowTransform(layer?.getAttribute("transform") ?? null),
|
||||
};
|
||||
if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) {
|
||||
return null;
|
||||
}
|
||||
// 그래프 SVG 가 래퍼 위쪽에서 떨어져 있으면 그만큼 오버레이 y 를 맞춘다.
|
||||
const top = svg.getBoundingClientRect().top - chart.getBoundingClientRect().top;
|
||||
return { view, top };
|
||||
}
|
||||
|
||||
export function createFreeView(free: FreeDraw): FreeView {
|
||||
let geom: FreeChartGeom | null = null;
|
||||
let active = false;
|
||||
let overlay: SVGSVGElement | null = null;
|
||||
let listeners: AbortController | null = null;
|
||||
|
||||
/** 마우스 자리 → (chainage, 표고). 그래프 밖이면 null. */
|
||||
function pointerAt(event: MouseEvent) {
|
||||
if (!geom) return null;
|
||||
const win = readWindow(geom.chart);
|
||||
if (!win) return null;
|
||||
const rect = geom.chart.getBoundingClientRect();
|
||||
const chainageM = geom.toChainage(event.clientX - rect.left);
|
||||
if (!Number.isFinite(chainageM)) return null;
|
||||
const elevationM = elevationAtChartY(win.view, event.clientY - rect.top - win.top);
|
||||
return { chainageM, elevationM, stations: geom.stations };
|
||||
}
|
||||
|
||||
function redraw(): void {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
if (!geom || !active) return;
|
||||
const win = readWindow(geom.chart);
|
||||
if (!win) return;
|
||||
const { x, widthPx, heightPx } = geom;
|
||||
const y = (elevationM: number): number => chartYAtElevation(win.view, elevationM) + win.top;
|
||||
const marks = free.marks();
|
||||
const svg = svgEl("svg", {
|
||||
class: "b05-profile-freemark",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
}) as SVGSVGElement;
|
||||
if (marks.band) {
|
||||
const [left, right] = marks.band.map(x);
|
||||
svg.append(
|
||||
svgEl("rect", {
|
||||
class: "b05-profile-freemark__band",
|
||||
x: left,
|
||||
y: 0,
|
||||
width: Math.max(0, right - left),
|
||||
height: heightPx,
|
||||
}),
|
||||
);
|
||||
}
|
||||
marks.edges.forEach((chainage) =>
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: "b05-profile-freemark__edge",
|
||||
x1: x(chainage),
|
||||
x2: x(chainage),
|
||||
y1: 0,
|
||||
y2: heightPx,
|
||||
}),
|
||||
),
|
||||
);
|
||||
marks.segments.forEach((segment) => {
|
||||
const x1 = x(segment.from.chainage_m);
|
||||
const x2 = x(segment.to.chainage_m);
|
||||
const y1 = y(segment.from.elevation_m);
|
||||
const y2 = y(segment.to.elevation_m);
|
||||
const over = segment.over ? " is-over" : "";
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: `b05-profile-freemark__line${over}${segment.preview ? " is-preview" : ""}`,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
}),
|
||||
);
|
||||
const label = svgEl("text", {
|
||||
class: `b05-profile-freemark__grade${over}`,
|
||||
x: (x1 + x2) / 2,
|
||||
y: (y1 + y2) / 2 - 6,
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
label.textContent = `${segment.grade_pct.toFixed(1)}%`;
|
||||
svg.append(label);
|
||||
});
|
||||
marks.dots.forEach((dot) =>
|
||||
svg.append(
|
||||
svgEl("circle", {
|
||||
class: "b05-profile-freemark__dot",
|
||||
cx: x(dot.chainage_m),
|
||||
cy: y(dot.elevation_m),
|
||||
r: 3.5,
|
||||
}),
|
||||
),
|
||||
);
|
||||
overlay = svg;
|
||||
geom.chart.append(svg);
|
||||
}
|
||||
|
||||
function mount(next: FreeChartGeom | null, on: boolean): void {
|
||||
listeners?.abort();
|
||||
listeners = null;
|
||||
if (next) geom = next;
|
||||
active = on;
|
||||
if (!on) free.reset();
|
||||
if (!geom || !on || !geom.chart.isConnected) {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
return;
|
||||
}
|
||||
listeners = new AbortController();
|
||||
const { signal } = listeners;
|
||||
const chart = geom.chart;
|
||||
const swallow = (event: Event): void => {
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
// 누르기는 삼킴 — 구조물 측점선 끌기 · 계획고 버튼 길게 누르기가 시작되지 않게.
|
||||
chart.addEventListener("pointerdown", swallow, { capture: true, signal });
|
||||
chart.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
swallow(event);
|
||||
event.preventDefault();
|
||||
const pointer = pointerAt(event);
|
||||
if (pointer) free.click(pointer.chainageM, pointer.elevationM, pointer.stations);
|
||||
free.hover(pointerAt(event));
|
||||
redraw();
|
||||
},
|
||||
{ capture: true, signal },
|
||||
);
|
||||
chart.addEventListener(
|
||||
"mousemove",
|
||||
(event) => {
|
||||
free.hover(pointerAt(event));
|
||||
redraw();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
chart.addEventListener(
|
||||
"mouseleave",
|
||||
() => {
|
||||
free.hover(null);
|
||||
redraw();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
geom.scroller.addEventListener("scroll", redraw, { signal, passive: true });
|
||||
redraw();
|
||||
}
|
||||
|
||||
return { mount, redraw };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* =============================================================================
|
||||
* key_shortcut.ts — Ctrl(⌘)+글자 단축키 판정 공용(PLAN 214-2)
|
||||
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞음 — 영문 글자·숫자면 그 글자 ·
|
||||
* 아니면 자판 자리(`code`: KeyZ · Digit1)로 판정. Shift 의 대문자도 소문자로 맞춤.
|
||||
* 쓰는 곳 — 페이지(B05 · M02 · 시트 키) + CAD(`B07_DesignDetail/openwebcad` 의 `input-shortcut.ts` 가 다시 내보냄).
|
||||
* 시험 = `resources/tester/cad/ctrl-shortcut-hangul.test.ts`
|
||||
* ========================================================================== */
|
||||
|
||||
type KeyLike = Pick<KeyboardEvent, "code"> & Partial<Pick<KeyboardEvent, "key">>;
|
||||
|
||||
/** 누른 글자·숫자(소문자) — 글자·숫자 자리가 아니면 빈 글 */
|
||||
export function keyLetter(evt: KeyLike): string {
|
||||
const key = evt.key ?? "";
|
||||
if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase();
|
||||
const m = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(evt.code ?? "");
|
||||
return m ? (m[1] ?? m[2]).toLowerCase() : "";
|
||||
}
|
||||
|
||||
/** Ctrl(Mac ⌘)과 함께 누른 `letter`(소문자 글자·숫자)인가 — Shift · Alt 는 보지 않음 */
|
||||
export function isCtrlKey(
|
||||
evt: KeyLike & Pick<KeyboardEvent, "ctrlKey" | "metaKey">,
|
||||
letter: string,
|
||||
): boolean {
|
||||
return (evt.ctrlKey || evt.metaKey) && keyLetter(evt) === letter;
|
||||
}
|
||||
|
||||
/** Ctrl 단축키 이름 */
|
||||
export type CtrlShortcut = "undo" | "redo" | "save" | "selectAll";
|
||||
|
||||
/** 공용 Ctrl 단축키 — Z · Shift+Z · Y · S · A (Alt 함께는 아님 · Shift 는 Z 만) */
|
||||
export function ctrlShortcut(
|
||||
evt: KeyLike &
|
||||
Pick<KeyboardEvent, "ctrlKey" | "altKey" | "shiftKey"> &
|
||||
Partial<Pick<KeyboardEvent, "metaKey">>,
|
||||
): CtrlShortcut | null {
|
||||
if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null;
|
||||
switch (keyLetter(evt)) {
|
||||
case "z":
|
||||
return evt.shiftKey ? "redo" : "undo";
|
||||
case "y":
|
||||
return evt.shiftKey ? null : "redo";
|
||||
case "s":
|
||||
return evt.shiftKey ? null : "save";
|
||||
case "a":
|
||||
return evt.shiftKey ? null : "selectAll";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { keyLetter } from "../key_shortcut";
|
||||
import { cycleAbs } from "./spreadsheet_editor";
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
@@ -127,7 +128,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
|
||||
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
|
||||
return true;
|
||||
}
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
const k = keyLetter(e) || (e.key.length === 1 ? e.key.toLowerCase() : e.key);
|
||||
if (ctrl && !e.altKey) {
|
||||
switch (k) {
|
||||
case "z":
|
||||
@@ -233,7 +234,8 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫
|
||||
// 한글 조합 중 — 브라우저 몫 (Ctrl+글자 단축키는 조합 중에도 판정 · 한글 자판 대응 214-2)
|
||||
if ((e.isComposing || e.keyCode === 229) && !((e.ctrlKey || e.metaKey) && keyLetter(e))) return;
|
||||
const inBar = e.target === ed.formula;
|
||||
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
|
||||
if (done) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조.
|
||||
* ========================================================================== */
|
||||
|
||||
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
tiltStraightRun,
|
||||
} from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import { createFreeDraw } from "../A00_Common/design/design_profile_free";
|
||||
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
|
||||
|
||||
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */
|
||||
const KEY_STEP_M = 0.1;
|
||||
@@ -111,6 +114,18 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
pause: () => (syncPipes(), inner.pause()),
|
||||
};
|
||||
|
||||
// [자유지정] — 적용은 직선화 결과와 같은 길(applyEdits · 이력 · 막기 규칙)(215-1).
|
||||
const free = createFreeDraw({
|
||||
base: ctx.base,
|
||||
alignment: ctx.alignment,
|
||||
edits: ctx.edits,
|
||||
apply: (next) => ctx.applyEdits(next),
|
||||
close: () => tools.clear(),
|
||||
changed: ctx.refresh,
|
||||
});
|
||||
const freeView = createFreeView(free);
|
||||
let lastGeom: FreeChartGeom | null = null;
|
||||
|
||||
const tools = createProfileTools({
|
||||
onStraighten: (fromM, toM) => {
|
||||
const base = ctx.base();
|
||||
@@ -143,12 +158,19 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
return alignment ? hasStraightRuns(alignment) : false;
|
||||
},
|
||||
onChanged: ctx.refresh,
|
||||
onModeChange: (mode) => freeView.mount(lastGeom, mode === "free"),
|
||||
freeHint: () => free.hint(),
|
||||
mountOverlay: (geom) => {
|
||||
lastGeom = geom;
|
||||
freeView.mount(geom, tools.mode() === "free");
|
||||
},
|
||||
});
|
||||
|
||||
/** 그래프에서 누른 자리를 도구가 먹는다. */
|
||||
function handleToolPick(chainageM: number | null): boolean {
|
||||
const mode = tools.mode();
|
||||
if (mode === "none") return false;
|
||||
if (mode === "free") return true; // 자유지정 클릭은 오버레이가 먼저 먹는다.
|
||||
if (chainageM === null) return tools.handleRunPick(null);
|
||||
const alignment = ctx.alignment();
|
||||
if (mode === "shift") {
|
||||
@@ -283,7 +305,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
// 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다.
|
||||
if (target && target.closest("input, select, textarea")) return;
|
||||
const key = event.key;
|
||||
if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) {
|
||||
// [자유지정] 중 Enter 끝 · Esc 취소 · Backspace 점 빼기 — 단추 초점의 Enter 누름도 막는다.
|
||||
if (tools.mode() === "free" && free.key(key)) {
|
||||
event.preventDefault();
|
||||
freeView.redraw();
|
||||
return;
|
||||
}
|
||||
if (isCtrlKey(event, "z")) {
|
||||
event.preventDefault();
|
||||
flushHold();
|
||||
if (event.shiftKey) history.redo();
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Y_AXIS_WINDOW_CLASS,
|
||||
type ElevationWindowResult,
|
||||
} from "@util/common_util_chart_ywindow";
|
||||
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { ProfileTools } from "./B05_Profile_UI_Profile_Tools";
|
||||
import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover";
|
||||
import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import type { ProfileZoomState } from "./B05_Profile_UI_Profile_Zoom";
|
||||
@@ -106,8 +106,8 @@ export interface ProfileRenderContext {
|
||||
) => { min: number; max: number } | undefined;
|
||||
/** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */
|
||||
toolActive: () => boolean;
|
||||
/** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */
|
||||
selectedRuns: () => StraightRun[];
|
||||
/** [쉬프트]로 고른 직선 구간(빨간 강조) · [자유지정] 오버레이(215-1). */
|
||||
tools: Pick<ProfileTools, "selectedRuns" | "mountOverlay">;
|
||||
stationIdAtStructure: (structureId: string | null) => string | null;
|
||||
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
|
||||
redraw: () => void;
|
||||
@@ -368,7 +368,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
if (alignment) {
|
||||
const highlight = createRunHighlight({
|
||||
alignment,
|
||||
runs: ctx.selectedRuns(),
|
||||
runs: ctx.tools.selectedRuns(),
|
||||
x,
|
||||
axis: yAxis,
|
||||
widthPx: width,
|
||||
@@ -403,6 +403,16 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
canvas.append(chartWrap, structureLane);
|
||||
body.replaceChildren(canvas);
|
||||
body.scrollLeft = scrollLeft;
|
||||
// [자유지정] — 범위 강조 · 점선 미리 보기 · 클릭 점(215-1). 화면에 붙인 뒤라야 자리를 잰다.
|
||||
ctx.tools.mountOverlay({
|
||||
chart: chartWrap,
|
||||
scroller: body,
|
||||
x,
|
||||
toChainage,
|
||||
widthPx: width,
|
||||
heightPx: chartHeight,
|
||||
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
|
||||
});
|
||||
// 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다.
|
||||
if (table) table.style.width = `${width}px`;
|
||||
tableOverlay.setTable(table);
|
||||
|
||||
@@ -46,7 +46,7 @@ function pviChainages(alignment: ProfileAlignment): number[] {
|
||||
}
|
||||
|
||||
/** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */
|
||||
function baseElevationAt(base: AlignmentBase, chainageM: number): number {
|
||||
export function baseElevationAt(base: AlignmentBase, chainageM: number): number {
|
||||
const nodes = base.basePvi;
|
||||
if (!nodes.length) return 0;
|
||||
if (chainageM <= nodes[0].chainage_m) return nodes[0].elevation_m;
|
||||
@@ -181,7 +181,7 @@ export function straightenBetween(
|
||||
}
|
||||
|
||||
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
|
||||
function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number {
|
||||
export function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number {
|
||||
const length = base.policy.default_curve_length_m;
|
||||
if (typeof length === "number" && Number.isFinite(length) && length > 0) return length;
|
||||
return base.policy.default_curve_radius_m * Math.abs(deltaGrade);
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
* [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림.
|
||||
* 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에
|
||||
* 라운드가 생기고 양쪽이 탄젠트 직선이 된다.
|
||||
* [자유지정] → 범위를 고르고 클릭으로 계획선을 잇는다(`design_profile_free` 몫 · 215-1).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import { sameRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
|
||||
|
||||
export type ProfileToolMode = "none" | "straighten" | "shift";
|
||||
export type ProfileToolMode = "none" | "straighten" | "shift" | "free";
|
||||
|
||||
export interface ProfileToolsCallbacks {
|
||||
/** 두 측점을 직선으로 잇는다. */
|
||||
@@ -36,6 +38,12 @@ export interface ProfileToolsCallbacks {
|
||||
hasStraightRuns: () => boolean;
|
||||
/** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */
|
||||
onChanged: () => void;
|
||||
/** 모드가 바뀜 — [자유지정] 오버레이를 붙이고 뗀다. */
|
||||
onModeChange: (mode: ProfileToolMode) => void;
|
||||
/** [자유지정] 단계 안내. */
|
||||
freeHint: () => string;
|
||||
/** 그래프를 새로 그림 — [자유지정] 오버레이를 다시 붙인다. */
|
||||
mountOverlay: (geom: FreeChartGeom) => void;
|
||||
}
|
||||
|
||||
export interface ProfileTools {
|
||||
@@ -54,6 +62,8 @@ export interface ProfileTools {
|
||||
nudge: (delta: number) => boolean;
|
||||
/** 모드·선택을 모두 끈다. */
|
||||
clear: () => void;
|
||||
/** 그래프를 새로 그릴 때마다 — [자유지정] 오버레이 자리. */
|
||||
mountOverlay: (geom: FreeChartGeom) => void;
|
||||
}
|
||||
|
||||
function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
||||
@@ -78,6 +88,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
mode = "none";
|
||||
pending = null;
|
||||
runs = [];
|
||||
callbacks.onModeChange(mode);
|
||||
}
|
||||
|
||||
function setMode(next: ProfileToolMode): void {
|
||||
@@ -87,6 +98,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
mode = next;
|
||||
pending = null;
|
||||
runs = [];
|
||||
callbacks.onModeChange(mode);
|
||||
}
|
||||
callbacks.onChanged();
|
||||
}
|
||||
@@ -106,6 +118,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
nudge: step,
|
||||
selectedRuns: () => runs,
|
||||
pendingStation: () => pending,
|
||||
mountOverlay: callbacks.mountOverlay,
|
||||
clear() {
|
||||
if (mode === "none" && pending === null && !runs.length) return;
|
||||
reset();
|
||||
@@ -174,15 +187,26 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
() => setMode("shift"),
|
||||
);
|
||||
shift.classList.toggle("is-active", mode === "shift");
|
||||
const free = toolButton(
|
||||
"자유지정",
|
||||
"범위를 고른 뒤 클릭으로 계획선을 잇습니다 (Enter 끝 · Backspace 점 빼기 · Esc 취소)",
|
||||
() => setMode("free"),
|
||||
);
|
||||
free.classList.toggle("is-active", mode === "free");
|
||||
|
||||
const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1));
|
||||
const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1));
|
||||
const idle = mode === "none" || (!runs.length && mode === "shift");
|
||||
const idle = mode === "none" || mode === "free" || (!runs.length && mode === "shift");
|
||||
up.disabled = idle || (mode === "straighten" && runs.length !== 1);
|
||||
down.disabled = up.disabled;
|
||||
|
||||
wrap.append(reset, undo, redo, straighten, shift, up, down);
|
||||
if (mode === "straighten" && pending !== null) {
|
||||
wrap.append(reset, undo, redo, straighten, free, shift, up, down);
|
||||
if (mode === "free") {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = callbacks.freeHint();
|
||||
wrap.append(hint);
|
||||
} else if (mode === "straighten" && pending !== null) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`;
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { keyLetter } from "../A00_Common/key_shortcut";
|
||||
|
||||
export type Vertex = [number, number];
|
||||
|
||||
/** 되돌릴 수 있는 편집 상태 한 벌. */
|
||||
@@ -154,7 +156,7 @@ export function bindHistoryControls(params: HistoryControlsParams): {
|
||||
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const key = keyLetter(event);
|
||||
if (key === "z" && !event.shiftKey) step("undo");
|
||||
else if (key === "y" || (key === "z" && event.shiftKey)) step("redo");
|
||||
else return;
|
||||
|
||||
@@ -334,6 +334,56 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* [자유지정] 오버레이 — 범위 띠 · 지나온 점선 · 마우스까지 미리 보기 · 기울기 글(215-1).
|
||||
기준 기울기를 넘는 구간은 빨강. 클릭은 그래프 래퍼가 받으므로 오버레이는 마우스를 통과시킨다. */
|
||||
.b05-profile-freemark {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__band {
|
||||
fill: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.b05-profile-freemark__edge {
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__line {
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__line.is-preview {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__line.is-over {
|
||||
stroke: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
.b05-profile-freemark__dot {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__grade {
|
||||
fill: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.b05-profile-freemark__grade.is-over {
|
||||
fill: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
.b05-profile-edit {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
|
||||
@@ -124,7 +124,8 @@
|
||||
}
|
||||
|
||||
/* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸.
|
||||
위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게 */
|
||||
위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게.
|
||||
217-1 — 칩 줄 위 여백 16→8 · 칩 줄 ↔ 첫 카드 28→16(카드 칸 gap 24 에서 8 을 당김) */
|
||||
.b06-section__notice-bar {
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--spacing-16));
|
||||
@@ -132,8 +133,8 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: calc(-1 * var(--spacing-16));
|
||||
padding-block: var(--spacing-16) var(--spacing-4);
|
||||
margin-block: calc(-1 * var(--spacing-16)) calc(-1 * var(--spacing-8));
|
||||
padding-block: var(--spacing-8) 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
/** Ctrl 단축키 이름 */
|
||||
export type CtrlShortcut = 'undo' | 'redo' | 'save' | 'selectAll';
|
||||
|
||||
/**
|
||||
* Ctrl 단축키 판정 — 글자(`key`)가 아니라 자판 자리(`code`).
|
||||
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 오고, Shift 를 누르면 `key` 가 대문자(「Z」)라 글자로는 안 맞는다.
|
||||
* Ctrl 단축키 판정 — 공용 `A00_Common/key_shortcut.ts`(214-2)를 그대로 내보냄.
|
||||
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞아 자판 자리(`code`)로 본다.
|
||||
*/
|
||||
export function ctrlShortcut(
|
||||
evt: Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'shiftKey' | 'code'>
|
||||
): CtrlShortcut | null {
|
||||
if (!evt.ctrlKey || evt.altKey) return null;
|
||||
switch (evt.code) {
|
||||
case 'KeyZ':
|
||||
return evt.shiftKey ? 'redo' : 'undo';
|
||||
case 'KeyY':
|
||||
return evt.shiftKey ? null : 'redo';
|
||||
case 'KeyS':
|
||||
return evt.shiftKey ? null : 'save';
|
||||
case 'KeyA':
|
||||
return evt.shiftKey ? null : 'selectAll';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export {
|
||||
ctrlShortcut,
|
||||
isCtrlKey,
|
||||
keyLetter,
|
||||
type CtrlShortcut,
|
||||
} from '../../../../A00_Common/key_shortcut';
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "../../A00_Common/key_shortcut.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움).
|
||||
* ========================================================================== */
|
||||
|
||||
import { isCtrlKey } from "../../A00_Common/key_shortcut";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
@@ -209,7 +210,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
|
||||
|
||||
const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] });
|
||||
root.addEventListener("keydown", (ev) => {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
|
||||
if (isCtrlKey(ev, "z") && !ev.shiftKey) {
|
||||
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
@@ -258,7 +259,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
|
||||
});
|
||||
full.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape" && !ev.defaultPrevented) shrink();
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
|
||||
if (isCtrlKey(ev, "z") && !ev.shiftKey) {
|
||||
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 접기는 부모의 attachCollapsible(제목 줄 클릭) — 오른쪽 단추를 눌러도 접히지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
|
||||
@@ -76,7 +77,7 @@ export function cardShell(opt: CardShellOptions): CardShell {
|
||||
const host = opt.stateHost ?? opt.body;
|
||||
let save: (() => void) | null = null;
|
||||
root.addEventListener("keydown", (event) => {
|
||||
if (!save || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return;
|
||||
if (!save || !isCtrlKey(event, "s")) return;
|
||||
event.preventDefault();
|
||||
save();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import "./M02_MasterTemplete_TreePanel.css";
|
||||
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
@@ -598,7 +599,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
|
||||
body.addEventListener("keydown", (event) => {
|
||||
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
|
||||
if (!row && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
|
||||
if (!row && isCtrlKey(event, "v")) {
|
||||
event.preventDefault(); // 빈 자리에 초점 = 맨 위에 붙여넣기
|
||||
return void paste([]);
|
||||
}
|
||||
@@ -626,10 +627,10 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const many = picked.size > 1 && picked.has(row.dataset.node ?? "");
|
||||
if (many && api.many) void removeMany(pickedTargets());
|
||||
else if (!(target.kind === "folder" && !target.path.length)) void remove(target);
|
||||
} else if (target && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
|
||||
} else if (target && isCtrlKey(event, "c")) {
|
||||
event.preventDefault();
|
||||
copy(picked.has(row.dataset.node ?? "") ? pickedTargets() : [target]);
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
|
||||
} else if (isCtrlKey(event, "v")) {
|
||||
event.preventDefault(); // 붙일 자리 = 초점 줄이 폴더면 그 안 · 아니면 맨 위
|
||||
void paste(target?.kind === "folder" ? target.path : []);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Ctrl 단축키 — 한글 자판에서도 되돌리기 · 다시 하기가 먹는다(PLAN 214-1).
|
||||
* 한글 자판은 key 가 「ㅋ」·「ㅛ」 · Shift 는 대문자 — 자판 자리(code)로 판정한다.
|
||||
* Ctrl 단축키 — 한글 자판에서도 먹는다(PLAN 214-1 CAD · 214-2 페이지 공용 `A00_Common/key_shortcut.ts`).
|
||||
* 한글 자판은 key 가 「ㅋ」·「ㅛ」 · Shift 는 대문자 — 영문이 아니면 자판 자리(code)로 판정한다.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad ctrl-shortcut-hangul
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -111,3 +111,39 @@ describe("키 입력 — 한글 자판", () => {
|
||||
expect(prevented).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 공용 판정 · 페이지(214-2) — 같은 함수를 B05 · M02 · 시트 키가 씀 ----
|
||||
import { isCtrlKey, keyLetter } from "../../../A00_Common/key_shortcut";
|
||||
|
||||
describe("keyLetter · isCtrlKey — 페이지 공용", () => {
|
||||
it("한글 자판 key 「ㅋ」 + KeyZ = z · 영문 · 대문자도 소문자 · 숫자", () => {
|
||||
expect(keyLetter({ key: "ㅋ", code: "KeyZ" })).toBe("z");
|
||||
expect(keyLetter({ key: "ㅊ", code: "KeyC" })).toBe("c");
|
||||
expect(keyLetter({ key: "ㅍ", code: "KeyV" })).toBe("v");
|
||||
expect(keyLetter({ key: "ㄴ", code: "KeyS" })).toBe("s");
|
||||
expect(keyLetter({ key: "Z", code: "KeyZ" })).toBe("z");
|
||||
expect(keyLetter({ key: "1", code: "Digit1" })).toBe("1");
|
||||
expect(keyLetter({ key: "Process", code: "Digit1" })).toBe("1");
|
||||
});
|
||||
it("다른 자판 배열(key 가 영문)은 key 를 따름 · 글자 아닌 키는 빈 글", () => {
|
||||
expect(keyLetter({ key: "z", code: "KeyY" })).toBe("z"); // 독일식 자리 바뀜
|
||||
expect(keyLetter({ key: "Enter", code: "Enter" })).toBe("");
|
||||
expect(keyLetter({ key: "Escape", code: "Escape" })).toBe("");
|
||||
});
|
||||
it("code 없는 합성 사건은 key 로", () => {
|
||||
expect(keyLetter({ key: "Z", code: "" })).toBe("z");
|
||||
});
|
||||
it("isCtrlKey — Ctrl · ⌘ 만 · 글자 맞아야", () => {
|
||||
const e = (extra: object) => ({
|
||||
key: "ㅋ",
|
||||
code: "KeyZ",
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
...extra,
|
||||
});
|
||||
expect(isCtrlKey(e({ ctrlKey: true }), "z")).toBe(true);
|
||||
expect(isCtrlKey(e({ metaKey: true }), "z")).toBe(true);
|
||||
expect(isCtrlKey(e({}), "z")).toBe(false);
|
||||
expect(isCtrlKey(e({ ctrlKey: true }), "y")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 시트 키 — 한글 자판에서도 Ctrl 단축키(PLAN 214-2).
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config ../../resources/tester/cad/vitest.ui.config.ts
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { attachKeys } from "../../../A00_Common/spreadsheet/spreadsheet_keys";
|
||||
|
||||
describe("시트 키 — 한글 자판 Ctrl 단축키", () => {
|
||||
const setup = () => {
|
||||
const log: string[] = [];
|
||||
const handlers: ((e: KeyboardEvent) => void)[] = [];
|
||||
const ed = {
|
||||
ta: {
|
||||
addEventListener: (_: string, fn: (e: KeyboardEvent) => void) => handlers.push(fn),
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
formula: { addEventListener: () => {}, removeEventListener: () => {} },
|
||||
editing: () => false,
|
||||
};
|
||||
const ctx = {
|
||||
undo: () => log.push("undo"),
|
||||
redo: () => log.push("redo"),
|
||||
readOnly: false,
|
||||
sheet: () => ({}),
|
||||
selection: {},
|
||||
};
|
||||
attachKeys(ctx as never, ed as never, {
|
||||
find: (replace: boolean) => log.push(replace ? "replace" : "find"),
|
||||
});
|
||||
const press = (extra: object) =>
|
||||
handlers[0]({
|
||||
key: "",
|
||||
code: "",
|
||||
keyCode: 0,
|
||||
isComposing: false,
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
target: null,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
...extra,
|
||||
} as unknown as KeyboardEvent);
|
||||
return { log, press };
|
||||
};
|
||||
it("「ㅋ」 = 되돌리기 · 「ㅛ」 = 다시 하기 · Shift+「ㅋ」 = 다시 하기", () => {
|
||||
const { log, press } = setup();
|
||||
press({ key: "ㅋ", code: "KeyZ" });
|
||||
press({ key: "ㅛ", code: "KeyY" });
|
||||
press({ key: "ㅋ", code: "KeyZ", shiftKey: true });
|
||||
expect(log).toEqual(["undo", "redo", "redo"]);
|
||||
});
|
||||
it("「ㄹ」(KeyF) = 찾기 · 「ㅗ」(KeyH) = 바꾸기 · 조합 중에도", () => {
|
||||
const { log, press } = setup();
|
||||
press({ key: "ㄹ", code: "KeyF" });
|
||||
press({ key: "Process", code: "KeyH", keyCode: 229, isComposing: true });
|
||||
expect(log).toEqual(["find", "replace"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/** 페이지 코드(`@ui` 별칭을 쓰는 것)를 시험하는 vitest 설정 — 시험은 `ctrl-shortcut-sheet.test.ts`. */
|
||||
const root = new URL("../../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1");
|
||||
|
||||
export default {
|
||||
root,
|
||||
resolve: { alias: { "@ui": `${root}ui_template` } },
|
||||
test: { include: ["resources/tester/cad/ctrl-shortcut-sheet.test.ts"] },
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
/* 215-1 종단 [자유지정] 단위검증 헬퍼 — `design_profile_free.ts` 를 그 자리에서 트랜스파일해
|
||||
* 범위 · 시작 이음 · 세로선 붙음 · 높이 변환 · 미리 보기 · 기울기 판정 · 종단곡선 · 끝 이음 ·
|
||||
* Esc · Backspace · 적용 편집을 Node 로 돌린다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
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 (request.startsWith(".")) return loadTs(path.join(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 free = loadTs(path.join(ROOT, "A00_Common", "design", "design_profile_free.ts"));
|
||||
const align = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
|
||||
// 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다.
|
||||
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
|
||||
const ground = stations.map((s, i) => 100 + 0.3 * i + (i % 2 ? 0.4 : 0));
|
||||
const base = {
|
||||
policy: {
|
||||
station_interval_m: 20,
|
||||
curve_radius_ratio: 1,
|
||||
curve_tangent_max_ratio: 0.5,
|
||||
curve_skip_legal_exception: false,
|
||||
default_curve_length_m: 20,
|
||||
default_curve_radius_m: 300,
|
||||
balance_tolerance_percent: 10,
|
||||
edit_step_m: 0.1,
|
||||
grade_violation_policy: "warn",
|
||||
max_grade_pct: 14,
|
||||
curve_skip_delta_pct: 0,
|
||||
paved: true,
|
||||
},
|
||||
onlyExplicitCurves: true,
|
||||
basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })),
|
||||
chainage: stations,
|
||||
ground,
|
||||
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
|
||||
};
|
||||
|
||||
function harness() {
|
||||
let edits = align.emptyEdits();
|
||||
const log = { applied: [], closed: 0, changed: 0 };
|
||||
const draw = free.createFreeDraw({
|
||||
base: () => base,
|
||||
alignment: () => align.buildAlignment(base, edits),
|
||||
edits: () => edits,
|
||||
apply: (next) => {
|
||||
log.applied.push(next);
|
||||
edits = next;
|
||||
},
|
||||
close: () => (log.closed += 1),
|
||||
changed: () => (log.changed += 1),
|
||||
});
|
||||
return { draw, log, edits: () => edits };
|
||||
}
|
||||
|
||||
const out = {};
|
||||
const z0 = (s) => ground[stations.indexOf(s)];
|
||||
|
||||
// ① 높이 변환 — 세로 창(기준 + 지금 변환) 왕복 · 변환 글 읽기.
|
||||
const view = { center: 100, pxPerM: 10, zero: 150, scale: 1.2, offset: -20 };
|
||||
out.yAt103_2 = free.chartYAtElevation(view, 103.2);
|
||||
out.zBack = free.elevationAtChartY(view, out.yAt103_2);
|
||||
out.parsed = free.parseWindowTransform("translate(0 -20) scale(1 1.2)");
|
||||
out.parsedNone = free.parseWindowTransform(null);
|
||||
|
||||
// ② 세로선 붙음 — 가장 가까운 측점 · 범위 안만.
|
||||
out.snap79 = free.snapStation(stations, 79);
|
||||
out.snapOutside = free.snapStation(stations, 190, [40, 160]);
|
||||
|
||||
// ③ 범위 → 시작 이음 → 점 → 미리 보기 → 끝 이음.
|
||||
{
|
||||
const { draw, log, edits } = harness();
|
||||
draw.click(41, 0, stations); // 범위 첫 세로선(40)
|
||||
out.pendingEdges = draw.marks().edges;
|
||||
draw.click(158, 0, stations); // 범위 끝(160)
|
||||
out.rangePhase = draw.phase();
|
||||
out.band = draw.marks().band;
|
||||
draw.click(100, 105, stations); // 범위 가운데 — 시작 아님
|
||||
out.stillArmed = draw.phase();
|
||||
draw.click(43, 999, stations); // 시작 세로선 근처 — 그 측점 계획고에 이어 시작
|
||||
out.startPhase = draw.phase();
|
||||
out.startDot = draw.marks().dots[0];
|
||||
draw.click(79, 103.456, stations); // 80 에 붙고 높이는 클릭 높이(0.01 단위)
|
||||
out.point1 = draw.marks().dots[1];
|
||||
draw.click(61, 90, stations); // 뒤로 가는 클릭 — 무시
|
||||
out.dotsAfterBack = draw.marks().dots.length;
|
||||
draw.hover({ chainageM: 121, elevationM: 101.2, stations });
|
||||
out.preview = draw.marks().segments.map((s) => ({
|
||||
to: s.to,
|
||||
grade: Number(s.grade_pct.toFixed(4)),
|
||||
over: s.over,
|
||||
preview: s.preview,
|
||||
}));
|
||||
draw.hover({ chainageM: 121, elevationM: 80, stations }); // 기울기 넘음
|
||||
const steep = draw.marks().segments.at(-1);
|
||||
out.steep = { grade: Number(steep.grade_pct.toFixed(2)), over: steep.over };
|
||||
draw.click(121, 101.2, stations);
|
||||
draw.hover({ chainageM: 157, elevationM: 50, stations }); // 끝 세로선 → 그 계획고로 이음
|
||||
out.finishPreview = draw.marks().segments.at(-1).to;
|
||||
out.hint = draw.hint();
|
||||
draw.click(157, 50, stations); // 끝 세로선 근처 = 끝
|
||||
out.appliedCount = log.applied.length;
|
||||
out.closed = log.closed;
|
||||
out.afterPhase = draw.phase();
|
||||
const next = edits();
|
||||
out.offsetKeys = Object.keys(next.station_offsets).sort();
|
||||
out.radiiKeys = Object.keys(next.curve_radii).sort();
|
||||
const built = align.buildAlignment(base, next);
|
||||
const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
|
||||
out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) };
|
||||
out.ground = { 40: z0(40), 160: z0(160) };
|
||||
const g1 = (103.46 - z0(40)) / 40;
|
||||
const g2 = (101.2 - 103.46) / 40;
|
||||
out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6));
|
||||
out.r80 = next.curve_radii["80.000"];
|
||||
out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null;
|
||||
out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
|
||||
}
|
||||
|
||||
// ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작.
|
||||
{
|
||||
const { draw, log, edits } = harness();
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(160, 0, stations);
|
||||
draw.click(162, 0, stations); // 끝 쪽 세로선에서 시작
|
||||
out.reverseAnchor = draw.marks().dots[0].chainage_m;
|
||||
draw.click(100, 104, stations);
|
||||
out.enter = draw.key("Enter");
|
||||
out.enterApplied = log.applied.length;
|
||||
const built = align.buildAlignment(base, edits());
|
||||
const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
|
||||
out.enterPvi = { 40: pvi(40), 60: pvi(60), 100: pvi(100), 140: pvi(140), 160: pvi(160) };
|
||||
}
|
||||
|
||||
// ⑤ Esc = 취소(편집 없음) · Backspace = 마지막 점 빼기.
|
||||
{
|
||||
const { draw, log } = harness();
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(160, 0, stations);
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(80, 102, stations);
|
||||
draw.click(120, 103, stations);
|
||||
draw.key("Backspace");
|
||||
out.afterBackspace = draw.marks().dots.map((d) => d.chainage_m);
|
||||
draw.key("Backspace");
|
||||
draw.key("Backspace");
|
||||
out.backspaceToArmed = draw.phase();
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(80, 102, stations);
|
||||
out.escape = draw.key("Escape");
|
||||
out.escApplied = log.applied.length;
|
||||
out.escClosed = log.closed;
|
||||
out.escPhase = draw.phase();
|
||||
out.escDots = draw.marks().dots.length;
|
||||
out.enterOutsideDrawing = draw.key("Enter");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""215-1 B05 종단 [자유지정] — 범위 고르고 클릭으로 계획선 잇기.
|
||||
|
||||
계산 · 상태(`A00_Common/design/design_profile_free.ts`)는 Node 헬퍼
|
||||
(`helper_215_1_free_profile.cjs`)가 트랜스파일해 돌리고, 화면 배선(도구줄 단추 · 오버레이 ·
|
||||
키 · 적용 길)은 소스로 본다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_215_1_free_profile.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["yAt103_2"] == pytest.approx(121.6)
|
||||
assert out["zBack"] == pytest.approx(103.2)
|
||||
assert out["parsed"] == {"scale": 1.2, "offset": -20}
|
||||
assert out["parsedNone"] == {"scale": 1, "offset": 0}
|
||||
|
||||
|
||||
def test_세로선_붙음_범위_안만(out):
|
||||
assert out["snap79"] == 80
|
||||
assert out["snapOutside"] == 160
|
||||
|
||||
|
||||
def test_범위_고르기_강조(out):
|
||||
assert out["pendingEdges"] == [40]
|
||||
assert out["rangePhase"] == "armed"
|
||||
assert out["band"] == [40, 160]
|
||||
assert out["stillArmed"] == "armed" # 가운데 클릭은 시작이 아님
|
||||
|
||||
|
||||
def test_시작_이음_지금_계획고(out):
|
||||
assert out["startPhase"] == "drawing"
|
||||
assert out["startDot"] == {"chainage_m": 40, "elevation_m": pytest.approx(100.6)}
|
||||
|
||||
|
||||
def test_클릭_점_세로선_붙음_높이(out):
|
||||
assert out["point1"] == {"chainage_m": 80, "elevation_m": pytest.approx(103.46)}
|
||||
assert out["dotsAfterBack"] == 2 # 뒤로 가는 클릭은 무시
|
||||
|
||||
|
||||
def test_점선_미리_보기_기울기_글(out):
|
||||
first, last = out["preview"]
|
||||
assert (first["preview"], last["preview"]) == (False, True)
|
||||
assert last["to"] == {"chainage_m": 120, "elevation_m": pytest.approx(101.2)}
|
||||
assert first["grade"] == pytest.approx(7.15)
|
||||
assert last["grade"] == pytest.approx(-5.65)
|
||||
assert not first["over"] and not last["over"]
|
||||
|
||||
|
||||
def test_기준_넘음_빨강(out):
|
||||
assert out["steep"]["over"] is True
|
||||
assert abs(out["steep"]["grade"]) > 14
|
||||
|
||||
|
||||
def test_끝_이음과_적용_편집(out):
|
||||
assert out["finishPreview"] == {"chainage_m": 160, "elevation_m": pytest.approx(102.4)}
|
||||
assert out["appliedCount"] == 1 and out["closed"] == 1 and out["afterPhase"] == "range"
|
||||
assert out["offsetKeys"] == ["100.000", "120.000", "140.000", "60.000", "80.000"]
|
||||
pvi = out["pvi"]
|
||||
assert pvi["40"] == pytest.approx(out["ground"]["40"]) # 양 끝 그대로
|
||||
assert pvi["160"] == pytest.approx(out["ground"]["160"])
|
||||
assert pvi["80"] == pytest.approx(103.46)
|
||||
assert pvi["120"] == pytest.approx(101.2)
|
||||
assert pvi["60"] == pytest.approx((100.6 + 103.46) / 2) # 점 사이 = 직선
|
||||
assert pvi["140"] == pytest.approx((101.2 + 102.4) / 2)
|
||||
assert out["outsideUntouched"] is True
|
||||
|
||||
|
||||
def test_종단곡선_자동(out):
|
||||
assert out["radiiKeys"] == ["120.000", "80.000"]
|
||||
assert out["r80"] == pytest.approx(out["expectR80"])
|
||||
assert out["curve80L"] == pytest.approx(20) # 정책 기본 길이 L
|
||||
|
||||
|
||||
def test_Enter_끝_거꾸로_시작(out):
|
||||
assert out["reverseAnchor"] == 160
|
||||
assert out["enter"] is True and out["enterApplied"] == 1
|
||||
pvi = out["enterPvi"]
|
||||
assert pvi["40"] == pytest.approx(100.6) and pvi["160"] == pytest.approx(102.4)
|
||||
assert pvi["100"] == pytest.approx(104)
|
||||
assert pvi["60"] == pytest.approx(100.6 + (104 - 100.6) / 3, abs=1e-5)
|
||||
|
||||
|
||||
def test_Backspace_와_Esc_취소(out):
|
||||
assert out["afterBackspace"] == [40, 80]
|
||||
assert out["backspaceToArmed"] == "armed"
|
||||
assert out["escape"] is True
|
||||
assert out["escApplied"] == 0 and out["escClosed"] == 1
|
||||
assert out["escPhase"] == "range" and out["escDots"] == 0
|
||||
assert out["enterOutsideDrawing"] is False
|
||||
|
||||
|
||||
def test_도구줄_직선화_오른쪽_자유지정():
|
||||
tools = _read("B05_Profile/B05_Profile_UI_Profile_Tools.ts")
|
||||
assert '"자유지정"' in tools
|
||||
assert "wrap.append(reset, undo, redo, straighten, free, shift, up, down)" in tools
|
||||
assert 'className = "b05-route-profile__tool"' in tools # 같은 단추 양식
|
||||
|
||||
|
||||
def test_적용은_직선화와_같은_길_키_배선():
|
||||
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "apply: (next) => ctx.applyEdits(next)" in panel
|
||||
assert 'tools.mode() === "free" && free.key(key)' in panel
|
||||
profile = _read("A00_Common/design/design_profile.ts")
|
||||
# applyEdits = 캐시(store.replace) + 되돌리기 기록 — 자동저장 없음
|
||||
assert "store.replace(next);\n history.record();" in profile
|
||||
render = _read("B05_Profile/B05_Profile_UI_Profile_Render.ts")
|
||||
assert "ctx.tools.mountOverlay(" in render
|
||||
|
||||
|
||||
def test_오버레이_점선_빨강_글():
|
||||
view = _read("A00_Common/design/design_profile_free_view.ts")
|
||||
assert "is-preview" in view and "is-over" in view
|
||||
assert "grade_pct.toFixed(1)}%" in view
|
||||
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
|
||||
assert ".b05-profile-freemark__line {" in css and "stroke-dasharray: 6 4" in css
|
||||
assert ".b05-profile-freemark__line.is-over" in css
|
||||
|
||||
|
||||
def test_줄_수_늘리지_않음():
|
||||
for relative in (
|
||||
"A00_Common/design/design_profile.ts",
|
||||
"A00_Common/design/design_profile_free.ts",
|
||||
"A00_Common/design/design_profile_free_view.ts",
|
||||
):
|
||||
assert len(_read(relative).splitlines()) <= 700
|
||||
assert len(_read("A00_Common/design/design_profile.ts").splitlines()) <= 698
|
||||
@@ -0,0 +1,30 @@
|
||||
"""217-1 B06 알림 칩 줄 여백 줄임 — 줄 위 여백 16→8 · 칩 줄 ↔ 첫 카드 28→16 (CSS 값 고정)."""
|
||||
|
||||
from test_196_1_b06_notice_chips import B06
|
||||
|
||||
CSS = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _rule(selector: str) -> str:
|
||||
body = CSS[CSS.index(selector + " {") :]
|
||||
return body[: body.index("}")]
|
||||
|
||||
|
||||
def test_칩_줄_위_여백은_8_아래_여백은_0():
|
||||
bar = _rule(".b06-section__notice-bar")
|
||||
assert "padding-block: var(--spacing-8) 0;" in bar
|
||||
assert "spacing-16) var(--spacing-4)" not in bar
|
||||
|
||||
|
||||
def test_첫_카드와의_간격은_카드_칸_gap_24에서_8을_당겨_16():
|
||||
bar = _rule(".b06-section__notice-bar")
|
||||
# 위 = 카드 칸 padding(16) 상쇄 · 아래 = gap(24) 중 8 당김
|
||||
assert "margin-block: calc(-1 * var(--spacing-16)) calc(-1 * var(--spacing-8));" in bar
|
||||
assert "gap: var(--spacing-24)" in _rule(".b06-section__cards")
|
||||
|
||||
|
||||
def test_sticky_top_과_scroll_margin_그대로():
|
||||
bar = _rule(".b06-section__notice-bar")
|
||||
assert "position: sticky" in bar
|
||||
assert "top: calc(-1 * var(--spacing-16))" in bar
|
||||
assert "scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8))" in CSS
|
||||
Reference in New Issue
Block a user