feat: separate activity maps by waterbody
This commit is contained in:
@@ -2,27 +2,47 @@
|
||||
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));
|
||||
const groups = [...items.reduce((result, item) => {
|
||||
const group = result.get(item.waterbody_slug) ?? [];
|
||||
group.push(item);
|
||||
result.set(item.waterbody_slug, group);
|
||||
return result;
|
||||
}, new Map<string, Activity[]>()).values()];
|
||||
const plotPoints = (group: Activity[]) => {
|
||||
const minX = Math.min(...group.map(item => item.x), 0);
|
||||
const maxX = Math.max(...group.map(item => item.x), 1);
|
||||
const minY = Math.min(...group.map(item => item.y), 0);
|
||||
const maxY = Math.max(...group.map(item => item.y), 1);
|
||||
const xRange = Math.max(1, maxX - minX);
|
||||
const yRange = Math.max(1, maxY - minY);
|
||||
const occupied = new Set<string>();
|
||||
return group.map(item => {
|
||||
const targetColumn = Math.min(19, Math.max(2, Math.round(((item.x - minX) / xRange) * 16) + 2));
|
||||
const targetRow = Math.min(19, Math.max(2, Math.round((1 - (item.y - minY) / yRange) * 16) + 2));
|
||||
let column = targetColumn;
|
||||
let row = targetRow;
|
||||
for (let distance = 0; occupied.has(`${column}:${row}`) && distance < 20; distance += 1) {
|
||||
column = ((targetColumn - 2 + distance + 1) % 18) + 2;
|
||||
row = ((targetRow - 2 + Math.floor((distance + 1) / 18)) % 18) + 2;
|
||||
}
|
||||
occupied.add(`${column}:${row}`);
|
||||
return { item, column, row };
|
||||
});
|
||||
};
|
||||
---
|
||||
<section class="activity-map" aria-label="Схема координат горячих точек">
|
||||
<header class="activity-map__header">
|
||||
<div><span class="overline">Режим схемы</span><h3>Горячие точки на координатной сетке</h3></div>
|
||||
<p>Показаны подтверждённые координаты текущей выборки. Это схема `x:y`, а не географическая карта водоёма.</p>
|
||||
<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>
|
||||
{groups.map(group => <section class="activity-map__group" aria-labelledby={`activity-map-${group[0].waterbody_slug}`}>
|
||||
<h4 id={`activity-map-${group[0].waterbody_slug}`}>{group[0].waterbody} <small>{group.length} {group.length === 1 ? "точка" : "точки"}</small></h4>
|
||||
<div class="activity-map__plot" role="group" aria-label={`Координатная схема водоёма ${group[0].waterbody}, точек: ${group.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>
|
||||
{plotPoints(group).map(({ item, column, row }) => <a class:list={["activity-map__point", `activity-map__point--col-${column}`, `activity-map__point--row-${row}`]} href={spotPath(item)} aria-label={`${item.waterbody}, ${item.x}:${item.y}, ${item.fish}`} title={`${item.waterbody} · ${item.x}:${item.y} · ${item.fish}`}><span>{item.x}:{item.y}</span><strong>{item.fish}</strong></a>)}
|
||||
</div>
|
||||
</section>)}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -30,6 +50,9 @@ const row = (value: number) => Math.min(20, Math.max(1, Math.round((1 - (value -
|
||||
.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__group { display: grid; gap: 10px; }
|
||||
.activity-map__group h4 { margin: 0; font: 400 21px Georgia, serif; }
|
||||
.activity-map__group h4 small { margin-left: 8px; color: var(--text-muted); font: 700 10px Inter, sans-serif; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.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; }
|
||||
@@ -39,7 +62,7 @@ const row = (value: number) => Math.min(20, Math.max(1, Math.round((1 - (value -
|
||||
.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; }
|
||||
.activity-map__point strong { max-width: 180px; overflow-wrap: anywhere; font-size: 12px; line-height: 1.2; }
|
||||
: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; } }
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ R-пункты уточняют критерии существующих B/G/U/
|
||||
- [x] **R04 · P1 · Восстановление загрузки скриншота.** Retry endpoint теперь сохраняет recovery-состояние даже после timeout: форма повторной загрузки остаётся доступной, а повтор безопасен при уже принятом upload. После успешного восстановления удаляются upload- и idempotency-cookie; backend не создаёт второй объект при повторе. Полный browser/network failure drill остаётся отдельной приёмкой.
|
||||
- [x] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Предметы и сборки получили независимые offsets и пагинаторы (`items_offset`/`rigs_offset`), один сбой endpoint больше не скрывает второй, 422 фильтров отделён от 503, пустой поиск отделён от незаполненного каталога. Карточки стали article-блоками с отдельной ссылкой названия и валидным source-link внутри паспорта без вложенных ссылок. `astro check` и production build проходят; browser/keyboard acceptance наполненного каталога остаётся частью G06/G09.
|
||||
- [x] **R06 · P1 · Надёжный перенос плана (U05).** Share-ссылка показывает preview и явно предлагает объединить, заменить или отменить импорт; `plan` удаляется из URL после чтения, поэтому reload не возвращает удалённые точки. Запись в storage защищена обработкой ошибок, freshness пересчитывается по timestamp, а описание точно говорит о локальном хранении и snapshot в ссылке.
|
||||
- [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия.
|
||||
- [x] **R07 · P1 · Схема координат по водоёмам (U04).** ActivityMap теперь строит отдельную сетку для каждого водоёма, явно подписывает водоём и число точек, разносит совпадающие ячейки, добавляет полную tooltip/accessible label и не обрезает длинные названия карточек. `astro check` проходит; browser acceptance на 320px остаётся отдельным визуальным gate.
|
||||
- [x] **R08 · P1 · Достоверная аналитика снастей (G07).** Публичные пороги закреплены минимум на 3 наблюдениях и 2 игроках независимо от URL-параметров; окно и freshness используют время улова с fallback на время импорта, ответ ограничен `limit`, а canonical ID назначается только при полном однозначном покрытии компонентных наблюдений. `1/1` нельзя превратить в рекомендацию, unresolved не получает ложную привязку.
|
||||
- [x] **R09 · P1 · Полное удаление личных данных.** Retention и admin-delete загружают дочерние компоненты, очищают их `source_url/raw_payload` вместе с родительским report и сбрасывают upload-token; retention теперь обнаруживает старые reports даже при уже очищенных родительских полях, если provenance осталась в component. Повторная очистка остаётся безопасной; S3/БД failure drill — отдельный эксплуатационный gate.
|
||||
- [ ] **R10 · P1 · Единый cooldown и стадии запроса.** Координация CLI/media/scheduler; release только до доказанного обращения. Критерий: DNS до первого запроса освобождает резерв, ошибка после redirect — нет; конкурентные пути делят одно окно. Проверять offline.
|
||||
|
||||
Reference in New Issue
Block a user