diff --git a/apps/web/src/components/ActivityCard.astro b/apps/web/src/components/ActivityCard.astro index 2b83051..299c5c1 100644 --- a/apps/web/src/components/ActivityCard.astro +++ b/apps/web/src/components/ActivityCard.astro @@ -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;
{item.x}:{item.y} {ago(item.last_confirmed_at)}

{item.explanation}

-
Работает сейчас{item.best_bait ?? "не указана"}
+
Работает сейчас{item.best_bait ?? "не указана"}
{item.catches}{plural(item.catches, ["улов", "улова", "уловов"])}
{item.unique_players}{plural(item.unique_players, ["игрок", "игрока", "игроков"])}
{kg(item.average_weight_g)}средний вес
{item.confidence_score}%уверенность
diff --git a/apps/web/src/components/CatchList.astro b/apps/web/src/components/CatchList.astro index e7dd242..ca76e85 100644 --- a/apps/web/src/components/CatchList.astro +++ b/apps/web/src/components/CatchList.astro @@ -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[] }; ---
{catches.map(item => { const timestamp = item.caught_at ?? item.reported_at; return
-
{item.fish}{item.bait ?? "Приманка не указана"}
+
{item.fish}{item.bait ?? "Приманка не указана"}
{kg(item.weight_g)}{item.player_name ?? "Анонимно"}{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}
; })} diff --git a/apps/web/src/components/TackleGlyph.astro b/apps/web/src/components/TackleGlyph.astro new file mode 100644 index 0000000..be5c5e6 --- /dev/null +++ b/apps/web/src/components/TackleGlyph.astro @@ -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); +--- + diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 7d32f07..c910741 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -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"; diff --git a/apps/web/src/lib/tackle-visuals.ts b/apps/web/src/lib/tackle-visuals.ts new file mode 100644 index 0000000..b3cb43d --- /dev/null +++ b/apps/web/src/lib/tackle-visuals.ts @@ -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); +} diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index 6ce3702..aed692a 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -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 = {
{selectedWaterbody}{selectedFish}{periodLabel}{sortLabel}{filtersChanged && Сбросить}
За выбранный период

Горячие точки

{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки. Попробуйте позже.

: items.length ? <>
{items.map(item => )}
{offset + items.length < totalItems && { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё {offset + items.length} из {totalItems}} :

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
- {items[0] && leaderLevel && } + {items[0] && leaderLevel && }
{signals.length > 0 && } {hasMoreSignals && signalLimit < 48 && Показать ещё {signalLimit} из доступных} diff --git a/apps/web/src/pages/records.astro b/apps/web/src/pages/records.astro index ddfd0d9..342244e 100644 --- a/apps/web/src/pages/records.astro +++ b/apps/web/src/pages/records.astro @@ -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];
{(fish || waterbody) && Сбросить}
{unavailable && } - {!unavailable && items.length &&
РыбаВесВодоёмПриманкаИгрокДата и источник
{items.map(record =>
{record.fish}{kg(record.weight_g)}{record.waterbody}{record.bait ?? "—"}{record.player_name ?? "—"}
)}
} + {!unavailable && items.length &&
РыбаВесВодоёмПриманкаИгрокДата и источник
{items.map(record =>
{record.fish}{kg(record.weight_g)}{record.waterbody}{record.bait ?? "—"}{record.player_name ?? "—"}
)}
} {!unavailable && items.length && offset + items.length < totalRecords && { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё {offset + items.length} из {totalRecords}} {!unavailable && !items.length && }

Источник: официальный сайт Russian Fishing 4. Координаты в официальных таблицах отсутствуют.

diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index d745afa..ad849d9 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -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

Последние уловы

{catches.length ? :

Уловов пока нет

Для этой точки нет одобренных наблюдений.

}
- + } diff --git a/apps/web/src/styles/tackle-glyph.css b/apps/web/src/styles/tackle-glyph.css new file mode 100644 index 0000000..d4b8efe --- /dev/null +++ b/apps/web/src/styles/tackle-glyph.css @@ -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%} diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts index 8f32cbf..0a00857 100644 --- a/apps/web/tests/unit/presentation.test.ts +++ b/apps/web/tests/unit/presentation.test.ts @@ -3,6 +3,7 @@ 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"; test("activity levels share one complete 0-100 scale", () => { assert.deepEqual( @@ -25,3 +26,11 @@ 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")); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 962aa73..d3c5a8b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,7 +28,7 @@ - [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. Не изображать конкретную модель, если известна только строка из рекорда. +- [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель. - [ ] **B10 · Визуальные отпечатки водоёмов.** Расширить текущие три береговых контура до детерминированного набора по slug: форма берега, число волн, положение точки и индексный код. Явно обозначать их как абстрактный знак, не карту и не игровую географию. - [ ] **B11 · Опциональный media pipeline.** После отдельного письменного разрешения на изображения добавить allowlist официальных asset URL, локальное сохранение без hotlink, content hash, MIME/dimensions, attribution/source URL, дату получения и fallback на SVG. Изменение/удаление оригинала должно отправлять media asset на повторную проверку, а не молча менять публичную карточку. diff --git a/docs/brand-system.md b/docs/brand-system.md index d5c16a9..defc746 100644 --- a/docs/brand-system.md +++ b/docs/brand-system.md @@ -56,6 +56,8 @@ CTA задаются смыслом через `data-action`: `primary` — ос Силуэт рыбы выбирается по узнаваемому типу тела, а не случайному hash имени: щуковые, лососёвые, карповые, окуневые, сомовые, угреобразные, плоские и морские. Классификатор поддерживает русские и основные названия источников, неизвестная сущность получает нейтральную форму. Явный `family` имеет приоритет над эвристикой и служит точкой ручной коррекции. +Приманки различаются типовым контуром только при явном указании категории в названии: блесна, воблер, мягкая приманка, бойл, естественная наживка или оснастка. Неизвестное название получает нейтральную каплевидную форму. Один из пяти спокойных акцентных цветов детерминирован normalized name и помогает повторно узнавать строку, но не кодирует эффективность, качество или игровую характеристику. + ## 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, светлая поверхность сайта и тёмные бренд-носители.