diff --git a/README.md b/README.md index d41014c..54c75bf 100644 --- a/README.md +++ b/README.md @@ -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 задаются переменными окружения. diff --git a/apps/web/src/components/PageHero.astro b/apps/web/src/components/PageHero.astro index 60c1570..2345ba0 100644 --- a/apps/web/src/components/PageHero.astro +++ b/apps/web/src/components/PageHero.astro @@ -1,9 +1,9 @@ --- -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"; +const { eyebrow, title, description, variant, count, identity = title } = Astro.props as { eyebrow: string; title: string; description?: string; variant?: "water" | "fish"; count?: number; identity?: string }; ---
{eyebrow}

{title}

{description &&

{description}

}
- {variant && } + {variant && }
diff --git a/apps/web/src/components/WaterbodyMark.astro b/apps/web/src/components/WaterbodyMark.astro index 2ce25ed..34bb297 100644 --- a/apps/web/src/components/WaterbodyMark.astro +++ b/apps/web/src/components/WaterbodyMark.astro @@ -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", ]; --- + + diff --git a/apps/web/src/lib/waterbody-visuals.ts b/apps/web/src/lib/waterbody-visuals.ts new file mode 100644 index 0000000..92c7667 --- /dev/null +++ b/apps/web/src/lib/waterbody-visuals.ts @@ -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), + }; +} diff --git a/apps/web/src/pages/fish/[slug].astro b/apps/web/src/pages/fish/[slug].astro index 067739e..d447f9e 100644 --- a/apps/web/src/pages/fish/[slug].astro +++ b/apps/web/src/pages/fish/[slug].astro @@ -22,6 +22,6 @@ const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage --- ← Все виды рыб - + {unavailable ? : !fish ? :
{items.length ? items.map(item => ) : }
}
diff --git a/apps/web/src/pages/waterbodies/[slug].astro b/apps/web/src/pages/waterbodies/[slug].astro index 59c00e1..38193e0 100644 --- a/apps/web/src/pages/waterbodies/[slug].astro +++ b/apps/web/src/pages/waterbodies/[slug].astro @@ -22,6 +22,6 @@ const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPag --- ← Все водоёмы - + {unavailable ? : !water ? :
{items.length ? items.map(item => ) : }
}
diff --git a/apps/web/src/pages/waterbodies/index.astro b/apps/web/src/pages/waterbodies/index.astro index 3e4fd7e..4f05390 100644 --- a/apps/web/src/pages/waterbodies/index.astro +++ b/apps/web/src/pages/waterbodies/index.astro @@ -14,5 +14,5 @@ if (unavailable) { --- - {unavailable ? : } + {unavailable ? : } diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts index 0a00857..5848fc1 100644 --- a/apps/web/tests/unit/presentation.test.ts +++ b/apps/web/tests/unit/presentation.test.ts @@ -4,6 +4,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"; +import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts"; test("activity levels share one complete 0-100 scale", () => { assert.deepEqual( @@ -34,3 +35,14 @@ test("tackle glyphs only infer a type from explicit name terms", () => { ); 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}$/); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d3c5a8b..47c9232 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -29,7 +29,7 @@ - [x] **B07 · Аудит официальных изображений.** Текущий records-parser получает названия рыб/водоёмов и `title` приманки, но не image URL; официальные gallery/userguide содержат тематические медиа без устойчивого полного соответствия каноническим сущностям. Изображения не хотлинкать и не считать разрешение на данные автоматическим разрешением на медиапубликацию. - [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором. - [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель. -- [ ] **B10 · Визуальные отпечатки водоёмов.** Расширить текущие три береговых контура до детерминированного набора по slug: форма берега, число волн, положение точки и индексный код. Явно обозначать их как абстрактный знак, не карту и не игровую географию. +- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. - [ ] **B11 · Опциональный media pipeline.** После отдельного письменного разрешения на изображения добавить allowlist официальных asset URL, локальное сохранение без hotlink, content hash, MIME/dimensions, attribution/source URL, дату получения и fallback на SVG. Изменение/удаление оригинала должно отправлять media asset на повторную проверку, а не молча менять публичную карточку. - [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления. diff --git a/docs/brand-system.md b/docs/brand-system.md index defc746..598328a 100644 --- a/docs/brand-system.md +++ b/docs/brand-system.md @@ -58,6 +58,8 @@ CTA задаются смыслом через `data-action`: `primary` — ос Приманки различаются типовым контуром только при явном указании категории в названии: блесна, воблер, мягкая приманка, бойл, естественная наживка или оснастка. Неизвестное название получает нейтральную каплевидную форму. Один из пяти спокойных акцентных цветов детерминирован 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, светлая поверхность сайта и тёмные бренд-носители.