Compare commits

...
3 Commits
Author SHA1 Message Date
ik cadd5607b8 feat: compose atlas identity for fishing pairs
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-12 15:37:54 +07:00
ik 2ce1e20af2 feat: add unique waterbody fingerprints 2026-09-12 15:35:57 +07:00
ik 80d534d549 feat: add semantic tackle glyphs 2026-09-12 15:30:40 +07:00
21 changed files with 154 additions and 21 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены резкие favicon/app icons из SVG-мастера, отдельные полнофоновые maskable-иконки, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества.
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют SVG-силуэты, карточки водоёмов — абстрактные береговые контуры и точку наблюдения. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
+2 -1
View File
@@ -4,6 +4,7 @@ import { activityLevel, ago, kg, plural, spotPath } from "../lib/api";
import FishingIcon from "./FishingIcon.astro";
import DataPassport from "./DataPassport.astro";
import FishSilhouette from "./FishSilhouette.astro";
import TackleGlyph from "./TackleGlyph.astro";
const { item } = Astro.props as { item: Activity };
const level = activityLevel(item.activity_score);
const limited = item.catches < 3;
@@ -17,7 +18,7 @@ const limited = item.catches < 3;
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
<p class="data-note">{item.explanation}</p>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/>
<div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
</div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
</a>
@@ -0,0 +1,25 @@
---
import FishSilhouette from "./FishSilhouette.astro";
import WaterbodyMark from "./WaterbodyMark.astro";
const { waterbody, fish } = Astro.props as { waterbody: string; fish: string };
---
<span class="atlas-pair-mark" aria-hidden="true">
<span class="atlas-pair-mark__water"><WaterbodyMark identity={waterbody} size={104} /></span>
<span class="atlas-pair-mark__fish"><FishSilhouette name={fish} size={78} /></span>
<i>×</i>
</span>
<style>
.atlas-pair-mark { position: relative; display: block; width: 128px; height: 74px; }
.atlas-pair-mark__water { position: absolute; inset: 15px auto auto 0; color: var(--lime); }
.atlas-pair-mark__water :global(.waterbody-mark) { position: static; opacity: .72; stroke: currentColor; }
.atlas-pair-mark__water :global(.waterbody-mark text) { fill: var(--white); opacity: .8; }
.atlas-pair-mark__fish { position: absolute; right: -2px; top: -6px; color: var(--white); filter: drop-shadow(0 3px 7px #06191c); }
.atlas-pair-mark__fish :global(.fish-silhouette) { position: static; opacity: .92; }
i { position: absolute; right: 2px; bottom: 0; width: 18px; height: 18px; display: grid; place-items: center; border-radius: 50%; background: var(--lime); color: var(--deep); font: 800 11px/1 Inter, sans-serif; }
@media (max-width: 720px) {
.atlas-pair-mark { width: 94px; height: 55px; transform: scale(.73); transform-origin: center; }
}
</style>
+2 -1
View File
@@ -1,13 +1,14 @@
---
import SourceBadge from "./SourceBadge.astro";
import { ago, kg, type Catch } from "../lib/api";
import TackleGlyph from "./TackleGlyph.astro";
const { catches } = Astro.props as { catches: Catch[] };
---
<div class="catch-list">
{catches.map(item => {
const timestamp = item.caught_at ?? item.reported_at;
return <article>
<div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span><SourceBadge source={item.source_system} href={item.source_url}/></div>
<div><strong>{item.fish}</strong><span class="tackle-label"><TackleGlyph name={item.bait} size={24}/><span>{item.bait ?? "Приманка не указана"}</span></span><SourceBadge source={item.source_system} href={item.source_url}/></div>
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><span>{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}</span><time datetime={timestamp}>{ago(timestamp)} · {new Date(timestamp).toLocaleString("ru-RU", { timeZone: "UTC" })} UTC</time></div>
</article>;
})}
+4 -3
View File
@@ -1,9 +1,10 @@
---
import FishingIcon from "./FishingIcon.astro";
import FishSilhouette from "./FishSilhouette.astro";
const { eyebrow, title, description, variant, count } = Astro.props as { eyebrow: string; title: string; description?: string; variant?: "water" | "fish"; count?: number };
import WaterbodyMark from "./WaterbodyMark.astro";
import AtlasPairMark from "./AtlasPairMark.astro";
const { eyebrow, title, description, variant, count, identity = title, secondaryIdentity = "waterbody" } = Astro.props as { eyebrow: string; title: string; description?: string; variant?: "water" | "fish" | "pair"; count?: number; identity?: string; secondaryIdentity?: string };
---
<section class:list={["catalog-hero content-grid", { "catalog-hero--illustrated": variant }]}>
<div class="catalog-hero__copy"><span class="overline">{eyebrow}</span><h1>{title}</h1>{description && <p>{description}</p>}</div>
{variant && <div class="catalog-hero__seal" aria-hidden="true"><span>{variant === "water" ? <FishingIcon name="ripple" size={54} /> : <FishSilhouette name={title} size={92} />}</span>{typeof count === "number" && <><strong>{String(count).padStart(2,"0")}</strong><small>{variant === "water" ? "водоёмов" : "видов рыб"}</small></>}</div>}
{variant && <div class:list={["catalog-hero__seal", { "catalog-hero__seal--pair": variant === "pair" }]} aria-hidden="true"><span>{variant === "water" ? <WaterbodyMark identity={identity} size={94} /> : variant === "pair" ? <AtlasPairMark waterbody={secondaryIdentity} fish={identity} /> : <FishSilhouette name={identity} size={92} />}</span>{typeof count === "number" && <><strong>{String(count).padStart(2,"0")}</strong><small>{variant === "water" ? "водоёмов" : "видов рыб"}</small></>}</div>}
</section>
+15
View File
@@ -0,0 +1,15 @@
---
import { tackleVisualKind, tackleVisualTone, type TackleVisualKind } from "../lib/tackle-visuals";
const { name, size = 28, kind: kindOverride } = Astro.props as { name?: string | null; size?: number; kind?: TackleVisualKind };
const kind = kindOverride ?? tackleVisualKind(name);
const tone = tackleVisualTone(name);
---
<svg class="tackle-glyph" data-kind={kind} data-tone={tone} width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden="true">
{kind === "spinner" && <><path d="M17 4v7"/><path class="tackle-glyph__fill" d="M17 10c7 1 8 7 2 11-5 3-9-2-7-6 1-3 3-4 5-5Z"/><path d="M18 21v4c0 4-5 4-5 0"/></>}
{kind === "wobbler" && <><path class="tackle-glyph__fill" d="M5 15c5-6 14-7 21-2-4 8-13 9-21 2Z"/><circle cx="22" cy="13" r="1"/><path d="m8 18-2 5m11-3-1 5m-1 0c0 3-4 3-4 0m10-2c0 3-4 3-4 0"/></>}
{kind === "soft" && <><path class="tackle-glyph__fill" d="M4 17c6-7 14-7 20-2l5-5-1 10-5-3c-7 5-14 5-19 0Z"/><circle cx="8" cy="15" r="1"/></>}
{kind === "boilie" && <><circle class="tackle-glyph__fill" cx="11" cy="16" r="6"/><circle class="tackle-glyph__fill" cx="22" cy="16" r="6"/><path d="M5 8c8 3 14 3 22 0"/></>}
{kind === "worm" && <><path class="tackle-glyph__stroke" d="M5 22c0-11 8-14 12-7s10 3 10-5"/><path d="m23 7 4 3-4 2"/></>}
{kind === "rig" && <><path d="M16 3v17c0 7-10 8-11 1-1-4 3-6 6-4"/><path d="m8 17 3 0-1 3"/><circle class="tackle-glyph__fill" cx="16" cy="7" r="3"/></>}
{kind === "unknown" && <><path class="tackle-glyph__fill" d="M16 4c5 5 8 10 7 16-1 5-5 8-9 6-5-2-6-7-3-11 2-3 4-6 5-11Z"/><path d="M15 8c4 4 5 8 3 12"/></>}
</svg>
+20 -6
View File
@@ -1,15 +1,29 @@
---
const { name, size = 112 } = Astro.props as { name: string; size?: number };
const variant = [...name].reduce((sum, char) => sum + (char.codePointAt(0) ?? 0), 0) % 3;
import { waterbodyVisual } from "../lib/waterbody-visuals";
const { identity, size = 112 } = Astro.props as { identity: string; size?: number };
const visual = waterbodyVisual(identity);
const shores = [
"M8 57C22 42 34 48 47 34S75 20 91 34s16 7 25-3",
"M7 48c17-20 34 5 50-10s28-15 38 1 13 14 22 9",
"M5 54c14-4 20-24 39-18s27-9 40-14 20 11 33 10",
"M7 42c14 8 25-17 42-8s23 16 35 2 20-13 34-6",
"M6 51c16-24 31 7 47-8s32-17 41 3 14 11 24 4",
"M8 38c19-8 26 19 46 8s30-25 42-8 14 11 22 7",
"M5 56c11-19 25-20 39-5s25-1 37-16 25-10 37 2",
"M6 47c18 15 31-18 50-5s27 9 37-5 17-15 27-5",
];
---
<svg class="waterbody-mark" width={size} height={Math.round(size * .62)} viewBox="0 0 124 72" aria-hidden="true">
<path class="waterbody-mark__shore" d={shores[variant]} />
<path d="M12 61c18-7 31 6 49-2s31-6 51 0" />
<path d="M24 68c13-4 25 3 39-2s25-3 37 0" />
<circle cx={variant === 1 ? 82 : 51} cy={variant === 2 ? 25 : 30} r="3" />
<path class="waterbody-mark__shore" d={shores[visual.shore]} />
{visual.waves >= 1 && <path d="M12 59c18-7 31 6 49-2s31-6 51 0" />}
{visual.waves >= 2 && <path d="M20 65c14-5 27 4 42-2s27-4 43 0" />}
{visual.waves >= 3 && <path d="M31 70c11-3 21 2 32-1s20-2 30 0" />}
<circle cx={visual.markerX} cy={visual.markerY} r="3" />
<text x="111" y="15" text-anchor="end">{visual.code}</text>
</svg>
<style>
text { fill: currentColor; stroke: none; font: 700 8px Inter, sans-serif; letter-spacing: .08em; }
:global(.catalog-hero__seal) .waterbody-mark { position: static; right: auto; bottom: auto; opacity: 1; color: var(--lime); stroke: currentColor; }
:global(.catalog-hero__seal) text { fill: var(--white); }
</style>
+1
View File
@@ -7,6 +7,7 @@ import "../styles/data-legend.css";
import "../styles/coordinate-radar.css";
import "../styles/activity-timeline.css";
import "../styles/fish-silhouette.css";
import "../styles/tackle-glyph.css";
import "../styles/empty-states.css";
import "../styles/alpha-banner.css";
import "../styles/signal-pagination.css";
+20
View File
@@ -0,0 +1,20 @@
export type TackleVisualKind = "spinner" | "wobbler" | "soft" | "boilie" | "worm" | "rig" | "unknown";
const KIND_TERMS: Array<[TackleVisualKind, RegExp]> = [
["spinner", /spinner|spiker|spoon|блесн|вращал|колеб|вертуш|spinner|blinker/i],
["wobbler", /wobbler|воблер|minnow|crank|popper|jerk|walker|plug/i],
["soft", /shad|twister|твистер|силикон|soft|vibro|виброхвост/i],
["boilie", /бойл|boilie|pellet|пеллет|pop[- ]?up/i],
["worm", /черв|worm|мотыл|опарыш|личин|maggot|bloodworm/i],
["rig", /оснаст|монтаж|rig|hook|крюч|leader|повод/i],
];
export function tackleVisualKind(name?: string | null): TackleVisualKind {
if (!name?.trim()) return "unknown";
return KIND_TERMS.find(([, pattern]) => pattern.test(name))?.[0] ?? "unknown";
}
export function tackleVisualTone(name?: string | null): number {
return [...(name?.trim().toLocaleLowerCase("ru") || "unknown")]
.reduce((sum, char) => (sum + (char.codePointAt(0) ?? 0)) % 5, 0);
}
+23
View File
@@ -0,0 +1,23 @@
export type WaterbodyVisual = {
shore: number;
waves: number;
markerX: number;
markerY: number;
code: string;
};
export function stableVisualHash(identity: string): number {
return [...identity.trim().toLocaleLowerCase("ru")]
.reduce((hash, char) => Math.imul(hash ^ (char.codePointAt(0) ?? 0), 16777619) >>> 0, 2166136261);
}
export function waterbodyVisual(identity: string): WaterbodyVisual {
const hash = stableVisualHash(identity || "waterbody");
return {
shore: hash % 8,
waves: 1 + ((hash >>> 4) % 3),
markerX: 32 + ((hash >>> 8) % 59),
markerY: 18 + ((hash >>> 15) % 23),
code: hash.toString(36).toUpperCase().padStart(2, "0").slice(-2),
};
}
+1 -1
View File
@@ -22,6 +22,6 @@ const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage
---
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
<a class="back" href="/fish">← Все виды рыб</a>
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} />
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <a href={`/waterbodies/${waterSlug}/${fish!.slug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
</Layout>
+2 -1
View File
@@ -3,6 +3,7 @@ import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro";
import FishingIcon from "../components/FishingIcon.astro";
import SourceBadge from "../components/SourceBadge.astro";
import TackleGlyph from "../components/TackleGlyph.astro";
import SignalFeed from "../components/SignalFeed.astro";
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
@@ -70,7 +71,7 @@ const datasetJsonLd = {
</form></section>
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
<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} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></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="float-gauge" style={`--level:${items[0].activity_score}%`} 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><FishingIcon name="lure" size={25}/><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>}
{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="float-gauge" style={`--level:${items[0].activity_score}%`} 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>
{signals.length > 0 && <SignalFeed signals={signals}/>}
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
+2 -1
View File
@@ -3,6 +3,7 @@ import Layout from "../layouts/Layout.astro";
import SourceBadge from "../components/SourceBadge.astro";
import SectionHeading from "../components/SectionHeading.astro";
import StatePanel from "../components/StatePanel.astro";
import TackleGlyph from "../components/TackleGlyph.astro";
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api";
const params = Astro.url.searchParams;
const fish = params.get("fish") ?? "";
@@ -25,7 +26,7 @@ const last = runs[0];
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} из ${totalRecords} записей`} /></div>
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
{!unavailable && items.length && offset + items.length < totalRecords && <a class="load-more" data-action="secondary" href={`/records?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё <span>{offset + items.length} из {totalRecords}</span> ↓</a>}
{!unavailable && !items.length && <StatePanel title="Рекорды ещё не импортированы" description="Для выбранных условий записей пока нет." actionHref="/records" actionLabel="Сбросить фильтры" />}
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
+2 -1
View File
@@ -2,6 +2,7 @@
import Layout from "../../layouts/Layout.astro";
import SourceBadge from "../../components/SourceBadge.astro";
import CoordinateRadar from "../../components/CoordinateRadar.astro";
import TackleGlyph from "../../components/TackleGlyph.astro";
import ActivityTimeline from "../../components/ActivityTimeline.astro";
import CatchList from "../../components/CatchList.astro";
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
@@ -41,7 +42,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
<div class="section-heading"><h2>Последние уловы</h2></div>
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
</div>
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
</section>
</>}
</Layout>
+1 -1
View File
@@ -22,6 +22,6 @@ const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPag
---
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
<a class="back" href="/waterbodies">← Все водоёмы</a>
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} />
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <a href={`/waterbodies/${water!.slug}/${fishSlug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
</Layout>
@@ -22,6 +22,6 @@ const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на $
---
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid || unavailable} structuredData={schema} errorPage={!valid || unavailable}>
<a class="back" href={water ? `/waterbodies/${water.slug}` : "/waterbodies"}>← К водоёму</a>
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${items.length} ${plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.` : undefined} />
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${items.length} ${plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.` : undefined} variant={valid ? "pair" : undefined} identity={fish?.name_ru} secondaryIdentity={water?.slug} />
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
</Layout>
+1 -1
View File
@@ -14,5 +14,5 @@ if (unavailable) {
---
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных.">
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={waters.length} />
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark name={water.name_ru}/></a>)}</nav>}
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>}
</Layout>
+1
View File
@@ -0,0 +1 @@
.tackle-glyph{--tackle-accent:#dd8b42;flex:none;color:var(--tackle-accent);stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.tackle-glyph[data-tone="1"]{--tackle-accent:#759d35}.tackle-glyph[data-tone="2"]{--tackle-accent:#3f8588}.tackle-glyph[data-tone="3"]{--tackle-accent:#9a6a9d}.tackle-glyph[data-tone="4"]{--tackle-accent:#bd6250}.tackle-glyph__fill{fill:color-mix(in srgb,currentColor 78%,var(--surface));stroke:currentColor}.tackle-glyph__stroke{stroke-width:3}.tackle-label{display:inline-flex;align-items:center;gap:8px;min-width:0}.tackle-label>span{min-width:0}.best-lure .tackle-glyph{color:var(--lime)}.bait-line>.tackle-glyph{width:25px;height:25px}.catch-list .tackle-label{margin-top:3px;color:var(--text-muted);font-size:12px}.detail-grid aside li .tackle-label{width:100%}
+21
View File
@@ -3,6 +3,8 @@ import test from "node:test";
import { activityLevel, plural } from "../../src/lib/presentation.ts";
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
test("activity levels share one complete 0-100 scale", () => {
assert.deepEqual(
@@ -25,3 +27,22 @@ test("fish visual families follow body type instead of a name hash", () => {
["pike", "salmonid", "cyprinid", "perch", "catfish", "eel", "flatfish", "marine", "generic"],
);
});
test("tackle glyphs only infer a type from explicit name terms", () => {
assert.deepEqual(
["Spiker #2", "Minnow 80F", "Salmon T1 Shad", "Клубничный бойл", "Червь", "Карповый монтаж", "Model 42", null].map(tackleVisualKind),
["spinner", "wobbler", "soft", "boilie", "worm", "rig", "unknown", "unknown"],
);
assert.equal(tackleVisualTone("Spiker #2"), tackleVisualTone("spiker #2"));
});
test("waterbody fingerprints are stable, bounded and entity-specific", () => {
const kuori = waterbodyVisual("kuori");
assert.deepEqual(kuori, waterbodyVisual(" KUORI "));
assert.notDeepEqual(kuori, waterbodyVisual("vyunok"));
assert.ok(kuori.shore >= 0 && kuori.shore < 8);
assert.ok(kuori.waves >= 1 && kuori.waves <= 3);
assert.ok(kuori.markerX >= 32 && kuori.markerX <= 90);
assert.ok(kuori.markerY >= 18 && kuori.markerY <= 40);
assert.match(kuori.code, /^[0-9A-Z]{2}$/);
});
+3 -2
View File
@@ -28,9 +28,10 @@
- [x] **B06 · Brand QA.** Desktop/mobile-проверка усилила каталоги atlas-hero, счётчиками, береговыми контурами и выразительными карточками. Favicon/PWA PNG перегенерированы из SVG-мастера без размытия; `any` и полнофоновые `maskable`-иконки разделены, manifest и Caddy cache обновлены, OG подтверждён как 1200×630.
- [x] **B07 · Аудит официальных изображений.** Текущий records-parser получает названия рыб/водоёмов и `title` приманки, но не image URL; официальные gallery/userguide содержат тематические медиа без устойчивого полного соответствия каноническим сущностям. Изображения не хотлинкать и не считать разрешение на данные автоматическим разрешением на медиапубликацию.
- [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором.
- [ ] **B09 · Глифы снастей и приманок.** Ввести компактные SVG-глифы по типу (`spinner`, `wobbler`, `soft`, `boilie`, `worm`, `hook`, `rig`, `unknown`) и детерминированный цвет по normalized name. Не изображать конкретную модель, если известна только строка из рекорда.
- [ ] **B10 · Визуальные отпечатки водоёмов.** Расширить текущие три береговых контура до детерминированного набора по slug: форма берега, число волн, положение точки и индексный код. Явно обозначать их как абстрактный знак, не карту и не игровую географию.
- [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель.
- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география.
- [ ] **B11 · Опциональный media pipeline.** После отдельного письменного разрешения на изображения добавить allowlist официальных asset URL, локальное сохранение без hotlink, content hash, MIME/dimensions, attribution/source URL, дату получения и fallback на SVG. Изменение/удаление оригинала должно отправлять media asset на повторную проверку, а не молча менять публичную карточку.
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления.
- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки.
+6
View File
@@ -56,6 +56,12 @@ CTA задаются смыслом через `data-action`: `primary` — ос
Силуэт рыбы выбирается по узнаваемому типу тела, а не случайному hash имени: щуковые, лососёвые, карповые, окуневые, сомовые, угреобразные, плоские и морские. Классификатор поддерживает русские и основные названия источников, неизвестная сущность получает нейтральную форму. Явный `family` имеет приоритет над эвристикой и служит точкой ручной коррекции.
Приманки различаются типовым контуром только при явном указании категории в названии: блесна, воблер, мягкая приманка, бойл, естественная наживка или оснастка. Неизвестное название получает нейтральную каплевидную форму. Один из пяти спокойных акцентных цветов детерминирован normalized name и помогает повторно узнавать строку, но не кодирует эффективность, качество или игровую характеристику.
Водоём получает воспроизводимый абстрактный отпечаток по каноническому slug: один из восьми контуров берега, от одной до трёх линий воды, положение точки и двухсимвольный индекс. Он помогает узнавать сущность между каталогом и detail-страницей, но не изображает карту, реальные координаты или форму игрового водоёма. Текстовое название всегда остаётся главным идентификатором.
На странице сочетания два знака образуют одну атласную эмблему: отпечаток водоёма служит нижним слоем, силуэт рыбы — верхним, а маленький знак пересечения подчёркивает смысл «вид на конкретном водоёме». Эмблема декоративна; названия обеих сущностей остаются в текстовой и навигационной иерархии.
## Brand assets и QA
`favicon.svg` — мастер обычных favicon, Apple Touch и PWA `purpose: any`; PNG генерируются из него напрямую в целевом размере, а не масштабируются из малого растра. `icon-maskable.svg` — полнофоновый мастер для отдельных `purpose: maskable` 192/512 px, чтобы системная маска не открывала прозрачные углы. OG имеет ровно 1200×630 px и использует тот же тёмный водный фон, лаймовый поплавок и координатную сетку. Проверены desktop и 390 px каталоги, 32/180/192/512 px assets, светлая поверхность сайта и тёмные бренд-носители.