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 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
+// 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.
+//
+// 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 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 , licząc od celu zdarzenia w górę. Aspekty to
+ // cienkie linie, a glify siedzą w — oba trzymają 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ć. #}
+
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 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 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 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():
+ """ 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 "
+ 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 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 —
+ zostają w rysunku i nic w chartwheel.py się nie zmienia."""
+ wheel = (APP / "chartwheel.py").read_text(encoding="utf-8")
+ assert "" in wheel
+ assert "wheeltip" not in wheel