diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index 7da19f8..c3d817f 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -81,6 +81,20 @@ svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; } max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out; } body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */ + +/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego + koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu + (1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za + kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */ +.wheel-tip { + position: fixed; z-index: 1150; pointer-events: none; + max-width: 22rem; padding: .4rem .6rem; + background: var(--panel); color: var(--fg, inherit); + border: 1px solid var(--line); border-radius: 8px; + font-size: .85rem; line-height: 1.35; + box-shadow: 0 4px 14px rgba(0, 0, 0, .45); +} +.wheel-tip[hidden] { display: none; } form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; } .row { display: flex; gap: .5rem; } .row input[type=text] { flex: 1; } diff --git a/services/presentation/app/static/wheeltip.js b/services/presentation/app/static/wheeltip.js new file mode 100644 index 0000000..06c3dd1 --- /dev/null +++ b/services/presentation/app/static/wheeltip.js @@ -0,0 +1,97 @@ +// Dymki na kosmogramie — własne, zamiast natywnych z (PRE-12/PRE-25). +// +// DLACZEGO NIE ZOSTAWIAMY NATYWNYCH. Rysunek niesie <title> przy każdym obiekcie +// i każdej linii aspektu i to wystarczało, dopóki koło było statyczne. Po dodaniu +// powiększania na pełne okno dymki przestały się pokazywać — a niezależnie od tego, +// która reguła przeglądarki je tłumi, natywny dymek i tak jest tu kiepskim +// narzędziem: pojawia się po sekundzie zwłoki, nie da się go stylować, nie działa +// na dotyku i nie ma go czym wywołać z klawiatury. +// +// Sprawdzone w przeglądarce (1280×900, komplet 13 systemów): elementy z <title> +// SĄ osiągalne kursorem — 19 z 20 trafień w hit-teście — więc nic ich nie zasłania +// i problem nie leży w geometrii ani w pointer-events. +// +// <title> ZOSTAJE w rysunku: to nazwa dostępna dla czytników ekranu, a PDF składa +// się po stronie serwera, więc ta ścieżka jest nietknięta. Na czas najechania +// odpinamy go i wieszamy z powrotem po zejściu kursora — dzięki temu nigdy nie +// widać dwóch dymków naraz, gdyby przeglądarka jednak swój pokazała. +(function () { + 'use strict'; + + var OFFSET = 14; // odsunięcie od kursora, żeby dymek nie siedział pod nim + var stash = null; // { owner, node } — odpięty <title> do przywieszenia + + function ready(fn) { + if (document.readyState !== 'loading') fn(); + else document.addEventListener('DOMContentLoaded', fn); + } + + ready(function () { + var wheels = document.querySelectorAll('svg.wheel'); + if (!wheels.length) return; + + var tip = document.createElement('div'); + tip.className = 'wheel-tip'; + tip.setAttribute('role', 'tooltip'); + tip.hidden = true; + document.body.appendChild(tip); + + function restore() { + if (!stash) return; + stash.owner.appendChild(stash.node); + stash = null; + } + + function hide() { + tip.hidden = true; + restore(); + } + + // Element z własnym <title>, licząc od celu zdarzenia w górę. Aspekty to + // cienkie linie, a glify siedzą w <text> — oba trzymają <title> jako dziecko. + function owner(el, root) { + while (el && el !== root) { + if (el.querySelector && el.querySelector(':scope > title')) return el; + el = el.parentNode; + } + return null; + } + + function place(x, y) { + // Ustawiamy najpierw, potem mierzymy — inaczej nie znamy rozmiaru dymka. + tip.style.left = '0px'; + tip.style.top = '0px'; + var w = tip.offsetWidth, h = tip.offsetHeight; + var left = x + OFFSET, top = y + OFFSET; + if (left + w > window.innerWidth - 4) left = x - OFFSET - w; // odbij w lewo + if (top + h > window.innerHeight - 4) top = y - OFFSET - h; // odbij w górę + tip.style.left = Math.max(4, left) + 'px'; + tip.style.top = Math.max(4, top) + 'px'; + } + + Array.prototype.forEach.call(wheels, function (svg) { + svg.addEventListener('mousemove', function (e) { + var el = owner(e.target, svg); + if (!el) { hide(); return; } + + if (!stash || stash.owner !== el) { + restore(); + var node = el.querySelector(':scope > title'); + tip.textContent = node.textContent; + stash = { owner: el, node: node }; + el.removeChild(node); + tip.hidden = false; + } + place(e.clientX, e.clientY); + }); + + svg.addEventListener('mouseleave', hide); + }); + + // Dymek nie może przeżyć przewinięcia ani powiększenia — zostałby wiszący + // w miejscu, w którym nic już nie ma. + window.addEventListener('scroll', hide, true); + window.addEventListener('resize', hide); + document.addEventListener('click', hide, true); + }); +})(); diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index c771609..7a6e025 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -13,6 +13,7 @@ {# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #} <script defer src="{{ static('wheelzoom.js') }}"></script> + <script defer src="{{ static('wheeltip.js') }}"></script> </head> <body> <main> diff --git a/services/presentation/tests/test_wheeltip.py b/services/presentation/tests/test_wheeltip.py new file mode 100644 index 0000000..43bf9cd --- /dev/null +++ b/services/presentation/tests/test_wheeltip.py @@ -0,0 +1,72 @@ +"""Dymki na kosmogramie (PRE-12/PRE-25). + +Rysunek niesie <title> przy każdym obiekcie i każdej linii aspektu, ale natywne +dymki przeglądarki przestały się pokazywać po dodaniu powiększania na pełne okno. +Sprawdzone w przeglądarce: elementy z <title> SĄ osiągalne kursorem (19 z 20 trafień +w hit-teście), więc nic ich nie zasłaniało — problem był w samym natywnym dymku. + +Test strukturalny, jak przy wheelzoom: bez uruchamiania przeglądarki nie zbadamy +zachowania JS-a. Pilnujemy więc tego, co da się zepsuć po cichu i co ma znaczenie: +kolejności warstw, zdejmowania i ODWIESZANIA <title> oraz wpięcia skryptu. +Zachowanie sprawdzone ręcznie w przeglądarce przy 1280×900, w obu stanach koła. +""" +import pathlib +import re + +APP = pathlib.Path(__file__).resolve().parents[1] / "app" +JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8") +CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8") +BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8") + + +def test_script_is_loaded_globally(): + """W base.html, bo koło jest też na zakładce „Skompiluj".""" + assert "wheeltip.js" in BASE + + +def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window(): + """Warstwy: nakładka powiększonego koła < dymek < okno postępu. + + Dymek MUSI być nad nakładką, inaczej po powiększeniu — czyli dokładnie tam, + gdzie zgłoszono problem — byłby niewidoczny. I POD oknem postępu, bo gdy trwa + pisanie horoskopu, log operacji ma zostać na wierzchu.""" + tip = re.search(r"\.wheel-tip\s*\{[^}]*z-index:\s*(\d+)", CSS) + zoom = re.search(r"\.wheel-fig\.zoomed\s*\{[^}]*z-index:\s*(\d+)", CSS) + progress = re.search(r"\.progress-overlay\s*\{[^}]*z-index:\s*(\d+)", CSS) + assert tip and zoom and progress, "brakuje którejś reguły z-index" + assert int(zoom.group(1)) < int(tip.group(1)) < int(progress.group(1)), \ + f"warstwy: nakładka {zoom.group(1)}, dymek {tip.group(1)}, postęp {progress.group(1)}" + + +def test_tooltip_never_swallows_the_pointer(): + """Dymek chodzi za kursorem, więc bez pointer-events:none zasłaniałby sam + sobie element, nad którym stoi — i migotał.""" + rule = re.search(r"\.wheel-tip\s*\{([^}]*)\}", CSS) + assert rule and "pointer-events: none" in rule.group(1) + + +def test_title_is_detached_only_for_the_hover_and_always_put_back(): + """<title> zostaje w rysunku — to nazwa dostępna dla czytników ekranu. + + Na czas najechania odpinamy go, żeby nie było dwóch dymków naraz, ale MUSI + wrócić. Brak odwieszania okradałby czytniki ekranu po pierwszym najechaniu, + i to po cichu — na ekranie nic by nie było widać.""" + assert "removeChild" in JS, "dymek natywny nie jest tłumiony" + assert "appendChild(stash.node)" in JS, "brak odwieszania <title>" + for leaving in ("mouseleave", "scroll", "resize"): + assert leaving in JS, f"brak sprzątania przy: {leaving}" + + +def test_tooltip_reads_the_title_that_is_already_in_the_drawing(): + """Źródłem tekstu jest <title> z SVG, nie druga kopia opisów w JS — + inaczej rozjechałyby się przy pierwszej zmianie treści dymka.""" + assert ":scope > title" in JS + assert "textContent" in JS + + +def test_pdf_path_is_untouched(): + """PDF składa się po stronie serwera, więc żaden dymek go nie dotyczy — + <title> zostają w rysunku i nic w chartwheel.py się nie zmienia.""" + wheel = (APP / "chartwheel.py").read_text(encoding="utf-8") + assert "<title>" in wheel + assert "wheeltip" not in wheel