diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 72e2f5a61..177a0ec17 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -101,6 +101,8 @@ export const STATE_REGISTRY = { }, /** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */ "cross-columns": { bucket: "pref", scope: "global" }, + /** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */ + "mass-haul-badge-pos": { bucket: "pref", scope: "global" }, "section-panel-height": { bucket: "pref", scope: "global", diff --git a/A00_Common/design/design_profile_overlays.ts b/A00_Common/design/design_profile_overlays.ts index 235596d52..b4c2d503a 100644 --- a/A00_Common/design/design_profile_overlays.ts +++ b/A00_Common/design/design_profile_overlays.ts @@ -4,7 +4,8 @@ * 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. * ========================================================================== */ -import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; +import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge"; +import { readStateRaw, writeStateRaw } from "../b_page_state"; import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay"; import { @@ -31,6 +32,11 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { // 그래프 위 도구줄 · 측점 ▲ 줄(바닥 47px) 아래로 내려 겹치지 않게 한다(289). massBadge.root.style.setProperty("--mass-haul-badge-top", "52px"); bodyWrap.append(massBadge.root); + // 그래프 안 끌어 옮김(309-2) — 자리는 화면 설정 한 칸(B05 · B06 같은 값) + attachMassHaulBadgeDrag(massBadge.root, body, { + read: () => readStateRaw("mass-haul-badge-pos"), + write: (value) => writeStateRaw("mass-haul-badge-pos", value), + }); let massHaulSummary: MassHaulSummaryValues | null = null; // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 // 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다. diff --git a/common_util/common_util_mass_haul.css b/common_util/common_util_mass_haul.css index ce334dbb9..9a4177c05 100644 --- a/common_util/common_util_mass_haul.css +++ b/common_util/common_util_mass_haul.css @@ -382,29 +382,35 @@ /* Y축 띠(고정 축) 오른쪽에 놓는다 — 8px 로 두면 축 라벨 위에 겹쳐 값이 잘린다 (2026-09-06 실측). 축 폭이 다른 화면은 이 변수를 덮어쓴다. */ left: var(--mass-haul-badge-left, 86px); - z-index: 4; + /* 바닥 서브패널(유토곡선 · 테이블 5) · 손잡이(6) 위 — 어디로 끌어도 보이고 다시 잡힘(309-2) */ + z-index: 7; display: inline-flex; align-items: center; gap: var(--spacing-4, 4px); /* 큰 알약(289) — 글자 · 여백 한 단 위 */ padding: var(--spacing-4, 4px) var(--spacing-12, 12px); - /* 붉은 계통(289-2) — 테두리 · 글자 · 옅은 바탕 = 위험색 섞음(밝음 · 어두움 같이) */ + /* 붉은 계통(289-2) — 테두리 · 글자 · 옅은 바탕 = 위험색 섞음(밝음 · 어두움 같이) + 바탕은 불투명(309-1) — 그래프 선 · 채움이 비치지 않게 · 글자 대비 = 두 테마 모두 4.5 : 1 넘음 */ border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent); border-radius: var(--radius-pills, 999px); - background: color-mix( - in srgb, - var(--color-danger) 6%, - color-mix(in srgb, var(--color-surface-raised) 88%, transparent) - ); + background: color-mix(in srgb, var(--color-danger) 8%, var(--color-surface-raised)); color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body)); font-size: var(--pill-font-size-lg); line-height: var(--leading-body-sm); white-space: nowrap; pointer-events: auto; + /* 그래프 안 끌어 옮기기(309-2) — 손가락 끌기가 화면 밀기 · 글 고름으로 새지 않게 */ + cursor: grab; + touch-action: none; + user-select: none; +} + +.mass-haul-badge.is-dragging { + cursor: grabbing; } .mass-haul-badge em { - color: color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary)); + color: color-mix(in srgb, var(--color-danger) 55%, var(--color-text-body)); font-style: normal; } diff --git a/common_util/common_util_mass_haul_badge.ts b/common_util/common_util_mass_haul_badge.ts index f8702813e..93e9f2179 100644 --- a/common_util/common_util_mass_haul_badge.ts +++ b/common_util/common_util_mass_haul_badge.ts @@ -84,3 +84,100 @@ export function createMassHaulBadge(): MassHaulBadge { }, }; } + +/** 알약 자리 저장 창구(화면 설정 한 칸) — 값 = `{"x":0~1,"y":0~1}` 글. */ +export interface MassHaulBadgePlace { + read: () => string | null; + write: (value: string) => void; +} + +/** 끈 것으로 보는 움직임(px) — 이 안이면 그냥 누름(지금 동작 그대로). */ +const DRAG_SLOP_PX = 4; + +/** + * 알약 끌어 옮기기(309-2) — 마우스 · 손가락 같은 pointer 길 · `area`(그래프 스크롤러)의 보이는 상자 안에서만. + * 영역 = 알약 처음 자리(Y축 띠 오른쪽 · 측점 ▲ 줄 아래)가 왼쪽 위 끝 · 스크롤러 보이는 오른쪽 · 아래 끝까지. + * 자리 = 영역 안 비율 — 폭 · 높이가 바뀌거나 다시 그려도 같은 비율. 알약은 그래프 형제라 끌기가 그래프 고름에 안 닿음. + */ +export function attachMassHaulBadgeDrag( + root: HTMLElement, + area: HTMLElement, + place: MassHaulBadgePlace, +): void { + let origin: { x: number; y: number } | null = null; + const bounds = () => { + if (!origin) { + const [left, top] = [root.style.left, root.style.top]; + root.style.left = root.style.top = ""; + origin = { x: root.offsetLeft, y: root.offsetTop }; + [root.style.left, root.style.top] = [left, top]; + } + const w = area.offsetLeft + area.clientWidth - root.offsetWidth - origin.x; + const h = area.offsetTop + area.clientHeight - root.offsetHeight - origin.y; + return { ...origin, w: Math.max(0, w), h: Math.max(0, h) }; + }; + const clamp = (v: number): number => Math.min(1, Math.max(0, v)); + const put = (x: number, y: number): void => { + const b = bounds(); + root.style.left = `${Math.round(b.x + clamp(x) * b.w)}px`; + root.style.top = `${Math.round(b.y + clamp(y) * b.h)}px`; + }; + const restore = (): void => { + if (root.hidden || !root.isConnected) return; + try { + const v = JSON.parse(place.read() ?? "null") as { x?: unknown; y?: unknown } | null; + if (typeof v?.x === "number" && typeof v.y === "number") put(v.x, v.y); + } catch { + /* 못 읽는 값 = 처음 자리 */ + } + }; + new ResizeObserver(restore).observe(area); + new MutationObserver(restore).observe(root, { attributeFilter: ["hidden"] }); + + let drag: { id: number; px: number; py: number; left: number; top: number } | null = null; + let moved: { x: number; y: number } | null = null; + root.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + drag = { + id: e.pointerId, + px: e.clientX, + py: e.clientY, + left: root.offsetLeft, + top: root.offsetTop, + }; + moved = null; + root.setPointerCapture(e.pointerId); + }); + root.addEventListener("pointermove", (e) => { + if (!drag || e.pointerId !== drag.id) return; + const dx = e.clientX - drag.px; + const dy = e.clientY - drag.py; + if (!moved && Math.hypot(dx, dy) < DRAG_SLOP_PX) return; + const b = bounds(); + moved = { + x: b.w ? clamp((drag.left + dx - b.x) / b.w) : 0, + y: b.h ? clamp((drag.top + dy - b.y) / b.h) : 0, + }; + root.classList.add("is-dragging"); + put(moved.x, moved.y); + }); + const end = (e: PointerEvent): void => { + if (!drag || e.pointerId !== drag.id) return; + drag = null; + root.classList.remove("is-dragging"); + if (moved) place.write(JSON.stringify({ x: +moved.x.toFixed(4), y: +moved.y.toFixed(4) })); + }; + root.addEventListener("pointerup", end); + root.addEventListener("pointercancel", end); + // 끈 뒤 따라오는 click 은 삼킴 — 안 끈 누름은 그대로 지나감 + root.addEventListener( + "click", + (e) => { + if (!moved) return; + moved = null; + e.stopPropagation(); + e.preventDefault(); + }, + true, + ); +} diff --git a/resources/tester/test_289_1_mass_badge_size.py b/resources/tester/test_289_1_mass_badge_size.py index 566ad448e..b79eb6a7f 100644 --- a/resources/tester/test_289_1_mass_badge_size.py +++ b/resources/tester/test_289_1_mass_badge_size.py @@ -70,9 +70,13 @@ def test_badge_red_tone_uses_theme_danger(): badge = _block(css, ".mass-haul-badge") assert "border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent)" in badge assert "color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body))" in badge - assert "var(--color-danger) 6%" in badge + # 바탕 불투명(309-1) — 그래프 선 · 채움이 비치지 않게 + assert ( + "background: color-mix(in srgb, var(--color-danger) 8%, var(--color-surface-raised))" + in badge + ) em = _block(css, ".mass-haul-badge em") - assert "color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary))" in em + assert "color-mix(in srgb, var(--color-danger) 55%, var(--color-text-body))" in em over = _block(css, ".mass-haul-badge.is-over") assert "border-color: var(--color-danger)" in over assert "var(--color-danger) 12%" in over @@ -83,9 +87,9 @@ def test_badge_red_tone_uses_theme_danger(): def test_badge_red_tone_contrast_both_themes(): for name, t in THEMES.items(): - bg = _mix(t["danger"], 6, t["surface"]) + bg = _mix(t["danger"], 8, t["surface"]) text = _mix(t["danger"], 70, t["body"]) - caption = _mix(t["danger"], 55, t["secondary"]) + caption = _mix(t["danger"], 55, t["body"]) over_bg = _mix(t["danger"], 12, t["surface"]) over_value = _mix(t["danger"], 85, t["body"]) # 음수 값 = 위험색 더 많이 assert _contrast(text, bg) >= 4.5, name diff --git a/resources/tester/test_309_mass_haul_badge.py b/resources/tester/test_309_mass_haul_badge.py new file mode 100644 index 000000000..f57c37ef2 --- /dev/null +++ b/resources/tester/test_309_mass_haul_badge.py @@ -0,0 +1,185 @@ +"""B05 · B06 종단 누가토량 알약 — 테마 색 · 그래프 안 끌기(PLAN 309 · 요구 202) — 실제 브라우저(5174). + +① 색: 밝음 · 어두움 × 보통 · 음수 — 바탕 불투명 · 글자(제목 · 값) 대비 4.5 넘음 · 붉은 계통(빨강이 가장 큼) +② 끌기(마우스): 움직임 · 끈 뒤 click 은 삼킴 · 안 끈 누름은 지나감 · 영역 끝 막힘(오른쪽 아래 · 왼쪽 위 = 처음 자리) +③ 자리 기억: 화면 설정 한 칸(계정 올리기 PUT 에 실림 — 시험은 가로채 서버에 안 씀) · 다시 열어도 · 창 크기 바꿔도 같은 비율 +④ B06 도 같은 자리 · 손가락 끌기(터치 pointer)도 같은 길 +""" + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import test_device_audit as audit # noqa: E402 + +KEY = "aislo:pref:mass-haul-badge-pos" +# 그래프 고름 · 종단 편집 흔적 — 고른 것 수 · 되돌리기 단추 켜짐 +PICKED = ( + "[document.querySelectorAll('.b05-route-profile__body :is(.is-selected, .is-picked," + " .is-active)').length, location.hash]" +) +BOX = ( + "(() => { const b = document.querySelector('.mass-haul-badge'), a = b.parentElement" + ".querySelector('.b05-route-profile__body'); const r = b.getBoundingClientRect()," + " q = a.getBoundingClientRect(); return {l: r.left, t: r.top, r: r.right, b: r.bottom," + " w: r.width, h: r.height, ar: q.left + a.clientWidth, ab: q.top + a.clientHeight," + " al: q.left, at: q.top}; })()" +) +CONTRAST = r""" +(theme) => { + document.documentElement.dataset.theme = theme; + const parse = (s) => { const m = s.match(/[\d.]+/g).map(Number); + const k = s.startsWith('color(') ? 255 : 1; + return {r: m[0] * k, g: m[1] * k, b: m[2] * k, a: m.length > 3 ? m[3] : 1}; }; + const lum = (c) => { const ch = [c.r, c.g, c.b].map((v) => { v /= 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); + return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2]; }; + const ratio = (x, y) => { const [h, l] = [lum(x), lum(y)].sort((p, q) => q - p); + return (h + 0.05) / (l + 0.05); }; + const badge = document.querySelector('.mass-haul-badge'); + const was = badge.classList.contains('is-over'); + const out = []; + for (const over of [false, true]) { + badge.classList.toggle('is-over', over); + const bg = parse(getComputedStyle(badge).backgroundColor); + for (const t of ['em', 'strong']) { + const fg = parse(getComputedStyle(badge.querySelector(t)).color); + out.push({over, t, alpha: bg.a, ratio: ratio(fg, bg), red: fg.r > fg.g && fg.r > fg.b}); + } + } + badge.classList.toggle('is-over', was); + return out; +} +""" + + +def _ctx(browser, cred, touch=False, saved=None): + ctx = browser.new_context( + viewport={"width": 1440, "height": 900}, has_touch=touch, is_mobile=False + ) + audit._login(ctx, cred) + pid = audit._project_id(ctx) + ctx.add_init_script(f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})") + pushed = [] + + # 계정 취향 — 받기는 빈 값(처음 자리부터) · 올리기는 가로채 서버에 안 씀 + def prefs(route): + if route.request.method == "PUT": + pushed.append(json.loads(route.request.post_data or "{}").get("prefs", {})) + body = {"prefs": saved or {}} if route.request.method == "GET" else {"ok": True} + route.fulfill(status=200, content_type="application/json", body=json.dumps(body)) + + ctx.route("**/api/dashboard/me/ui-prefs", prefs) + return ctx, pushed + + +def _drag(page, dx, dy): + b = page.evaluate(BOX) + x, y = b["l"] + b["w"] / 2, b["t"] + b["h"] / 2 + page.mouse.move(x, y) + page.mouse.down() + page.mouse.move(x + dx / 2, y + dy / 2, steps=4) + page.mouse.move(x + dx, y + dy, steps=4) + page.mouse.up() + + +def _saved(page): + return json.loads(page.evaluate(f"localStorage.getItem({json.dumps(KEY)})") or "null") + + +@pytest.mark.skipif(not audit._server_up(), reason="sub1 화면 서버(5174) 꺼짐") +def test_mass_haul_badge_color_and_drag(): + from playwright.sync_api import sync_playwright + + cred = json.loads((audit.ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + ctx, pushed = _ctx(browser, cred) + page = ctx.new_page() + audit._open(page, "b05-profile") + page.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000) + + # ① + for theme in ("light", "dark"): + for row in page.evaluate(CONTRAST, theme): + assert row["alpha"] == 1 and row["ratio"] >= 4.5 and row["red"], (theme, row) + page.evaluate("document.documentElement.removeAttribute('data-theme')") + + # ② 안 끈 누름 = 지나감 · 끈 뒤 click = 삼킴 + page.evaluate( + "window.__clicks = 0; document.addEventListener('click', (e) =>" + " e.target.closest?.('.mass-haul-badge') && window.__clicks++)" + ) + start = page.evaluate(BOX) + picked = page.evaluate(PICKED) + page.click(".mass-haul-badge") + assert page.evaluate("window.__clicks") == 1 + assert page.evaluate(BOX)["l"] == start["l"] + assert _saved(page) is None + _drag(page, 200, 60) + moved = page.evaluate(BOX) + assert abs(moved["l"] - start["l"] - 200) <= 2 and abs(moved["t"] - start["t"] - 60) <= 2 + assert page.evaluate("window.__clicks") == 1 + _drag(page, 5000, 5000) # 오른쪽 아래 끝 막힘 + end = page.evaluate(BOX) + assert ( + end["ar"] - 1 <= end["r"] <= end["ar"] + 1 + and end["ab"] - 1 <= end["b"] <= end["ab"] + 1 + ) + assert _saved(page) == {"x": 1, "y": 1} + _drag(page, -5000, -5000) # 왼쪽 위 끝 = 처음 자리 + assert page.evaluate(BOX)["l"] == start["l"] and page.evaluate(BOX)["t"] == start["t"] + assert _saved(page) == {"x": 0, "y": 0} + assert page.evaluate(PICKED) == picked # 끌기가 그래프 고름 · 편집을 안 일으킴 + + # ③ 비율 기억 · 계정 올리기에 실림 · 다시 열기 · 창 크기 + _drag(page, 300, 100) + ratio = _saved(page) + assert 0 < ratio["x"] < 1 and 0 < ratio["y"] < 1 + page.wait_for_timeout(2000) # 올리기 모음 1.5초 + assert any(KEY in push for push in pushed) + audit._open(page, "b05-profile") + page.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000) + b = page.evaluate(BOX) + x0, y0 = start["l"] - start["al"], start["t"] - start["at"] + span_x = b["ar"] - b["al"] - x0 - b["w"] + span_y = b["ab"] - b["at"] - y0 - b["h"] + assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio) + assert abs((b["t"] - b["at"] - y0) - ratio["y"] * span_y) <= 2, (b, ratio) + page.set_viewport_size({"width": 1200, "height": 800}) + page.wait_for_timeout(800) + b = page.evaluate(BOX) + span_x = b["ar"] - b["al"] - x0 - b["w"] + assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio) + assert b["r"] <= b["ar"] + 1 and b["b"] <= b["ab"] + 1 + ctx.close() + + # ④ B06 같은 자리(계정 값으로 받음) · 손가락 끌기 + tctx, _ = _ctx(browser, cred, touch=True, saved={KEY: json.dumps(ratio)}) + tp = tctx.new_page() + audit._open(tp, "b06-section") + tp.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000) + b = tp.evaluate(BOX) + span_x = b["ar"] - b["al"] - x0 - b["w"] + assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio) + cdp = tctx.new_cdp_session(tp) + x, y = b["l"] + b["w"] / 2, b["t"] + b["h"] / 2 + cdp.send( + "Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x, "y": y}]} + ) + for i in range(1, 6): + cdp.send( + "Input.dispatchTouchEvent", + {"type": "touchMove", "touchPoints": [{"x": x - 20 * i, "y": y - 8 * i}]}, + ) + cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) + after = tp.evaluate(BOX) + assert abs(after["l"] - b["l"] + 100) <= 2 and abs(after["t"] - b["t"] + 40) <= 2, ( + b, + after, + ) + assert _saved(tp) != ratio + browser.close()