feat(B04/B05): 재확정 재계산 체인(12) + 편집 버튼·유토곡선 UI 정리(13~15)
- 12: run_redesign_chain — B04 재확정 시 사용자 입력(stage 2 params) 유지한 채 새 지표면 기준 B05 재계산·확정, 옛 측점별 설계·표준단면 설정을 chainage 매칭 이월 후 B06 확정. 경로 없으면 신규 자동 체인 폴백. B04 confirm에 백그라운드 결선 - 13: 구간 이동 글리프 ⇧⇩ → 속 찬 ⬆︎⬇︎(21×17·굵게), 규칙·비정규 측점 버튼 최소 간격 20px 캐스케이드 배치로 근접 측점 ▲▼ 겹침 해소 - 14: 유토곡선 손잡이를 표준 삼각형 손잡이(64×24, 바닥 중앙)로 통일, 캡션 제거, '유토곡선 펼치기/접기' 툴팁 - 15: 유토곡선 스크롤러 세로 휠 → 가로 이동(종단 그래프와 동기) Playwright 검증: 버튼 27개 최소 간격 20px, 글리프 ⬆︎ 21×17, 표준 손잡이 64×24, 휠 스크롤 0→400 종단 동기, 콘솔 오류 0. typecheck·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -146,3 +146,180 @@ async def run_auto_design_chain(project_id: UUID, surface_model_id: int | None =
|
||||
except Exception:
|
||||
# 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다.
|
||||
logger.exception("자동 설계 체인 실패: project_id=%s", project_id)
|
||||
|
||||
|
||||
async def run_redesign_chain(
|
||||
project_id: UUID,
|
||||
surface_model_id: int,
|
||||
selection: dict[str, Any],
|
||||
) -> None:
|
||||
"""B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장.
|
||||
|
||||
관리자가 B04에서 다른 지표면 모델로 재확정하면(2026-08-04 사용자 확정) 그 값을
|
||||
기준으로 다음 페이지들도 함께 갱신돼야 한다. 이때 일반 사용자가 이미 쓰던 설정은
|
||||
버리지 않는다:
|
||||
- B05: 저장된 stage 2 params(제어점 BP/EP/CP·회피/금지원·경사 옵션·측점 간격 등)를
|
||||
그대로 쓰고 **지표면(filter/method/smooth/model id)만** 새 확정값으로 바꾼다.
|
||||
- B06: 옛 경로의 측점별 설계(지반유형·단면유형·측구·암 경계)를 chainage 매칭으로
|
||||
새 경로에 이월하고, 표준단면 설정(data.options)도 함께 넘긴다. 나머지 미지정
|
||||
측점은 확정 시 기본값으로 채워진다.
|
||||
|
||||
경로가 아예 없으면 신규 자동 체인(계획노선 CSV 기본값)으로 되돌아간다.
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import get_latest_route
|
||||
from B05_wf2_Route.B05_wf2_Route_Router import confirm_latest_route, solve_route
|
||||
from B05_wf2_Route.B05_wf2_Route_Schema import RouteSolveRequest
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_longitudinal_section,
|
||||
update_cross_section_design,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router_Confirm import confirm_sections
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import SectionConfirmRequest
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
_ = get_project_storage_relative_path # 시그니처 정렬용 (사용 안 함)
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest:
|
||||
logger.info(
|
||||
"재확정 체인 → 기존 경로 없음, 신규 자동 체인으로: project_id=%s", project_id
|
||||
)
|
||||
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
|
||||
return
|
||||
|
||||
# 1) 사용자 입력 회수 — 마지막 경로 계산의 stage 2 params가 정본이다.
|
||||
old_route_id = int(latest["id"])
|
||||
async with pool.acquire() as connection:
|
||||
import aiomysql
|
||||
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
state = await get_workflow_state(cursor, str(project_id))
|
||||
old_longitudinal = await get_longitudinal_section(connection, project_id, old_route_id)
|
||||
old_designs = await get_cross_section_designs(connection, old_route_id)
|
||||
stage2 = next(
|
||||
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
|
||||
)
|
||||
params = (stage2 or {}).get("params") or {}
|
||||
points = params.get("points") or {}
|
||||
options = params.get("options") or {}
|
||||
if not points.get("bp") or not points.get("ep"):
|
||||
logger.warning(
|
||||
"재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id
|
||||
)
|
||||
return
|
||||
|
||||
# 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분.
|
||||
request = RouteSolveRequest(
|
||||
filter_key=str(selection.get("source_filter") or params.get("filter_key")),
|
||||
method=str(selection.get("method") or params.get("method") or "dtm"),
|
||||
smooth=bool(selection.get("smooth", params.get("smooth", False))),
|
||||
surface_model_id=surface_model_id,
|
||||
algorithm=str(params.get("algorithm") or "dijkstra"),
|
||||
bp=points["bp"],
|
||||
ep=points["ep"],
|
||||
cp=points.get("cp") or [],
|
||||
ap=points.get("ap") or [],
|
||||
fp=points.get("fp") or [],
|
||||
station_interval_m=params.get("station_interval_m"),
|
||||
cross_half_width_m=params.get("cross_half_width_m"),
|
||||
cross_sample_interval_m=params.get("cross_sample_interval_m"),
|
||||
long_sample_interval_m=params.get("long_sample_interval_m"),
|
||||
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
|
||||
paved=bool(options.get("paved", False)),
|
||||
terrain_type=str(options.get("terrain_type") or "normal"),
|
||||
main_direction=str(options.get("main_direction") or "auto"),
|
||||
min_curve_radius_m=options.get("min_curve_radius_m"),
|
||||
max_uphill_grade=options.get("max_uphill_grade"),
|
||||
max_downhill_grade=options.get("max_downhill_grade"),
|
||||
min_uphill_grade=options.get("min_uphill_grade"),
|
||||
min_downhill_grade=options.get("min_downhill_grade"),
|
||||
weights=options.get("weights"),
|
||||
allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)),
|
||||
max_grade_pct=params.get("max_grade_pct"),
|
||||
min_vertical_radius_m=params.get("min_vertical_radius_m"),
|
||||
min_tangent_length_m=params.get("min_tangent_length_m"),
|
||||
balance_segment_length_m=params.get("balance_segment_length_m"),
|
||||
start_elevation_offset_m=params.get("start_elevation_offset_m"),
|
||||
end_elevation_offset_m=params.get("end_elevation_offset_m"),
|
||||
)
|
||||
solve_result: Any = await solve_route(project_id, request)
|
||||
if isinstance(solve_result, JSONResponse):
|
||||
logger.error(
|
||||
"재확정 체인 중단(B05 재계산 실패): project_id=%s status=%s",
|
||||
project_id,
|
||||
solve_result.status_code,
|
||||
)
|
||||
return
|
||||
new_route_id = int(solve_result.route_id)
|
||||
logger.info(
|
||||
"재확정 체인 B05 재계산 완료: project_id=%s %s→%s",
|
||||
project_id,
|
||||
old_route_id,
|
||||
new_route_id,
|
||||
)
|
||||
|
||||
# 3) 옛 측점별 사용자 설계를 chainage 매칭으로 새 경로에 이월(비치명적).
|
||||
carried = 0
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
for record in old_designs:
|
||||
design = record.get("design")
|
||||
if not isinstance(design, dict):
|
||||
continue
|
||||
await update_cross_section_design(
|
||||
connection,
|
||||
route_id=new_route_id,
|
||||
chainage_m=float(record["chainage_m"]),
|
||||
design=design,
|
||||
project_id=project_id,
|
||||
)
|
||||
carried += 1
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"재확정 체인 — 옛 설계 이월 실패(계속 진행): project_id=%s", project_id
|
||||
)
|
||||
logger.info("재확정 체인 설계 이월: project_id=%s %d건", project_id, carried)
|
||||
|
||||
# 4) B05 확정 → B06 확정(옛 표준단면 설정 이월, 미지정 측점 기본값 채움).
|
||||
confirm_result = await confirm_latest_route(project_id, None)
|
||||
if isinstance(confirm_result, JSONResponse):
|
||||
logger.error(
|
||||
"재확정 체인 중단(B05 확정 실패): project_id=%s status=%s",
|
||||
project_id,
|
||||
confirm_result.status_code,
|
||||
)
|
||||
return
|
||||
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
|
||||
section_request = (
|
||||
SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"])
|
||||
if old_options.get("standard_cross_section")
|
||||
else None
|
||||
)
|
||||
sections_result = await confirm_sections(project_id, new_route_id, section_request)
|
||||
if isinstance(sections_result, JSONResponse):
|
||||
logger.error(
|
||||
"재확정 체인 중단(B06 확정 실패): project_id=%s status=%s",
|
||||
project_id,
|
||||
sections_result.status_code,
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)",
|
||||
project_id,
|
||||
old_route_id,
|
||||
new_route_id,
|
||||
carried,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("재확정 체인 실패: project_id=%s", project_id)
|
||||
|
||||
@@ -262,6 +262,18 @@ async def confirm_surface(
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
# 재확정 체인(2026-08-04 사용자 확정) — 새 지표면 기준으로 B05·B06을 백그라운드
|
||||
# 재계산·저장한다. 사용자 저장 입력(제어점·경사 옵션·측점별 설계)은 유지·이월된다.
|
||||
# 응답을 막지 않도록 백그라운드로 돌리고, 체인은 실패를 스스로 격리한다.
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import run_redesign_chain
|
||||
|
||||
redesign_task = asyncio.create_task(
|
||||
run_redesign_chain(project_id, request.model_id, dict(selection)),
|
||||
name=f"redesign-chain-{project_id}",
|
||||
)
|
||||
redesign_task.add_done_callback(
|
||||
lambda task: task.exception() # 체인 내부에서 이미 로깅 — 미회수 예외 경고만 방지
|
||||
)
|
||||
return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
|
||||
@@ -235,10 +235,35 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
|
||||
const repeater = createHoldRepeater();
|
||||
const edited = new Set(Object.keys(alignment.edits.station_offsets));
|
||||
const stationXs = [
|
||||
...alignment.stations.map((station) => x(station.chainage_m)),
|
||||
...irregular.map((station) => x(station.chainage_m)),
|
||||
];
|
||||
|
||||
/**
|
||||
* 규칙·비정규 측점을 chainage 순으로 합치고, 버튼끼리 겹치지 않게 자리를 잡는다.
|
||||
* 비정규 측점(구조물)이 규칙 측점 바로 옆에 서면 ▲▼가 서로 덮였다(2026-08-04 사용자
|
||||
* 보고) — 앞 버튼과 최소 간격(BUTTON_CLEARANCE_PX)을 강제하며 오른쪽으로 밀어낸다.
|
||||
* 측점선 자체는 그대로라 버튼만 살짝 비켜선다.
|
||||
*/
|
||||
const stationPlacements = [
|
||||
...alignment.stations.map((station) => ({
|
||||
chainage: station.chainage_m,
|
||||
plan: station.plan_elevation_m,
|
||||
})),
|
||||
...irregular.map((station) => ({
|
||||
chainage: station.chainage_m,
|
||||
plan: planElevationAtSample(alignment, station.chainage_m),
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => a.chainage - b.chainage)
|
||||
.map(
|
||||
(() => {
|
||||
let previousX = Number.NEGATIVE_INFINITY;
|
||||
return (entry: { chainage: number; plan: number }) => {
|
||||
const placed = Math.max(x(entry.chainage), previousX + BUTTON_CLEARANCE_PX);
|
||||
previousX = placed;
|
||||
return { ...entry, left: placed };
|
||||
};
|
||||
})(),
|
||||
);
|
||||
const stationXs = stationPlacements.map((entry) => entry.left);
|
||||
|
||||
/**
|
||||
* 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다.
|
||||
@@ -259,8 +284,8 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
}
|
||||
// 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도
|
||||
// `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다.
|
||||
function addStationButtons(chainageM: number, planElevationM: number): void {
|
||||
const left = x(chainageM);
|
||||
// left는 겹침 회피가 끝난 화면 x — 측점 수직선(x(chainage))과 다를 수 있다.
|
||||
function addStationButtons(chainageM: number, planElevationM: number, left: number): void {
|
||||
const isEdited = edited.has(chainageKey(chainageM));
|
||||
const label = `${chainageM.toFixed(1)}m 계획고 ${planElevationM.toFixed(2)}m`;
|
||||
|
||||
@@ -291,12 +316,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
layer.append(reset);
|
||||
}
|
||||
|
||||
alignment.stations.forEach((station) =>
|
||||
addStationButtons(station.chainage_m, station.plan_elevation_m),
|
||||
);
|
||||
irregular.forEach((station) =>
|
||||
addStationButtons(station.chainage_m, planElevationAtSample(alignment, station.chainage_m)),
|
||||
);
|
||||
stationPlacements.forEach((entry) => addStationButtons(entry.chainage, entry.plan, entry.left));
|
||||
|
||||
alignment.segments.forEach((segment) => {
|
||||
const left = x(segment.from_m);
|
||||
@@ -306,14 +326,16 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
const label =
|
||||
`구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` +
|
||||
`(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`;
|
||||
const up = overlayButton(repeater, "is-segment is-up", "⇧", `${label} — ${step}m 올림`, () =>
|
||||
// 구간 이동 화살표는 속이 찬 글리프(⬆⬇)를 쓴다(2026-08-04 사용자 지시 — ⇧⇩는 윤곽선뿐이라
|
||||
// 흐릿했다). ︎(텍스트 표기 선택자)로 이모지 컬러 렌더링을 막아 방향색이 살게 한다.
|
||||
const up = overlayButton(repeater, "is-segment is-up", "⬆︎", `${label} — ${step}m 올림`, () =>
|
||||
onSegment(segment, step),
|
||||
);
|
||||
up.style.left = `${center - BUTTON_HALF_PX}px`;
|
||||
const down = overlayButton(
|
||||
repeater,
|
||||
"is-segment is-down",
|
||||
"⇩",
|
||||
"⬇︎",
|
||||
`${label} — ${step}m 내림`,
|
||||
() => onSegment(segment, -step),
|
||||
);
|
||||
|
||||
@@ -151,13 +151,14 @@ function readVisible(): Set<string> {
|
||||
* @param onChanged 펼침·범례 토글·높이 조절로 다시 그려야 할 때 호출된다(패널 전체 redraw).
|
||||
*/
|
||||
export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPanel {
|
||||
// 손잡이는 다른 패널과 **같은 양식**의 표준 삼각형 손잡이 하나만 쓴다(2026-08-04 사용자
|
||||
// 지시 — 예전 풀폭 바 + "유토곡선" 캡션은 다른 패널들과 모양이 달랐다). 무엇의 손잡이인지는
|
||||
// 툴팁으로 밝힌다.
|
||||
const handleControl = createWorkflowPanelHandle("bottom", "down");
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "b05-profile__masshaul-handle";
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "b05-profile__masshaul-caption";
|
||||
caption.textContent = "유토곡선";
|
||||
handle.append(handleControl.root, caption);
|
||||
handle.append(handleControl.root);
|
||||
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
|
||||
|
||||
// 오버레이 뼈대 — 위 경계 리사이저 + 요약 막대 + 가로 스크롤러 + 범례.
|
||||
const overlay = document.createElement("div");
|
||||
@@ -190,6 +191,23 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
});
|
||||
overlay.append(resizer.root, bar, scroll, legendLayer);
|
||||
|
||||
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
||||
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
||||
scroll.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (event.shiftKey || event.deltaY === 0) return;
|
||||
const limit = scroll.scrollWidth - scroll.clientWidth;
|
||||
if (limit <= 0) return;
|
||||
const delta = event.deltaY;
|
||||
if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit))
|
||||
return;
|
||||
scroll.scrollLeft += delta;
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
let open = sessionStorage.getItem(OPEN_KEY) === "true";
|
||||
let context: RouteMassHaulContext | null = null;
|
||||
|
||||
@@ -197,15 +215,17 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
open = next;
|
||||
sessionStorage.setItem(OPEN_KEY, String(next));
|
||||
handleControl.setOpen(next);
|
||||
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
|
||||
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", next);
|
||||
overlay.hidden = !next;
|
||||
onChanged();
|
||||
}
|
||||
handleControl.setOpen(open);
|
||||
handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", open);
|
||||
overlay.hidden = !open;
|
||||
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
||||
caption.addEventListener("click", () => applyOpen(!open));
|
||||
|
||||
function toggleSeries(key: string): void {
|
||||
// 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준 그래프만 전체 영역에 보인다.
|
||||
|
||||
@@ -883,6 +883,15 @@
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 구간 이동(⬆⬇)은 측점 버튼보다 살짝 크게 — 속 찬 화살표가 잘 읽히도록(2026-08-04). */
|
||||
.b05-profile-edit__btn.is-segment {
|
||||
width: 21px;
|
||||
height: 17px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.b05-profile-edit__btn.is-down {
|
||||
border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent);
|
||||
color: var(--color-chart-0, #5b8def);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
.b05-profile__masshaul-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 20px; /* 유토곡선 접기 손잡이(.b05-profile__masshaul-handle height) 몫 */
|
||||
bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
@@ -80,33 +80,18 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 2차 슬라이드 손잡이 — 바깥 패널 손잡이와 같은 문법이되 본문 폭 안에 눕혀 놓는다. */
|
||||
/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 패널 본문 바닥 중앙에 붙고, 오버레이(z-index 5) 위에 뜬다. */
|
||||
.b05-profile__masshaul-handle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-8);
|
||||
height: 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
z-index: 6;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-caption {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-handle.is-open .b05-profile__masshaul-caption {
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 공용 손잡이 버튼은 부모 바닥 경계에 절대배치되도록 만들어졌다 — 이 줄에서는 흐름에 둔다. */
|
||||
/* 공용 손잡이는 부모 경계 밖(top: -24px) 절대배치로 만들어졌다 — 여기서는 래퍼가
|
||||
자리를 잡으므로 흐름에 되돌린다. 크기·모양(64×24 삼각형)은 공용 규칙 그대로다. */
|
||||
.b05-profile__masshaul-handle .ui-workflow-overlay__handle {
|
||||
position: static;
|
||||
transform: none;
|
||||
|
||||
Reference in New Issue
Block a user