feat(B05 발자국): 346-2 평면 발자국 그리기 — 노선 편집 캔버스에 가게 키 footprint(도로 끝 좌 · 우 · 비탈 끝 깎기 붉음 · 쌓기 파랑 · 밝음 · 어두움 토큰) · 등고선 위 · 노선 아래 · 가게 구독으로 바뀐 때만 다시 그림(프레임마다 한 번) · 도구줄 [발자국] 켬끔(기본 켬 · 페이지 동안) · 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Chrome.ts
|
||||
@@ -72,6 +73,8 @@ export function createRouteEditChrome(
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [확인] 때 저장">측점 스냅</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="footprint"
|
||||
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">발자국</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>
|
||||
@@ -152,6 +155,7 @@ export function createRouteEditChrome(
|
||||
)!;
|
||||
const terrainSelect = overlay.querySelector<HTMLSelectElement>('[data-act="terrain"]')!;
|
||||
const stationSnapButton = overlay.querySelector<HTMLButtonElement>('[data-act="station-snap"]')!;
|
||||
bindFootprintToggle(overlay.querySelector<HTMLButtonElement>('[data-act="footprint"]')!);
|
||||
|
||||
return {
|
||||
overlay,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Footprint.ts
|
||||
* 노선 편집 평면에 **평면 발자국**(도로 끝 · 비탈 끝)을 그린다(PLAN 346-2).
|
||||
*
|
||||
* 값 = 가게 키 `footprint`(계산 묶음 footprint 단계가 씀) · 선 갈래 = `logic/_Footprint_Strokes`.
|
||||
* 가게 값이 바뀌거나 [발자국] 단추를 누르면 마지막 장면을 한 번 다시 그린다(프레임마다 한 번) —
|
||||
* 편집기 본체가 그릴 때마다 `drawFootprint` 가 다시 그릴 길을 새로 받아 둔다.
|
||||
* 켬끔은 이 페이지 동안만(기본 켬 · 계정 설정 아님).
|
||||
* ========================================================================== */
|
||||
|
||||
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
|
||||
import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
type FootprintStrokeKind,
|
||||
type FootprintValue,
|
||||
footprintStrokes,
|
||||
} from "./logic/B05_Profile_Footprint_Strokes";
|
||||
import "./B05_Profile_UI_Style_RouteEdit_Footprint.css";
|
||||
|
||||
/** 갈래 → 색 토큰 · 대체 색. */
|
||||
const STROKE_STYLE: Record<FootprintStrokeKind, [string, string]> = {
|
||||
road: ["--b05-footprint-road", "#475569"],
|
||||
cut: ["--b05-footprint-cut", "#dc2626"],
|
||||
fill: ["--b05-footprint-fill", "#2563eb"],
|
||||
slope: ["--b05-footprint-road", "#475569"],
|
||||
};
|
||||
const STROKE_WIDTH = 1.4;
|
||||
|
||||
let shown = true;
|
||||
let redraw: (() => void) | null = null;
|
||||
let frame = 0;
|
||||
let listening: { projectId: string; stop: () => void } | null = null;
|
||||
|
||||
function scheduleRedraw(): void {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
redraw?.();
|
||||
});
|
||||
}
|
||||
|
||||
function listen(projectId: string): void {
|
||||
if (listening?.projectId === projectId) return;
|
||||
listening?.stop();
|
||||
const stop = useDesignStore(projectId, null).subscribe("footprint", scheduleRedraw);
|
||||
listening = { projectId, stop };
|
||||
}
|
||||
|
||||
/** [발자국] 단추 — 누르면 켬끔 · 다시 그림. */
|
||||
export function bindFootprintToggle(button: HTMLButtonElement): void {
|
||||
const paint = (): void => {
|
||||
button.classList.toggle("is-active", shown);
|
||||
button.setAttribute("aria-pressed", String(shown));
|
||||
};
|
||||
paint();
|
||||
button.addEventListener("click", () => {
|
||||
shown = !shown;
|
||||
paint();
|
||||
scheduleRedraw();
|
||||
});
|
||||
}
|
||||
|
||||
/** 발자국을 그린다(회전 · 등고선 위 · 노선 아래 자리에서 부름). `again` = 장면을 다시 그리는 길. */
|
||||
export function drawFootprint(
|
||||
context: CanvasRenderingContext2D,
|
||||
toScreen: (vertex: Vertex) => [number, number],
|
||||
style: CSSStyleDeclaration,
|
||||
again: () => void,
|
||||
): void {
|
||||
redraw = again;
|
||||
const projectId = draftProjectId();
|
||||
listen(projectId);
|
||||
if (!shown) return;
|
||||
const strokes = footprintStrokes(
|
||||
useDesignStore(projectId, null).get("footprint") as FootprintValue | null,
|
||||
);
|
||||
if (!strokes.length) return;
|
||||
context.save();
|
||||
context.setLineDash([]);
|
||||
context.lineWidth = STROKE_WIDTH;
|
||||
for (const { kind, points } of strokes) {
|
||||
const [token, fallback] = STROKE_STYLE[kind];
|
||||
context.strokeStyle = style.getPropertyValue(token).trim() || fallback;
|
||||
context.beginPath();
|
||||
points.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock";
|
||||
import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
|
||||
/** 노드 반지름(px). */
|
||||
const NODE_R = 4;
|
||||
@@ -202,6 +203,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
}
|
||||
context.restore();
|
||||
|
||||
// 평면 발자국(도로 끝 · 비탈 끝 · 346-2) — 등고선 위 · 노선 아래.
|
||||
drawFootprint(context, toScreen, style, () => drawRouteEditScene(context, scene));
|
||||
strokePolyline(
|
||||
context,
|
||||
toScreen,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/* 노선 편집 평면 발자국(PLAN 346-2) — 도로 끝 · 비탈 끝 깎기(붉음) · 쌓기(파랑) */
|
||||
:root {
|
||||
--b05-footprint-road: #475569;
|
||||
--b05-footprint-cut: #dc2626;
|
||||
--b05-footprint-fill: #2563eb;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--b05-footprint-road: #cbd5e1;
|
||||
--b05-footprint-cut: #f87171;
|
||||
--b05-footprint-fill: #60a5fa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--b05-footprint-road: #cbd5e1;
|
||||
--b05-footprint-cut: #f87171;
|
||||
--b05-footprint-fill: #60a5fa;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Footprint_Strokes.ts
|
||||
* 평면 발자국(가게 키 `footprint` · 계약 `PlanFootprint`) → 그릴 선 갈래(PLAN 346-2).
|
||||
*
|
||||
* 도로 끝 좌 · 우 = `road` · 비탈 끝 = 점마다 깎기 여부(`slopeLeftCut` · `slopeRightCut` —
|
||||
* footprint 단계가 붙임)로 `cut` · `fill` 조각 · 깎기 여부가 없으면 `slope` 한 줄.
|
||||
* 조각이 바뀌는 점은 앞뒤 조각이 함께 가짐(선이 끊겨 보이지 않게 · `splitRuns` 와 같은 규칙).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { PlanFootprint, Vertex } from "../../A00_Common/design/design_pipeline_types";
|
||||
|
||||
export type FootprintStrokeKind = "road" | "cut" | "fill" | "slope";
|
||||
|
||||
export interface FootprintStroke {
|
||||
kind: FootprintStrokeKind;
|
||||
points: Vertex[];
|
||||
}
|
||||
|
||||
/** footprint 단계 출력 — 계약 칸 + 비탈 끝 점마다 깎기 여부(없을 수 있음). */
|
||||
export type FootprintValue = PlanFootprint & {
|
||||
slopeLeftCut?: ReadonlyArray<boolean>;
|
||||
slopeRightCut?: ReadonlyArray<boolean>;
|
||||
};
|
||||
|
||||
function slopeRuns(points: Vertex[], cut: ReadonlyArray<boolean> | undefined): FootprintStroke[] {
|
||||
if (points.length < 2) return [];
|
||||
if (!cut || cut.length !== points.length) return [{ kind: "slope", points }];
|
||||
const runs: FootprintStroke[] = [];
|
||||
let current: FootprintStroke | null = null;
|
||||
points.forEach((point, index) => {
|
||||
const kind = cut[index] ? "cut" : "fill";
|
||||
if (current?.kind !== kind) {
|
||||
current?.points.push(point);
|
||||
current = { kind, points: [point] };
|
||||
runs.push(current);
|
||||
} else {
|
||||
current.points.push(point);
|
||||
}
|
||||
});
|
||||
return runs.filter((run) => run.points.length >= 2);
|
||||
}
|
||||
|
||||
/** 그릴 선 — 도로 끝 둘 · 비탈 끝 조각들(2점 미만은 뺌). */
|
||||
export function footprintStrokes(footprint: FootprintValue | null): FootprintStroke[] {
|
||||
if (!footprint) return [];
|
||||
const road = [footprint.roadLeft, footprint.roadRight]
|
||||
.filter((points) => points?.length >= 2)
|
||||
.map((points): FootprintStroke => ({ kind: "road", points }));
|
||||
return [
|
||||
...road,
|
||||
...slopeRuns(footprint.slopeLeft ?? [], footprint.slopeLeftCut),
|
||||
...slopeRuns(footprint.slopeRight ?? [], footprint.slopeRightCut),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user