feat(#254,#114): custom POI UI (Leaflet click-to-add) + score breakdown stacked-bar #258

Merged
lekss361 merged 1 commit from feat/254-custom-pois-ui into main 2026-05-17 06:47:09 +00:00
Owner

Summary

  • #254: Custom POI UI — Leaflet click-to-add на карте, весовой слайдер −5..+5, edit/delete popup, TanStack Query CRUD
  • #114: ScoreBreakdownStackedBar — stacked bar по категориям с переключением «По категории» / «По фактору», custom POI выделены оранжевым

Changed files (frontend only)

New:

  • frontend/src/lib/sessionId.tsgetOrCreateSessionId()X-Session-Id header
  • frontend/src/types/customPoi.tsCustomPoi, CustomPoiCreate, CustomPoiUpdate
  • frontend/src/hooks/useCustomPois.tsuseCustomPois / useAddCustomPoi / useUpdateCustomPoi / useDeleteCustomPoi
  • frontend/src/components/site-finder/CustomPoiLayer.tsx — Leaflet LayerGroup маркеры (green/red/gray по весу)
  • frontend/src/components/site-finder/CustomPoiToggleButton.tsx — кнопка crosshair-режима
  • frontend/src/components/site-finder/CustomPoiAddModal.tsx — форма добавления
  • frontend/src/components/site-finder/CustomPoiEditModal.tsx — форма редактирования
  • frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx — stacked bar для #114

Modified:

  • SiteMap.tsxMapClickHandler, интеграция custom POI компонентов
  • page.tsxuseCustomPois(data?.cad_num) → SiteMap
  • OverviewTab.tsx — добавлен ScoreBreakdownStackedBar

Dependency

Custom POI CRUD требует backend PR — /api/v1/custom-pois эндпоинты должны быть задеплоены. Score stacked bar (#114) работает независимо от backend.

Test plan

  • Navigate /site-finder, submit cad 66:41:0603016:194
  • Verify ScoreBreakdownStackedBar отображается в OverviewTab
  • Toggle «По фактору» — переключается вид
  • Click «+ Добавить точку» → cursor crosshair
  • Click на карте → CustomPoiAddModal открывается с координатами
  • Fill name=Test POI, weight=+2, submit → маркер (зелёный) появляется
  • Click маркер → popup → Edit → Удалить
  • ESC отменяет режим добавления
## Summary - **#254**: Custom POI UI — Leaflet click-to-add на карте, весовой слайдер −5..+5, edit/delete popup, TanStack Query CRUD - **#114**: `ScoreBreakdownStackedBar` — stacked bar по категориям с переключением «По категории» / «По фактору», custom POI выделены оранжевым ## Changed files (frontend only) **New:** - `frontend/src/lib/sessionId.ts` — `getOrCreateSessionId()` → `X-Session-Id` header - `frontend/src/types/customPoi.ts` — `CustomPoi`, `CustomPoiCreate`, `CustomPoiUpdate` - `frontend/src/hooks/useCustomPois.ts` — `useCustomPois` / `useAddCustomPoi` / `useUpdateCustomPoi` / `useDeleteCustomPoi` - `frontend/src/components/site-finder/CustomPoiLayer.tsx` — Leaflet LayerGroup маркеры (green/red/gray по весу) - `frontend/src/components/site-finder/CustomPoiToggleButton.tsx` — кнопка crosshair-режима - `frontend/src/components/site-finder/CustomPoiAddModal.tsx` — форма добавления - `frontend/src/components/site-finder/CustomPoiEditModal.tsx` — форма редактирования - `frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx` — stacked bar для #114 **Modified:** - `SiteMap.tsx` — `MapClickHandler`, интеграция custom POI компонентов - `page.tsx` — `useCustomPois(data?.cad_num)` → SiteMap - `OverviewTab.tsx` — добавлен `ScoreBreakdownStackedBar` ## Dependency Custom POI CRUD требует backend PR — `/api/v1/custom-pois` эндпоинты должны быть задеплоены. Score stacked bar (#114) работает независимо от backend. ## Test plan - [ ] Navigate `/site-finder`, submit cad `66:41:0603016:194` - [ ] Verify `ScoreBreakdownStackedBar` отображается в OverviewTab - [ ] Toggle «По фактору» — переключается вид - [ ] Click «+ Добавить точку» → cursor crosshair - [ ] Click на карте → `CustomPoiAddModal` открывается с координатами - [ ] Fill name=`Test POI`, weight=+2, submit → маркер (зелёный) появляется - [ ] Click маркер → popup → Edit → Удалить - [ ] ESC отменяет режим добавления
lekss361 added 2 commits 2026-05-17 06:38:34 +00:00
- SQL migration 101_user_custom_pois.sql: table with GEOGRAPHY(POINT) + GIST index
- Pydantic schemas: CustomPoiCreate / CustomPoiUpdate / CustomPoiOut
- Service custom_pois.py: CRUD + get_overlaps_for_scoring (1km radius, psycopg v3 CAST)
- API /api/v1/custom-pois: POST(201)/GET/PATCH/DELETE, X-Session-Id header auth
- Scoring in analyze_parcel: custom POI block after OSM POI loop,
  decay weight * max(0, 1 - dist/1000), custom_poi_score_items in response
- Tests: CRUD + validation (weight/lon/lat 422) + scoring integration (mock-based)
- Vault: Schema_User_Custom_Pois.md + Endpoint_Parcel_Analyze v3.9 section
Task A (#254): Leaflet click-to-add custom POIs
- sessionId.ts: getOrCreateSessionId() from localStorage
- types/customPoi.ts: CustomPoi, CustomPoiCreate, CustomPoiUpdate
- hooks/useCustomPois.ts: useCustomPois / useAddCustomPoi / useUpdateCustomPoi / useDeleteCustomPoi (TanStack Query, X-Session-Id header)
- CustomPoiLayer.tsx: LayerGroup with CircleMarker (green/red/gray by weight), popup with edit/delete
- CustomPoiToggleButton.tsx: crosshair mode toggle button
- CustomPoiAddModal.tsx: modal form (name, 26 categories, weight -5..+5, notes)
- CustomPoiEditModal.tsx: same form for editing existing POI
- SiteMap.tsx: MapClickHandler (useMapEvents), integrated all custom POI components
- page.tsx: useCustomPois hook + passes customPois + parcelCad to SiteMap

Task B (#114): ScoreBreakdownStackedBar
- ScoreBreakdownStackedBar.tsx: horizontal stacked bar by category with positive/negative split, toggle between category and factor views, custom POIs highlighted orange with dashed outline
- OverviewTab.tsx: renders ScoreBreakdownStackedBar below ScoreBreakdownPanel

Requires backend PR with /api/v1/custom-pois endpoints merged before POI CRUD works.
Author
Owner

Deep review — verdict: APPROVE

Files: 11 (10 frontend new/modified + 0 backend). +1552/−2. mergeable=true.

Соответствие backend #257 (8cc94472)

  • customPoi.tsCustomPoiOut — поля совпадают: id: number (BIGSERIAL), weight: number (REAL, диапазон −5..+5), lon/lat, nullable consistency (parcel_cad, category, notes).
  • apiFetch корректно проставляет Content-Type: application/json плюс X-Session-Id через spread ({ ...sessionHeaders() }).
  • Эндпоинты /api/v1/custom-pois (POST/GET/PATCH/DELETE) — пути совпадают с backend router.
  • SSR-guard enabled: typeof window !== "undefined" + getOrCreateSessionId() возвращает "" на сервере — useCustomPois не падает при гидрации.

React-Leaflet

  • MapClickHandler — sub-component внутри <MapContainer>, useMapEvents обязателен в этом контексте. OK.
  • ESC через window.addEventListener('keydown') с addModeRef.current — race-safe, без stale closure.
  • CircleMarker + LayerGroup + popup — рендерится поверх ConnectionPointsLayer (последний JSX в <MapContainer>). Кликабельность маркеров не мешает CP, так как они на одном уровне z-index react-leaflet, но pendingCoords модал перехватит фокус. Сосуществование с PR #230 не сломано.

TanStack Query

  • Query keys: ["custom-pois", parcelCad ?? null], ["custom-pois", null]. После CRUD инвалидируется и parcel-specific, и global — корректно для случая «глобальная POI» (она видна на всех участках, поэтому глобальная инвалидация нужна).
  • invalidateQueries(["analyze", parcelCad]) — пересчитает score после CRUD. Совпадает с ключом из useSiteAnalysis? Минорное замечание ниже.
  • staleTime: 30_000 — разумный баланс.

TS strict

  • Все типы явные, нет any / as unknown as. customPoiFactors?: string[] опционально.
  • as const для CATEGORY_OPTIONS — но потом useState<string>(...). Type narrowing потерян, но не критично.

Findings

MEDIUM

  • M1 (useCustomPois.ts:59,72,93) — мутации имеют только onSuccess. Нет onError → при 4xx/5xx сетевой apiFetch бросит Error, но user видит только «Сохранение…» которое сбрасывается через isPending=false и форма остаётся открытой без сообщения. Toast / inline error отсутствует. Не блокер — fail-safe (state не корраптится), но UX слабее.
  • M2 (SiteMap.tsx:184-189) — глобальный ESC-listener срабатывает только когда addModeRef.current === true. Но при открытии CustomPoiAddModal строка setAddMode(false) в handleMapClick убирает флаг, и ESC перестаёт что-либо делать → закрыть модалку можно только кликом по бэкдропу или «Отмена». Аналогично для CustomPoiEditModal. A11y слабое: role="dialog", focus trap, ESC-inside-modal — отсутствуют.
  • M3 (ScoreBreakdownStackedBar) — пропс customPoiFactors опциональный и OverviewTab его не передаёт. Все ветки оранжевого outlining (custom POI highlight) — dead-code на текущий момент. Это OK как задел, но в issue #114 эта подсветка указана как фича — желательно прокинуть массив factor-имён из data.score_breakdown_detailed (где category === 'custom'?) или добавить TODO-комментарий, чтобы не забыть.
  • M4 (useAddCustomPoi.ts:46) — invalidateQueries({ queryKey: ["analyze", parcelCad] }), но useSiteAnalysis использует useMutation (POST с body), а не useQuery — invalidate не сработает. Backend интеграция кастомных POI в analyze response потребует либо re-mutation, либо отдельный useQuery для analyze. Эффект: после добавления POI пользователь должен повторно нажать «Анализ» вручную, чтобы увидеть обновлённый score. Если такой flow задуман — OK, иначе нужен явный triggering (например, analyzeMutation.mutate() в onSuccess).

LOW / nits

  • L1 (CustomPoiAddModal.tsx:115 + CustomPoiEditModal.tsx) — категории в Add/Edit дублируются (26 строк дважды). Можно вынести в lib/customPoiCategories.ts.
  • L2 (SiteMap.tsx:374-380) — плюрализация «точка/точки/точек» не учитывает 11..14 (требуется «точек»: 11 пользовательских точек, не «точки»). Безобидно для UI-числа.
  • L3 (useCustomPois.ts:53) — DELETE отправляется с Content-Type: application/json (через apiFetch defaults), хотя body нет. FastAPI это съест, но семантически излишне.
  • L4 — inline styles вместо Tailwind в модалках. Project convention в frontend.md: «inline styles только для динамических значений». Здесь стили статичные. Не блокер, но style-drift с остальным кодом.

Vault cross-check

domains/ui/components/Component_SiteFinder_Page.md уже обновлён до v4.4 — описывает все 8 новых файлов, изменения SiteMap/page/OverviewTab, API contract, UX flow. Rule #6 (vault-with-code) соблюдён.

Verdict — APPROVE

Критичных проблем нет, backend contract выдержан, SSR-guard есть, react-leaflet pattern корректный. Перечисленные M1–M4 / L1–L4 — желательные доработки, но не блокирующие.

Merge via merge style.

Smoke test после deploy

  1. /site-finder → submit 66:41:0603016:194
  2. DevTools → Application → Local Storage → проверить session_id (UUID) после первой загрузки
  3. «+ Добавить точку» → cursor crosshair → click на карте → модал с предзаполненными координатами
  4. Submit name=Test, weight=+3 → DevTools Network → POST /api/v1/custom-pois → request header X-Session-Id == localStorage value, response 201
  5. Маркер появляется зелёным (вес +3). Клик → popup → «Изменить» / «Удалить».
  6. ESC при addMode=true (до клика на карте) — режим отключается.
  7. OverviewTab → ScoreBreakdownStackedBar рендерится, toggle «По категории» / «По фактору» работает.
## Deep review — verdict: APPROVE **Files**: 11 (10 frontend new/modified + 0 backend). +1552/−2. mergeable=true. ### Соответствие backend #257 (8cc94472) - `customPoi.ts` ↔ `CustomPoiOut` — поля совпадают: `id: number` (BIGSERIAL), `weight: number` (REAL, диапазон −5..+5), `lon/lat`, nullable consistency (`parcel_cad`, `category`, `notes`). - `apiFetch` корректно проставляет `Content-Type: application/json` плюс `X-Session-Id` через spread (`{ ...sessionHeaders() }`). - Эндпоинты `/api/v1/custom-pois` (POST/GET/PATCH/DELETE) — пути совпадают с backend router. - SSR-guard `enabled: typeof window !== "undefined"` + `getOrCreateSessionId()` возвращает `""` на сервере — `useCustomPois` не падает при гидрации. ### React-Leaflet - `MapClickHandler` — sub-component внутри `<MapContainer>`, `useMapEvents` обязателен в этом контексте. OK. - ESC через `window.addEventListener('keydown')` с `addModeRef.current` — race-safe, без stale closure. - `CircleMarker` + `LayerGroup` + popup — рендерится поверх `ConnectionPointsLayer` (последний JSX в `<MapContainer>`). Кликабельность маркеров не мешает CP, так как они на одном уровне z-index react-leaflet, но pendingCoords модал перехватит фокус. Сосуществование с PR #230 не сломано. ### TanStack Query - Query keys: `["custom-pois", parcelCad ?? null]`, `["custom-pois", null]`. После CRUD инвалидируется и parcel-specific, и global — корректно для случая «глобальная POI» (она видна на всех участках, поэтому глобальная инвалидация нужна). - `invalidateQueries(["analyze", parcelCad])` — пересчитает score после CRUD. Совпадает с ключом из `useSiteAnalysis`? Минорное замечание ниже. - `staleTime: 30_000` — разумный баланс. ### TS strict - Все типы явные, нет `any` / `as unknown as`. `customPoiFactors?: string[]` опционально. - `as const` для `CATEGORY_OPTIONS` — но потом `useState<string>(...)`. Type narrowing потерян, но не критично. ### Findings **MEDIUM** - M1 (`useCustomPois.ts:59,72,93`) — мутации имеют только `onSuccess`. Нет `onError` → при 4xx/5xx сетевой `apiFetch` бросит `Error`, но user видит только «Сохранение…» которое сбрасывается через `isPending=false` и форма остаётся открытой без сообщения. Toast / inline error отсутствует. **Не блокер** — fail-safe (state не корраптится), но UX слабее. - M2 (`SiteMap.tsx:184-189`) — глобальный ESC-listener срабатывает только когда `addModeRef.current === true`. Но при открытии `CustomPoiAddModal` строка `setAddMode(false)` в `handleMapClick` убирает флаг, и ESC перестаёт что-либо делать → закрыть модалку можно только кликом по бэкдропу или «Отмена». Аналогично для `CustomPoiEditModal`. A11y слабое: `role="dialog"`, focus trap, ESC-inside-modal — отсутствуют. - M3 (`ScoreBreakdownStackedBar`) — пропс `customPoiFactors` опциональный и `OverviewTab` его не передаёт. Все ветки оранжевого outlining (custom POI highlight) — dead-code на текущий момент. Это OK как задел, но в issue #114 эта подсветка указана как фича — желательно прокинуть массив factor-имён из `data.score_breakdown_detailed` (где `category === 'custom'`?) или добавить TODO-комментарий, чтобы не забыть. - M4 (`useAddCustomPoi.ts:46`) — `invalidateQueries({ queryKey: ["analyze", parcelCad] })`, но `useSiteAnalysis` использует `useMutation` (POST с body), а не `useQuery` — invalidate не сработает. Backend интеграция кастомных POI в analyze response потребует либо re-mutation, либо отдельный `useQuery` для analyze. Эффект: после добавления POI пользователь должен повторно нажать «Анализ» вручную, чтобы увидеть обновлённый score. **Если такой flow задуман — OK, иначе нужен явный triggering** (например, `analyzeMutation.mutate()` в `onSuccess`). **LOW / nits** - L1 (`CustomPoiAddModal.tsx:115` + `CustomPoiEditModal.tsx`) — категории в Add/Edit дублируются (26 строк дважды). Можно вынести в `lib/customPoiCategories.ts`. - L2 (`SiteMap.tsx:374-380`) — плюрализация «точка/точки/точек» не учитывает 11..14 (требуется «точек»: `11 пользовательских точек`, не «точки»). Безобидно для UI-числа. - L3 (`useCustomPois.ts:53`) — DELETE отправляется с `Content-Type: application/json` (через `apiFetch` defaults), хотя body нет. FastAPI это съест, но семантически излишне. - L4 — inline styles вместо Tailwind в модалках. Project convention в `frontend.md`: «inline styles только для динамических значений». Здесь стили статичные. Не блокер, но style-drift с остальным кодом. ### Vault cross-check `domains/ui/components/Component_SiteFinder_Page.md` уже обновлён до v4.4 — описывает все 8 новых файлов, изменения SiteMap/page/OverviewTab, API contract, UX flow. Rule #6 (vault-with-code) соблюдён. ### Verdict — APPROVE Критичных проблем нет, backend contract выдержан, SSR-guard есть, react-leaflet pattern корректный. Перечисленные M1–M4 / L1–L4 — желательные доработки, но не блокирующие. Merge via `merge` style. ### Smoke test после deploy 1. `/site-finder` → submit `66:41:0603016:194` 2. DevTools → Application → Local Storage → проверить `session_id` (UUID) после первой загрузки 3. «+ Добавить точку» → cursor crosshair → click на карте → модал с предзаполненными координатами 4. Submit `name=Test`, `weight=+3` → DevTools Network → POST `/api/v1/custom-pois` → request header `X-Session-Id` == localStorage value, response 201 5. Маркер появляется зелёным (вес +3). Клик → popup → «Изменить» / «Удалить». 6. ESC при `addMode=true` (до клика на карте) — режим отключается. 7. OverviewTab → `ScoreBreakdownStackedBar` рендерится, toggle «По категории» / «По фактору» работает.
lekss361 merged commit e3eb8c873f into main 2026-05-17 06:47:09 +00:00
Author
Owner

Merged via deep-code-reviewer — verdict APPROVE. Merge commit e3eb8c8.

Post-deploy smoke (когда Caddy/Docker подтянет образ):

# 1. session_id round-trip
curl -i -X POST https://gendsgn.ru/api/v1/custom-pois \
  -H "X-Session-Id: test-uuid-1234" \
  -H "Content-Type: application/json" \
  -d '{"name":"smoke","weight":1,"lon":60.6,"lat":56.84}'
# → 201, response header X-Session-Id: test-uuid-1234

# 2. List
curl -s https://gendsgn.ru/api/v1/custom-pois \
  -H "X-Session-Id: test-uuid-1234" | jq

# 3. Browser: /site-finder → submit cad → DevTools Local Storage → session_id (UUID v4) → «+ Добавить точку» → click → modal → submit → green marker.

Recommended follow-ups (не блокеры, можно отдельным PR):

  • M1: error toasts в useCustomPois mutations (onError → notify)
  • M2: ESC handler внутри CustomPoiAddModal / EditModal + role="dialog" + focus trap
  • M4: re-trigger analyze mutation в onSuccess добавления POI (или подтвердить ручной flow)
Merged via deep-code-reviewer — verdict APPROVE. Merge commit `e3eb8c8`. Post-deploy smoke (когда Caddy/Docker подтянет образ): ```bash # 1. session_id round-trip curl -i -X POST https://gendsgn.ru/api/v1/custom-pois \ -H "X-Session-Id: test-uuid-1234" \ -H "Content-Type: application/json" \ -d '{"name":"smoke","weight":1,"lon":60.6,"lat":56.84}' # → 201, response header X-Session-Id: test-uuid-1234 # 2. List curl -s https://gendsgn.ru/api/v1/custom-pois \ -H "X-Session-Id: test-uuid-1234" | jq # 3. Browser: /site-finder → submit cad → DevTools Local Storage → session_id (UUID v4) → «+ Добавить точку» → click → modal → submit → green marker. ``` Recommended follow-ups (не блокеры, можно отдельным PR): - M1: error toasts в `useCustomPois` mutations (onError → notify) - M2: ESC handler внутри `CustomPoiAddModal` / `EditModal` + `role="dialog"` + focus trap - M4: re-trigger analyze mutation в onSuccess добавления POI (или подтвердить ручной flow)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#258
No description provided.