feat: add honest activity map mode

This commit is contained in:
ik
2026-09-21 19:40:35 +07:00
parent b9aecb75f5
commit 5c1a7ffa01
4 changed files with 54 additions and 3 deletions
+46
View File
@@ -0,0 +1,46 @@
---
import { spotPath, type Activity } from "../lib/api";
const { items } = Astro.props as { items: Activity[] };
const xValues = items.map((item) => item.x);
const yValues = items.map((item) => item.y);
const minX = Math.min(...xValues, 0);
const maxX = Math.max(...xValues, 1);
const minY = Math.min(...yValues, 0);
const maxY = Math.max(...yValues, 1);
const xRange = Math.max(1, maxX - minX);
const yRange = Math.max(1, maxY - minY);
const column = (value: number) => Math.min(20, Math.max(1, Math.round(((value - minX) / xRange) * 18) + 1));
const row = (value: number) => Math.min(20, Math.max(1, Math.round((1 - (value - minY) / yRange) * 18) + 1));
---
<section class="activity-map" aria-label="Схема координат горячих точек">
<header class="activity-map__header">
<div><span class="overline">Режим схемы</span><h3>Горячие точки на координатной сетке</h3></div>
<p>Показаны подтверждённые координаты текущей выборки. Это схема `x:y`, а не географическая карта водоёма.</p>
</header>
<div class="activity-map__plot" role="group" aria-label={`Координатная схема, точек: ${items.length}`}>
<span class="activity-map__axis activity-map__axis--x" aria-hidden="true">x →</span>
<span class="activity-map__axis activity-map__axis--y" aria-hidden="true">y ↑</span>
{items.map((item) => <a class:list={["activity-map__point", `activity-map__point--col-${column(item.x)}`, `activity-map__point--row-${row(item.y)}`]} href={spotPath(item)} aria-label={`${item.waterbody}, ${item.x}:${item.y}, ${item.fish}`}><span>{item.x}:{item.y}</span><strong>{item.fish}</strong></a>)}
</div>
</section>
<style>
.activity-map { display: grid; gap: 18px; }
.activity-map__header { display: flex; justify-content: space-between; gap: 22px; align-items: end; }
.activity-map__header h3 { margin: 6px 0 0; font: 400 27px Georgia, serif; letter-spacing: -.03em; }
.activity-map__header p { max-width: 330px; margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; }
.activity-map__plot { position: relative; display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); grid-template-rows: repeat(20, minmax(0, 1fr)); min-height: 470px; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: linear-gradient(90deg, color-mix(in srgb, var(--accent-muted) 8%, transparent) 1px, transparent 1px), linear-gradient(color-mix(in srgb, var(--accent-muted) 8%, transparent) 1px, transparent 1px), var(--surface-soft); background-size: 10% 10%; }
.activity-map__plot::after { content: ""; position: absolute; inset: 8%; border: 1px dashed color-mix(in srgb, var(--accent-muted) 40%, transparent); border-radius: 12px; pointer-events: none; }
.activity-map__axis { position: absolute; z-index: 1; color: var(--text-subtle); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.activity-map__axis--x { right: 5%; bottom: 3%; }
.activity-map__axis--y { left: 2%; top: 4%; writing-mode: vertical-rl; transform: rotate(180deg); }
.activity-map__point { position: relative; z-index: 2; display: grid; align-self: center; justify-self: center; gap: 3px; min-width: 74px; margin: 5px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); border-radius: 12px; background: var(--surface); color: var(--text); text-decoration: none; box-shadow: 0 8px 18px color-mix(in srgb, var(--deep) 11%, transparent); }
.activity-map__point::before { content: ""; position: absolute; left: 50%; bottom: -6px; width: 10px; height: 10px; border-right: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); border-bottom: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); background: var(--surface); transform: translateX(-50%) rotate(45deg); }
.activity-map__point:hover, .activity-map__point:focus-visible { border-color: var(--focus); transform: scale(1.03); }
.activity-map__point span { color: var(--text-muted); font-size: 10px; font-weight: 750; }
.activity-map__point strong { max-width: 130px; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
:global(.activity-map__point--col-1) { grid-column: 1; } :global(.activity-map__point--col-2) { grid-column: 2; } :global(.activity-map__point--col-3) { grid-column: 3; } :global(.activity-map__point--col-4) { grid-column: 4; } :global(.activity-map__point--col-5) { grid-column: 5; } :global(.activity-map__point--col-6) { grid-column: 6; } :global(.activity-map__point--col-7) { grid-column: 7; } :global(.activity-map__point--col-8) { grid-column: 8; } :global(.activity-map__point--col-9) { grid-column: 9; } :global(.activity-map__point--col-10) { grid-column: 10; } :global(.activity-map__point--col-11) { grid-column: 11; } :global(.activity-map__point--col-12) { grid-column: 12; } :global(.activity-map__point--col-13) { grid-column: 13; } :global(.activity-map__point--col-14) { grid-column: 14; } :global(.activity-map__point--col-15) { grid-column: 15; } :global(.activity-map__point--col-16) { grid-column: 16; } :global(.activity-map__point--col-17) { grid-column: 17; } :global(.activity-map__point--col-18) { grid-column: 18; } :global(.activity-map__point--col-19) { grid-column: 19; } :global(.activity-map__point--col-20) { grid-column: 20; }
:global(.activity-map__point--row-1) { grid-row: 1; } :global(.activity-map__point--row-2) { grid-row: 2; } :global(.activity-map__point--row-3) { grid-row: 3; } :global(.activity-map__point--row-4) { grid-row: 4; } :global(.activity-map__point--row-5) { grid-row: 5; } :global(.activity-map__point--row-6) { grid-row: 6; } :global(.activity-map__point--row-7) { grid-row: 7; } :global(.activity-map__point--row-8) { grid-row: 8; } :global(.activity-map__point--row-9) { grid-row: 9; } :global(.activity-map__point--row-10) { grid-row: 10; } :global(.activity-map__point--row-11) { grid-row: 11; } :global(.activity-map__point--row-12) { grid-row: 12; } :global(.activity-map__point--row-13) { grid-row: 13; } :global(.activity-map__point--row-14) { grid-row: 14; } :global(.activity-map__point--row-15) { grid-row: 15; } :global(.activity-map__point--row-16) { grid-row: 16; } :global(.activity-map__point--row-17) { grid-row: 17; } :global(.activity-map__point--row-18) { grid-row: 18; } :global(.activity-map__point--row-19) { grid-row: 19; } :global(.activity-map__point--row-20) { grid-row: 20; }
@media (max-width: 720px) { .activity-map__header { display: grid; gap: 8px; align-items: start; } .activity-map__plot { min-height: 390px; } .activity-map__point { min-width: 62px; padding: 7px 8px; } .activity-map__point strong { max-width: 90px; } }
</style>
+4 -1
View File
@@ -1,6 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro";
import ActivityMap from "../components/ActivityMap.astro";
import FishingIcon from "../components/FishingIcon.astro";
import SourceBadge from "../components/SourceBadge.astro";
import TackleGlyph from "../components/TackleGlyph.astro";
@@ -14,6 +15,7 @@ const hours = params.get("hours") ?? "24";
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
const sort = params.get("sort") ?? "activity";
const view = params.get("view") === "map" ? "map" : "list";
const activityLimit = 5;
const requestedOffset = Number(params.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
@@ -73,6 +75,7 @@ const queryTitle = `${selectedFish} · ${selectedWaterbody}`;
const moreSignalParams = new URLSearchParams(params);
moreSignalParams.set("signals", String(Math.min(48, signalLimit + 12)));
const moreSignalsHref = `/?${moreSignalParams.toString()}#signals-title`;
const viewHref = (next: "list" | "map") => { const query = new URLSearchParams(params); query.set("view", next); query.delete("offset"); return `/?${query.toString()}#results`; };
const datasetJsonLd = {
"@context": "https://schema.org", "@type": "Dataset",
name: "RF4 Spotter — наблюдения об активности рыбы",
@@ -94,7 +97,7 @@ const datasetJsonLd = {
<button data-action="primary">⌕ Найти клёв</button>
</form></section>
<section class="query-summary content-grid" aria-label="Контекст запроса" aria-live="polite"><div><span class="overline">Ваш запрос</span><h2 id="query-summary-title">{queryTitle}</h2><p>{periodLabel} · {sortLabel}</p></div><div class="query-summary__filters" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить запрос</a>}</div></section>
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} на странице · всего {totalItems}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} periodLabel={periodLabel} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p>{filtersChanged && <a data-action="secondary" href="/#results">Показать все данные</a>}</div>}</div>
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><div class="result-tools"><span class="result-count">{items.length} на странице · всего {totalItems}</span><nav class="view-toggle" aria-label="Режим отображения"><a class:list={{ "view-toggle__active": view === "list" }} href={viewHref("list")} aria-current={view === "list" ? "page" : undefined}>Список</a><a class:list={{ "view-toggle__active": view === "map" }} href={viewHref("map")} aria-current={view === "map" ? "page" : undefined}>Схема</a></nav></div></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? (view === "map" ? <ActivityMap items={items} /> : <div class="spot-list">{items.map(item => <ActivityCard item={item} periodLabel={periodLabel} />)}</div>) : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p>{filtersChanged && <a data-action="secondary" href="/#results">Показать все данные</a>}</div>}</div>
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
</section>
{!filterError && !activityUnavailable && <Pagination path="/" params={params} total={totalItems} limit={activityLimit} offset={offset} anchor="#results" itemLabel="точек" />}
+2
View File
@@ -1 +1,3 @@
.dashboard:has(>.results-column:only-child){grid-template-columns:1fr}
.result-tools{display:flex;align-items:center;gap:10px}.view-toggle{display:flex;padding:3px;border:1px solid var(--border);border-radius:999px;background:var(--surface-soft)}.view-toggle a{padding:7px 10px;border-radius:999px;color:var(--text-muted);font-size:11px;font-weight:750;text-decoration:none}.view-toggle a:hover,.view-toggle__active{background:var(--surface);color:var(--text)!important;box-shadow:0 2px 8px color-mix(in srgb,var(--deep) 10%,transparent)}
@media(max-width:720px){.section-heading{align-items:start}.result-tools{display:grid;justify-items:end;gap:7px}.view-toggle a{padding:6px 9px}}