From 3250c3bb65de722de2bfc1aa6243334402f5bf49 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 09:36:59 +0900 Subject: [PATCH] 260719_1 --- B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 38 +++++++------ .../B05_wf2_Route_UI_Profile_Panel.ts | 14 ++--- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 54 +++---------------- .../B06_wf3_ProfileCross_UI_Style.css | 5 ++ ui_template/ui_template_elements.ts | 17 +++++- ui_template/ui_template_overlay.css | 42 ++++++++++++--- ui_template/ui_template_overlay.ts | 44 ++++++++++++--- 7 files changed, 125 insertions(+), 89 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 0f4cd800..9ef8baa0 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -1,4 +1,5 @@ import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers"; +import { type ButtonVariant, createButton } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; export interface RoutePanelValues { @@ -50,13 +51,12 @@ function section(title: string): { root: HTMLElement; body: HTMLElement } { return { root, body }; } -function button(label: string, onClick: () => void, className = ""): HTMLButtonElement { - const element = document.createElement("button"); - element.type = "button"; - element.className = `b05-route__button ${className}`.trim(); - element.textContent = label; - element.addEventListener("click", onClick); - return element; +function button( + label: string, + onClick: () => void, + variant: ButtonVariant = "ghost", +): HTMLButtonElement { + return createButton({ label, variant, onClick: () => onClick() }); } function numberField(label: string, value = ""): WrappedInput { @@ -87,12 +87,16 @@ function toggleButton( checked: boolean, onChange: (checked: boolean) => void, ): HTMLButtonElement { - const element = button(label, () => { - const active = !element.classList.contains("is-active"); - element.classList.toggle("is-active", active); - element.setAttribute("aria-pressed", String(active)); - onChange(active); - }); + const element = button( + label, + () => { + const active = !element.classList.contains("is-active"); + element.classList.toggle("is-active", active); + element.setAttribute("aria-pressed", String(active)); + onChange(active); + }, + "glass", + ); element.classList.toggle("is-active", checked); element.setAttribute("aria-pressed", String(checked)); return element; @@ -113,7 +117,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { const viewButtons = document.createElement("div"); viewButtons.className = "b05-route__view-group"; (["iso", "top", "front", "side"] as const).forEach((preset) => - viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset))), + viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; @@ -131,7 +135,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { separator1, visibilityButtons, separator2, - button("뷰 초기화", callbacks.onResetView), + button("뷰 초기화", callbacks.onResetView, "glass"), ); const contour = section("등고선 간격"); @@ -173,7 +177,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { selectedActions.className = "b05-route__actions"; selectedActions.append( button("위치 이동", callbacks.onMovePoint), - button("삭제", callbacks.onDeletePoint, "is-danger"), + button("삭제", callbacks.onDeletePoint, "danger"), ); selected.body.append(selectedName, radius.wrapper, selectedActions); @@ -240,7 +244,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { metrics.textContent = "경로를 계산하면 결과가 표시됩니다."; result.body.append(stale, metrics); - const solveButton = button("최적 경로 계산", callbacks.onSolve, "is-primary"); + const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); const confirmButton = button("경로 확정", callbacks.onConfirm); confirmButton.disabled = true; const actionRow = document.createElement("div"); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 24c6b1f7..cfd69641 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -3,6 +3,7 @@ import type { SectionDetailResponse, } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; import { createLongitudinalProfile } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View"; +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; @@ -20,18 +21,15 @@ function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection export function createRouteProfilePanel(onSelectStation: (stationId: string) => void) { const root = document.createElement("section"); root.className = "b05-route-profile"; - const header = document.createElement("header"); - const toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = "b05-route-profile__toggle"; + const panelHandle = createWorkflowPanelHandle("bottom"); + const toggle = panelHandle.root; const body = document.createElement("div"); body.className = "b05-route-profile__body"; const empty = document.createElement("p"); empty.className = "b05-route-profile__empty"; empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; body.append(empty); - header.append(toggle); - root.append(header, body); + root.append(toggle, body); let detail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; let stationInterval: number | undefined; @@ -52,9 +50,7 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) => function setCollapsed(collapsed: boolean): void { root.classList.toggle("is-collapsed", collapsed); - toggle.textContent = collapsed ? "⌃" : "⌄"; - toggle.setAttribute("aria-label", collapsed ? "종단면도 펼치기" : "종단면도 접기"); - toggle.setAttribute("aria-expanded", String(!collapsed)); + panelHandle.setOpen(!collapsed); sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index a0eddf55..087d2bf1 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -35,40 +35,22 @@ } .b05-route-profile { + position: relative; flex: 0 0 250px; min-height: 250px; - overflow: hidden; + overflow: visible; border-top: 1px solid var(--color-border); background: var(--color-surface-raised); transition: flex-basis var(--transition-fast); } .b05-route-profile.is-collapsed { - flex-basis: 24px; - min-height: 24px; -} - -.b05-route-profile > header { - display: flex; - height: 24px; - align-items: center; - justify-content: center; - padding: 0; - border-bottom: 1px solid var(--color-border); - color: var(--color-text); -} - -.b05-route-profile__toggle { - width: 100%; - height: 100%; - border: 0; - background: transparent; - color: var(--color-primary); - cursor: pointer; + flex-basis: 0; + min-height: 0; } .b05-route-profile__body { - height: calc(100% - 24px); + height: 100%; overflow: auto; } @@ -124,19 +106,9 @@ gap: var(--spacing-4); } -.b05-route__view-controls .b05-route__button { +.b05-route__view-controls .ui-btn { height: 34px; padding: 0 var(--spacing-8); - border-color: color-mix(in srgb, var(--color-border) 65%, transparent); - background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); - backdrop-filter: blur(6px); -} - -.b05-route__view-controls .b05-route__button:hover, -.b05-route__view-controls .b05-route__button.is-active { - border-color: var(--color-primary); - background: color-mix(in srgb, var(--color-primary) 78%, transparent); - color: var(--color-text-on-primary); } .b05-route__view-separator { @@ -191,7 +163,7 @@ flex: 1; } -.b05-route__contour-row .b05-route__button { +.b05-route__contour-row .ui-btn { flex: 0 0 auto; } @@ -228,7 +200,6 @@ gap: var(--spacing-8); } -.b05-route__button, .b05-route__chip { padding: var(--spacing-8); border: 1px solid var(--color-border); @@ -239,17 +210,6 @@ cursor: pointer; } -.b05-route__button:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.b05-route__button.is-primary { - background: var(--color-primary); - color: var(--color-text-on-primary); -} - -.b05-route__button.is-danger, .b05-route__chip.is-ep, .b05-route__chip.is-fp { color: var(--color-danger); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index 9955a859..cd92daa1 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -114,6 +114,11 @@ min-width: 0; } +.b06-section { + box-sizing: border-box; + padding-inline: var(--spacing-24); +} + .b06-section__panel, .b06-cross-card { overflow: hidden; diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 4e08ab9c..50a6c4e9 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -43,7 +43,7 @@ function el( * 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav * -------------------------------------------------------------------------- */ -export type ButtonVariant = "filled" | "ghost" | "pill" | "danger"; +export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "glass"; export interface ButtonOptions { /** 표시 텍스트 (i18n 결과 문자열을 전달) */ @@ -552,6 +552,21 @@ const BASE_CSS = ` color: var(--color-canvas); } +/* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */ +.ui-btn--glass { + border-color: color-mix(in srgb, var(--color-border) 65%, transparent); + background-color: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); + color: var(--color-text-body); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.ui-btn--glass:hover:not(:disabled), +.ui-btn--glass.is-active { + border-color: var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 82%, transparent); + color: var(--color-primary-text); +} + /* --- Input Field --- */ .ui-field { display: flex; flex-direction: column; gap: var(--spacing-4); } .ui-field__label { diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index fcc8da8d..587e20ea 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -81,17 +81,9 @@ cursor: pointer; } -/* title 패널 우측 가장자리 세로 중앙 핸들 — 닫히면 이 버튼만 화면 왼쪽에 남음 */ .ui-workflow-overlay__handle { position: absolute; - top: 50%; - right: calc(-1 * var(--spacing-24)); - width: var(--spacing-24); - height: var(--spacing-64); - transform: translateY(-50%); border: 1px solid var(--color-border); - border-left: 0; - border-radius: 0 var(--radius-buttons) var(--radius-buttons) 0; background: var(--color-surface-raised); color: var(--color-text); box-shadow: var(--shadow-lg); @@ -99,6 +91,40 @@ pointer-events: auto; } +.ui-workflow-overlay__handle-icon { + display: block; + width: 0; + height: 0; + margin: auto; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 7px solid currentcolor; + transition: transform var(--transition-fast); +} + +/* title 패널 우측 가장자리 세로 중앙 핸들 — 닫히면 이 버튼만 화면 왼쪽에 남음 */ +.ui-workflow-overlay__handle--side { + top: 50%; + right: calc(-1 * var(--spacing-24)); + width: var(--spacing-24); + height: var(--spacing-64); + transform: translateY(-50%); + border-left: 0; + border-radius: 0 var(--radius-buttons) var(--radius-buttons) 0; +} + +/* 하단 도킹 패널 상단 중앙 핸들 */ +.ui-workflow-overlay__handle--bottom { + z-index: 2; + top: calc(-1 * var(--spacing-24)); + left: 50%; + width: var(--spacing-64); + height: var(--spacing-24); + transform: translateX(-50%); + border-bottom: 0; + border-radius: var(--radius-buttons) var(--radius-buttons) 0 0; +} + .ui-workflow-overlay__body { overflow-y: auto; padding: 0 var(--spacing-16) var(--spacing-16); diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 7a9c76fd..b157e005 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -18,6 +18,30 @@ export interface WorkflowOverlayHandle { setProgressOpen: (isOpen: boolean) => void; } +export interface WorkflowPanelHandle { + root: HTMLButtonElement; + setOpen: (isOpen: boolean) => void; +} + +export function createWorkflowPanelHandle(placement: "side" | "bottom"): WorkflowPanelHandle { + const root = document.createElement("button"); + root.type = "button"; + root.className = `ui-workflow-overlay__handle ui-workflow-overlay__handle--${placement}`; + const icon = document.createElement("span"); + icon.className = "ui-workflow-overlay__handle-icon"; + root.append(icon); + + function setOpen(isOpen: boolean): void { + const rotation = placement === "side" ? (isOpen ? 180 : 0) : isOpen ? 90 : -90; + icon.style.transform = `rotate(${rotation}deg)`; + root.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); + root.setAttribute("aria-label", root.title); + root.setAttribute("aria-expanded", String(isOpen)); + } + + return { root, setOpen }; +} + function readOpenState(key: string): boolean { return sessionStorage.getItem(key) !== "false"; } @@ -40,9 +64,12 @@ function createPanel( const title = document.createElement("h2"); title.className = "ui-workflow-overlay__title"; title.textContent = titleText; - const toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = isSidebar ? "ui-workflow-overlay__handle" : "ui-workflow-overlay__toggle"; + const panelHandle = isSidebar ? createWorkflowPanelHandle("side") : null; + const toggle = panelHandle?.root ?? document.createElement("button"); + if (!panelHandle) { + toggle.type = "button"; + toggle.className = "ui-workflow-overlay__toggle"; + } if (isSidebar) { header.append(title); @@ -58,10 +85,13 @@ function createPanel( function setOpen(isOpen: boolean): void { root.classList.toggle("is-collapsed", !isOpen); - toggle.textContent = isSidebar ? (isOpen ? "◀" : "▶") : isOpen ? "−" : "+"; - toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); - toggle.setAttribute("aria-label", toggle.title); - toggle.setAttribute("aria-expanded", String(isOpen)); + if (panelHandle) panelHandle.setOpen(isOpen); + else { + toggle.textContent = isOpen ? "−" : "+"; + toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); + toggle.setAttribute("aria-label", toggle.title); + toggle.setAttribute("aria-expanded", String(isOpen)); + } sessionStorage.setItem(storageKey, String(isOpen)); onOpenChange?.(isOpen); }