- 433-14 ③ · 433-21 ③ 누름 · 맴돔 · 커서 한 판정(pressWinner) — 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것 - 433-21 ① 손잡이 끌기 = 노드 갈래와 같게 — 맞추기 전 자리에서 셈 · 미리보기 맞춤 · 측점 밖이 늘면 그 걸음 되돌림 - 433-14 ① 막힌 걸음만 하고 놓으면 미리보기 맞춤을 누르기 전 자리로 - 433-14 ② 사진첩 다리도 스냅 · 분석 지형 바뀜을 봄 — 노선이 처음으로 돌아가도 초안 - 433-1 test_365 ⑧ 이름 = 노선 한 걸음 + 계획고 맞춤 - 시험 test_453_plan_pick_draft 새로 · 390_5 손잡이 훑기 · 381_3 · 399 · 409 · 430 배선 글 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
13 KiB
JavaScript
340 lines
13 KiB
JavaScript
/* 390-5 스냅 간격 = 조작 단위 — 방향키 걸음(`KeyNudge.arrowStep`) · 끌기 미리보기(`StationFit.fitDrag`) ·
|
|
* MC 측점 값 자릿수(`Frame.snapCurveStations` · 눈금 글 `stationLabel`)를 Node 로 돌려 JSON 한 줄
|
|
* (`test_390_5_snap_step_ops.py` 가 판정 · 파이썬 짝 거울은 그쪽).
|
|
* node helper_390_5_snap_step_ops.cjs '<{"grid":[…],"spans":[[bc,ec]…]}>'
|
|
*/
|
|
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) =>
|
|
request.startsWith(".")
|
|
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
|
: require(request);
|
|
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
|
cache.set(full, box.exports);
|
|
return box.exports;
|
|
}
|
|
|
|
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
|
const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts"));
|
|
const curve = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Curve.ts"));
|
|
const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts"));
|
|
const nudge = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_KeyNudge.ts"));
|
|
const station = loadTs(path.join(ROOT, "common_util", "common_util_station.ts"));
|
|
|
|
const INTERVAL = 20;
|
|
const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL };
|
|
const ROUTE = [
|
|
[0, 0],
|
|
[93, 7],
|
|
[171, 61],
|
|
[262, 48],
|
|
[337, 112],
|
|
[431, 101],
|
|
[503, 160],
|
|
];
|
|
const STEPS = [0.1, 1, 10, INTERVAL];
|
|
|
|
function columns(planned) {
|
|
const n = planned.length;
|
|
return {
|
|
planned,
|
|
curveOn: planned.map(() => true),
|
|
curveRadius: planned.map(() => null),
|
|
curveLock: planned.map(() => null),
|
|
curveArc: planned.map(() => null),
|
|
apexLock: planned.map((_, i) => i === 0 || i === n - 1),
|
|
};
|
|
}
|
|
|
|
const build = (planned) =>
|
|
curve.buildEditedPolyline(
|
|
planned,
|
|
planned.map(() => true),
|
|
planned.map(() => null),
|
|
LIMITS.minRadiusM,
|
|
);
|
|
|
|
function mcs(planned) {
|
|
const built = build(planned);
|
|
return frame.curveSpans(built.vertices, built.curves).map(([a, b]) => (a + b) / 2);
|
|
}
|
|
|
|
const residue = (mc, step) => Math.abs(mc - Math.round(mc / step) * step);
|
|
const moved = (a, b) => Math.max(...a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1])));
|
|
|
|
// ① 방향키 — 화면 축이 30° 돌고 1 px = 0.5 m 인 지도
|
|
const turn = Math.PI / 6;
|
|
const metricAt = (x, y) => [
|
|
100 + 0.5 * (x * Math.cos(turn) - y * Math.sin(turn)),
|
|
200 + 0.5 * (x * Math.sin(turn) + y * Math.cos(turn)),
|
|
];
|
|
const len = ([x, y]) => Math.hypot(x, y);
|
|
const keys = {
|
|
off: len(nudge.arrowStep("ArrowUp", false, metricAt)),
|
|
offShift: len(nudge.arrowStep("ArrowUp", true, metricAt)),
|
|
snap: Object.fromEntries(
|
|
STEPS.map((step) => [
|
|
step,
|
|
{
|
|
plain: len(nudge.arrowStep("ArrowRight", false, metricAt, step)),
|
|
shift: len(nudge.arrowStep("ArrowRight", true, metricAt, step)),
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
|
|
// ② 끌기 미리보기 — 측점 No. 로 맞춘 노선에서 IP 3 을 끌어 감(간격별)
|
|
const base = fit.fitCurveStations(columns(ROUTE), LIMITS, 1);
|
|
const DRAG = 3;
|
|
const mouse = [base[DRAG][0] + 7.3, base[DRAG][1] + 4.1];
|
|
const drag = Object.fromEntries(
|
|
STEPS.map((step) => {
|
|
const limits = { ...LIMITS, intervalM: step };
|
|
const now = base.map((v, i) => (i === DRAG ? mouse : v));
|
|
const preview = fit.fitDrag(columns(now), base, DRAG, limits);
|
|
// 앞 프레임이 다른 자리를 이미 밀어 둔 상태에서도 같은 값(쌓이지 않음)
|
|
const stale = preview.map((v, i) => (i === DRAG ? mouse : [v[0] + 0.7, v[1] - 0.4]));
|
|
const again = fit.fitDrag(columns(stale), base, DRAG, limits);
|
|
// 놓을 때 정본 맞춤(`applyEdit` = fitStart → fitCurveStations)이 미리보기를 안 바꿈
|
|
const from = fit.fitStart(columns(base), columns(preview));
|
|
const release = fit.fitCurveStations(columns(preview), limits, from);
|
|
const after = mcs(preview);
|
|
const built = build(preview);
|
|
return [
|
|
step,
|
|
{
|
|
residues: after.map((mc) => residue(mc, step)),
|
|
tol: fit.mcTolM(step),
|
|
off: fit.offStationCount(built.vertices, built.curves, step),
|
|
dragMoved: Math.hypot(preview[DRAG][0] - mouse[0], preview[DRAG][1] - mouse[1]),
|
|
stale: moved(preview, again),
|
|
release: moved(preview, release),
|
|
before: moved(base.slice(0, DRAG - 1), preview.slice(0, DRAG - 1)),
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
|
|
// ④ 끌기 훑기(441) — 편집 창 끌기 차례(`_Pointer`) 그대로: 하한 막힘 · 미리보기 측점 밖이 누를 때보다 늘면
|
|
// 그 걸음 되돌림 · 놓을 때 정본 맞춤(`applyEdit`). 측점 No. · IP 셋 × 12 방향 × 3 거리 · 고정 R(곡선표 반경 못박음).
|
|
const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts"));
|
|
const copy = (c) => ({
|
|
...c,
|
|
planned: c.planned.map(([x, y]) => [x, y]),
|
|
curveRadius: [...c.curveRadius],
|
|
});
|
|
function sweep(fixedR) {
|
|
const start = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1));
|
|
if (fixedR)
|
|
start.curveRadius = edits
|
|
.rebuildLine(copy(start), LIMITS)
|
|
.nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100));
|
|
const floor = (built) =>
|
|
edits.curveShortfalls(built.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM);
|
|
const off = (built) => fit.offStationCount(built.vertices, built.curves, INTERVAL);
|
|
const tally = { drags: 0, released: 0, offAfter: 0, strayed: 0, worst: [] };
|
|
for (const K of [2, 3, 4])
|
|
for (let deg = 0; deg < 360; deg += 30)
|
|
for (const reach of [9, 17, 26]) {
|
|
const hist = copy(start);
|
|
let st = copy(hist);
|
|
let built = edits.rebuildLine(st, LIMITS);
|
|
const offBefore = off(built);
|
|
let raw = null;
|
|
let movedOnce = false;
|
|
const preview = () => {
|
|
raw = st.planned.map(([x, y]) => [x, y]);
|
|
st = { ...st, planned: fit.fitDrag(st, hist.planned, K, LIMITS) };
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
};
|
|
const p0 = hist.planned[K];
|
|
for (let t = 0.5; t <= reach + 1e-9; t += 0.5) {
|
|
if (raw) raw.forEach((v, i) => (st.planned[i] = v));
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
const before = floor(built);
|
|
const previous = st.planned[K];
|
|
st.planned[K] = [
|
|
p0[0] + Math.cos((deg * Math.PI) / 180) * t,
|
|
p0[1] + Math.sin((deg * Math.PI) / 180) * t,
|
|
];
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
if (edits.shortfallCrossed(before, floor(built))) {
|
|
st.planned[K] = previous;
|
|
preview();
|
|
continue;
|
|
}
|
|
preview();
|
|
if (off(built) > offBefore) {
|
|
raw.forEach((v, i) => (st.planned[i] = v));
|
|
st.planned[K] = previous;
|
|
preview();
|
|
tally.strayed += 1;
|
|
continue;
|
|
}
|
|
movedOnce = true;
|
|
}
|
|
tally.drags += 1;
|
|
if (!movedOnce) continue;
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
if (edits.floorStop(hist, st.planned, built.nodes, LIMITS)) continue;
|
|
const from = fit.fitStart(hist, st);
|
|
st = { ...st, planned: fit.fitCurveStations(st, LIMITS, from) };
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
tally.released += 1;
|
|
const left = fit.offStationMcs(built.vertices, built.curves, INTERVAL);
|
|
if (left.length) {
|
|
tally.offAfter += 1;
|
|
if (tally.worst.length < 5) tally.worst.push({ K, deg, reach, mc: left });
|
|
}
|
|
}
|
|
return tally;
|
|
}
|
|
const swept = { auto: sweep(false), fixed: sweep(true) };
|
|
|
|
// ④-2 손잡이(BC · EC) 끌기 훑기(433-21 ①) — `_Pointer` 손잡이 갈래 그대로: 맞추기 전 자리에서 셈 · 하한 막힘 ·
|
|
// 미리보기 측점 밖이 누를 때보다 늘면 그 걸음 되돌림(`guard`) · 놓을 때 정본 맞춤. IP 셋 × 두 끝 × 12 방향 × 3 거리.
|
|
function sweepHandle(fixedR, guard = true) {
|
|
const start = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1));
|
|
if (fixedR)
|
|
start.curveRadius = edits
|
|
.rebuildLine(copy(start), LIMITS)
|
|
.nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100));
|
|
const floor = (built) =>
|
|
edits.curveShortfalls(built.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM);
|
|
const off = (built) => fit.offStationCount(built.vertices, built.curves, INTERVAL);
|
|
const tally = { drags: 0, released: 0, offAfter: 0, strayed: 0, worst: [] };
|
|
for (const K of [2, 3, 4])
|
|
for (const which of ["start", "end"])
|
|
for (let deg = 0; deg < 360; deg += 30)
|
|
for (const reach of [9, 17, 26]) {
|
|
const hist = copy(start);
|
|
let st = { ...copy(hist), curveOn: [...hist.curveOn] };
|
|
let built = edits.rebuildLine(st, LIMITS);
|
|
const offBefore = off(built);
|
|
const c0 = built.curves.find((c) => c.node_first === K);
|
|
if (!c0) continue;
|
|
const h0 = which === "start" ? c0.start : c0.end;
|
|
let raw = null;
|
|
let movedOnce = false;
|
|
const preview = () => {
|
|
raw = st.planned.map(([x, y]) => [x, y]);
|
|
st = { ...st, planned: fit.fitDrag(st, hist.planned, K, LIMITS) };
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
};
|
|
for (let t = 0.5; t <= reach + 1e-9; t += 0.5) {
|
|
if (raw) raw.forEach((v, i) => (st.planned[i] = v));
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
const c = built.curves.find((entry) => entry.node_first === K);
|
|
const to = [
|
|
h0[0] + Math.cos((deg * Math.PI) / 180) * t,
|
|
h0[1] + Math.sin((deg * Math.PI) / 180) * t,
|
|
];
|
|
const moved = c
|
|
? curve.dragHandleTo(
|
|
st.planned[K - 1],
|
|
[c.apex[0], c.apex[1]],
|
|
st.planned[K + 1],
|
|
which,
|
|
to,
|
|
)
|
|
: null;
|
|
if (!moved) {
|
|
if (raw) preview();
|
|
continue;
|
|
}
|
|
const before = floor(built);
|
|
const kept = [st.planned[K], st.curveRadius[K], st.curveOn[K]];
|
|
const back = () => {
|
|
raw?.forEach((v, i) => (st.planned[i] = v));
|
|
[st.planned[K], st.curveRadius[K], st.curveOn[K]] = kept;
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
preview();
|
|
};
|
|
st.planned[K] = moved.apex;
|
|
st.curveRadius[K] = Math.max(
|
|
edits.radiusFloorM(
|
|
built.nodes[K]?.inner_angle_deg,
|
|
LIMITS.limitRadiusM,
|
|
LIMITS.limitArcM,
|
|
),
|
|
Math.round(moved.radius * 100) / 100,
|
|
);
|
|
st.curveOn[K] = true;
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
if (edits.shortfallCrossed(before, floor(built))) {
|
|
back();
|
|
continue;
|
|
}
|
|
preview();
|
|
if (guard && off(built) > offBefore) {
|
|
back();
|
|
tally.strayed += 1;
|
|
continue;
|
|
}
|
|
movedOnce = true;
|
|
}
|
|
tally.drags += 1;
|
|
if (!movedOnce) continue;
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
if (edits.floorStop(hist, st.planned, built.nodes, LIMITS)) continue;
|
|
const from = fit.fitStart(hist, st);
|
|
st = { ...st, planned: fit.fitCurveStations(st, LIMITS, from) };
|
|
built = edits.rebuildLine(st, LIMITS);
|
|
tally.released += 1;
|
|
const left = fit.offStationMcs(built.vertices, built.curves, INTERVAL);
|
|
if (left.length) {
|
|
tally.offAfter += 1;
|
|
if (tally.worst.length < 5) tally.worst.push({ K, which, deg, reach, mc: left });
|
|
}
|
|
}
|
|
return tally;
|
|
}
|
|
const sweptHandle = {
|
|
auto: sweepHandle(false),
|
|
fixed: sweepHandle(true),
|
|
unguarded: { auto: sweepHandle(false, false), fixed: sweepHandle(true, false) },
|
|
};
|
|
|
|
// ③ MC 측점 값 · 눈금 글
|
|
const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}');
|
|
const snapped = Object.fromEntries(
|
|
[
|
|
["0.1", 0.1],
|
|
["1", 1],
|
|
["10", 10],
|
|
["station", null],
|
|
].map(([key, step]) => [key, frame.snapCurveStations(input.grid, input.spans, step)]),
|
|
);
|
|
const labels = {
|
|
int: station.stationLabel(119.8, 20),
|
|
one: station.stationLabel(119.8, 20, 1),
|
|
oneWhole: station.stationLabel(120, 20, 1),
|
|
oneSmall: station.stationLabel(100.1, 20, 1),
|
|
oneDefault: station.stationLabel(119.84, 20),
|
|
};
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
keys,
|
|
drag,
|
|
swept,
|
|
sweptHandle,
|
|
snapped,
|
|
labels,
|
|
scale: [0.1, 1, 10, null].map(frame.mcScale),
|
|
}),
|
|
);
|