mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +00:00
Merge pull request #6 from migatu/feat/presentation-chart
Warstwa prezentacji: widok horoskopu do recznego testowania
This commit is contained in:
@@ -5,12 +5,21 @@ przekazuje dane z formularza do warstwy logicznej i renderuje opracowane wyniki.
|
|||||||
Brak logiki biznesowej i dostępu do danych.
|
Brak logiki biznesowej i dostępu do danych.
|
||||||
|
|
||||||
## Trasy
|
## Trasy
|
||||||
- `GET /` — strona z formularzem
|
- `GET /` — **Horoskop**: formularz danych momentu (data, godzina, strefa, opcjonalnie lokalizacja)
|
||||||
- `POST /` — wysłanie formularza → warstwa logiczna → render wyników
|
- `POST /` — liczy pozycje obiektów (woła logic `/chart/positions`) i renderuje czytelną tabelę
|
||||||
|
- `GET /significators`, `POST /significators` — wyszukiwarka w bazach interpretacji (warstwa danych)
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
|
|
||||||
|
Strona „Horoskop" to widok do **ręcznego testowania**: podstawowe dane wejściowe →
|
||||||
|
to, co policzyła warstwa logiczna (znak, pozycja w znaku, absolutna, kierunek, prędkość).
|
||||||
|
Przycisk „Tu i teraz" uzupełnia bieżącą datę/godzinę i strefę przeglądarki.
|
||||||
|
|
||||||
## Zależności w dół
|
## Zależności w dół
|
||||||
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej).
|
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej) i jej kontrakty `/chart/positions`
|
||||||
|
oraz `/api/query`. Nie wie nic o silniku ani bazach.
|
||||||
|
|
||||||
|
> Uwaga: pełne liczenie pozycji wymaga warstwy logicznej z silnikiem efemeryd
|
||||||
|
> (gałąź `feat/logic-engine`). Bez niego strona „Horoskop" pokaże czytelny komunikat.
|
||||||
|
|
||||||
## Uruchomienie
|
## Uruchomienie
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Klient HTTP do warstwy logicznej.
|
"""Klient HTTP do warstwy logicznej.
|
||||||
|
|
||||||
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
|
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
|
||||||
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy.
|
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -22,3 +22,13 @@ class LogicClient:
|
|||||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
r = client.post(f"{self.base_url}/api/query", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self, when_utc_iso: str, lat: float, lon: float, objects: list[str] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Pozycje obiektów dla danego momentu — woła logic /chart/positions."""
|
||||||
|
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "objects": objects}
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
||||||
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
|
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
|
||||||
|
|
||||||
|
Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
|
||||||
|
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
|
||||||
|
„/significators".
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, Form, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -19,13 +25,62 @@ templates = Jinja2Templates(directory="app/templates")
|
|||||||
logic = LogicClient()
|
logic = LogicClient()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||||
|
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
|
||||||
|
|
||||||
|
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny − offset.
|
||||||
|
"""
|
||||||
|
local = datetime.fromisoformat(f"{date}T{time}")
|
||||||
|
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
|
||||||
|
label = utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
return utc.isoformat(), label
|
||||||
|
|
||||||
|
|
||||||
|
def _logic_error(e: Exception) -> str:
|
||||||
|
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||||||
|
return (
|
||||||
|
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
|
||||||
|
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
|
||||||
|
)
|
||||||
|
return f"Warstwa logiczna niedostępna: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request):
|
def chart_form(request: Request):
|
||||||
return templates.TemplateResponse(request, "index.html", {"result": None, "form": {}})
|
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/", response_class=HTMLResponse)
|
@app.post("/", response_class=HTMLResponse)
|
||||||
def search(
|
def chart_compute(
|
||||||
|
request: Request,
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
):
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
ctx["result"] = logic.positions(when_utc_iso=iso_utc, lat=lat, lon=lon)
|
||||||
|
except (httpx.HTTPError,) as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "chart.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
|
||||||
|
@app.get("/significators", response_class=HTMLResponse)
|
||||||
|
def significators_form(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/significators", response_class=HTMLResponse)
|
||||||
|
def significators_search(
|
||||||
request: Request,
|
request: Request,
|
||||||
query: str = Form(...),
|
query: str = Form(...),
|
||||||
field: str = Form("name"),
|
field: str = Form("name"),
|
||||||
@@ -37,8 +92,8 @@ def search(
|
|||||||
try:
|
try:
|
||||||
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
ctx["error"] = f"Warstwa logiczna niedostępna: {e}"
|
ctx["error"] = _logic_error(e)
|
||||||
return templates.TemplateResponse(request, "index.html", ctx)
|
return templates.TemplateResponse(request, "significators.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ body {
|
|||||||
main { width: 100%; max-width: 880px; }
|
main { width: 100%; max-width: 880px; }
|
||||||
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||||
|
|
||||||
|
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
|
||||||
|
.topbar nav { display: flex; gap: .25rem; }
|
||||||
|
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
|
||||||
|
.topbar nav a:hover { color: var(--ink); }
|
||||||
|
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
|
||||||
|
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
|
||||||
|
|
||||||
|
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||||
|
.grid input { width: 100%; }
|
||||||
|
.loc { margin-top: .8rem; }
|
||||||
|
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||||
|
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||||
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
.row { display: flex; gap: .5rem; }
|
.row { display: flex; gap: .5rem; }
|
||||||
.row input[type=text] { flex: 1; }
|
.row input[type=text] { flex: 1; }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>astrololo · {% block title %}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>astrololo</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
||||||
|
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
<footer class="foot">
|
||||||
|
prezentacja → logika → dane · widok testowy
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Horoskop{% endblock %}
|
||||||
|
{% block nav_chart %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="sub">Wpisz podstawowe dane momentu — program policzy pozycje obiektów (silnik efemeryd warstwy logicznej).</p>
|
||||||
|
|
||||||
|
<form method="post" action="/">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Data
|
||||||
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Godzina (lokalna)
|
||||||
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Strefa (offset h)
|
||||||
|
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<details class="loc">
|
||||||
|
<summary>Lokalizacja (opcjonalnie — przyda się później do osi i domów)</summary>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Szerokość (lat)
|
||||||
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Długość (lon, + na wschód)
|
||||||
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
|
<button type="submit">Policz pozycje</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
<div class="meta">
|
||||||
|
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||||
|
obiektów: {{ result.positions | length }}
|
||||||
|
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Absolutna</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in result.positions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td>{{ p.sign }}</td>
|
||||||
|
<td class="mono">{{ p.in_sign }}</td>
|
||||||
|
<td class="mono">{{ p.absolute }}</td>
|
||||||
|
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||||
|
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// „Tu i teraz": uzupełnia datę/godzinę bieżącą i offset lokalny przeglądarki.
|
||||||
|
document.getElementById('nowBtn').addEventListener('click', function () {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
document.querySelector('input[name=date]').value =
|
||||||
|
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pl">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>astrololo</title>
|
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>astrololo</h1>
|
|
||||||
<p class="sub">Warstwa prezentacji → logiczna → bazodanowa</p>
|
|
||||||
|
|
||||||
<form method="post" action="/">
|
|
||||||
<div class="row">
|
|
||||||
<input type="text" name="query" placeholder="Szukana fraza…"
|
|
||||||
value="{{ form.query or '' }}" autofocus required>
|
|
||||||
<select name="field">
|
|
||||||
{% for f in ["name", "id", "symbol", "category", "value"] %}
|
|
||||||
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit">Szukaj</button>
|
|
||||||
</div>
|
|
||||||
<div class="opts">
|
|
||||||
<label><input type="checkbox" name="exact" value="true"
|
|
||||||
{{ 'checked' if form.exact else '' }}> dokładne</label>
|
|
||||||
<label>limit
|
|
||||||
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="error">{{ error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if result %}
|
|
||||||
<div class="meta">
|
|
||||||
Znaleziono <strong>{{ result.count }}</strong> ·
|
|
||||||
provider: {{ result.meta.data_provider }} ·
|
|
||||||
cache: {{ result.meta.data_cache }} ·
|
|
||||||
{{ result.meta.data_elapsed_ms }} ms
|
|
||||||
</div>
|
|
||||||
{% if result.results %}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in result.results %}
|
|
||||||
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty">Brak wyników.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Sygnifikatory{% endblock %}
|
||||||
|
{% block nav_sig %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/significators">
|
||||||
|
<div class="row">
|
||||||
|
<input type="text" name="query" placeholder="Szukana fraza…"
|
||||||
|
value="{{ form.query or '' }}" autofocus required>
|
||||||
|
<select name="field">
|
||||||
|
{% for f in ["name", "id", "symbol", "category", "value"] %}
|
||||||
|
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Szukaj</button>
|
||||||
|
</div>
|
||||||
|
<div class="opts">
|
||||||
|
<label><input type="checkbox" name="exact" value="true"
|
||||||
|
{{ 'checked' if form.exact else '' }}> dokładne</label>
|
||||||
|
<label>limit
|
||||||
|
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
<div class="meta">
|
||||||
|
Znaleziono <strong>{{ result.count }}</strong> ·
|
||||||
|
provider: {{ result.meta.data_provider }} ·
|
||||||
|
cache: {{ result.meta.data_cache }} ·
|
||||||
|
{{ result.meta.data_elapsed_ms }} ms
|
||||||
|
</div>
|
||||||
|
{% if result.results %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in result.results %}
|
||||||
|
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">Brak wyników.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user