feat(site-finder): Ad-hoc custom POI с весом — пользователь добавляет свою точку на карту (требование Макса) #254

Closed
opened 2026-05-17 06:23:39 +00:00 by lekss361 · 1 comment
Owner

Контекст / требование Макса

В дополнение к #114 (слайдеры весов для 25 категорий OSM POI), Макс просил возможность добавлять свои точки на карту с произвольным весом — учитывать их в scoring.

Use cases:

  • «Здесь будет наш садик через год — добавь с весом +2.0»
  • «Скоро открывается ТЦ конкурента — добавь с весом -1.0 (отнимает покупателей)»
  • «Заводская труба здесь — добавь с весом -3.0 (загрязнение)»
  • «Планируется станция метро через 2 года — добавь +5.0»

Это дополнение к #114: #114 = веса категорий (school, park, etc), новая фича = конкретные точки не из OSM.

Что нужно

Backend

  1. Схема data/sql/NN_user_custom_pois.sql:

    CREATE TABLE user_custom_pois (
        id BIGSERIAL PRIMARY KEY,
        user_id TEXT NOT NULL,           -- сейчас admin/anonymous, после #67 — pilot user id
        parcel_cad TEXT,                  -- NULL = глобальная точка, иначе scoped к участку
        name TEXT NOT NULL,
        category TEXT,                    -- свободный текст или enum из 25 OSM категорий
        weight REAL NOT NULL CHECK (weight BETWEEN -5 AND 5),
        lon DOUBLE PRECISION NOT NULL,
        lat DOUBLE PRECISION NOT NULL,
        geom GEOGRAPHY(POINT, 4326) GENERATED ALWAYS AS (
            ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography
        ) STORED,
        notes TEXT,
        created_at TIMESTAMPTZ DEFAULT NOW(),
        updated_at TIMESTAMPTZ DEFAULT NOW()
    );
    CREATE INDEX user_custom_pois_geom_gist ON user_custom_pois USING GIST(geom);
    CREATE INDEX user_custom_pois_user_parcel ON user_custom_pois (user_id, parcel_cad);
    
  2. CRUD endpoints backend/app/api/v1/custom_pois.py:

    • POST /api/v1/custom-pois (body: name, category, weight, lon, lat, parcel_cad?)
    • GET /api/v1/custom-pois?parcel_cad=...&user_id=... — список (для отрисовки на карте)
    • PATCH /api/v1/custom-pois/{id} — изменить вес/name
    • DELETE /api/v1/custom-pois/{id}
  3. Scoring integration services/site_finder/scorer.py:

    • В analyze_parcel добавить SELECT user_custom_pois WHERE ST_DWithin(geom, parcel.geom::geography, 1000) AND user_id = :uid AND (parcel_cad IS NULL OR parcel_cad = :cad)
    • Применить тот же distance decay что для OSM POI: score += weight * max(0, 1 - distance_m / 1000)
    • В score_breakdown добавить items с source: "custom", label: name, contribution: ...

Frontend

  1. UI на карте Leaflet (frontend/src/components/site-finder/SiteMap.tsx или новый CustomPoiLayer.tsx):

    • Кнопка "+ Добавить точку" → активирует click-mode
    • Клик по карте → modal: name, category (dropdown 25 OSM + "Другое"), weight (-5..+5 slider), notes
    • Существующие custom POIs рендерятся как маркеры (цвет по знаку weight: красный отрицательный, зелёный положительный)
    • Click на маркер → popup с edit/delete
  2. Score breakdown UI (ScoreCard.tsx):

    • В блоке breakdown отделить "Кастомные точки" с visual differentiation
  3. TanStack Query hooks:

    • useCustomPois(parcelCad) + useAddCustomPoi() + useUpdate/Delete

Tests

  • Backend: CRUD endpoints, scoring integration (custom POI with weight=+2 повышает score), validation (weight в [-5, 5], coords валидны)
  • Frontend: click-to-add UX, optimistic update

Acceptance

  • Пользователь кликает на карту → создаётся custom POI с указанным весом
  • После создания — analyze пересчитывает score с учётом этой точки
  • score_breakdown показывает вклад custom POI отдельно от OSM
  • Custom POI sticky к user (после relogin сохраняется)
  • Можно scope глобально (для всех cad) или per-cad

Severity / Effort

🟡 P2 — high value для Макса (явное требование), но not blocking. После #114 #115 #126 fix'ов.

Effort: ~5-7 дней (backend 2 + frontend 3 + tests 1-2)

Dependencies / blocked-by

  • Желательно после #67 (auth) — иначе custom POI привязаны к anonymous пользователю и теряются. Workaround: использовать stable browser session-id из localStorage (crypto.randomUUID()) пока auth не появится.

Open questions

  1. Scope: global (per-user) или per-parcel scope? Default = global, optional per-cad.
  2. Sharing: можно ли pilot user'у поделиться своими POIs с partner'ом? Phase 2.
  3. Bulk import: CSV с координатами? Phase 2.
  • #114 — слайдеры весов для 25 OSM категорий (foundation)
  • #87 — score_breakdown / explainability (где будем рендерить custom POI contribution)

(Создано 2026-05-17 на основе уточнения с user про feedback Макса)

## Контекст / требование Макса В дополнение к #114 (слайдеры весов для 25 категорий OSM POI), Макс просил **возможность добавлять свои точки** на карту с произвольным весом — учитывать их в scoring. Use cases: - «Здесь будет наш садик через год — добавь с весом +2.0» - «Скоро открывается ТЦ конкурента — добавь с весом -1.0 (отнимает покупателей)» - «Заводская труба здесь — добавь с весом -3.0 (загрязнение)» - «Планируется станция метро через 2 года — добавь +5.0» Это **дополнение к #114**: #114 = веса категорий (school, park, etc), новая фича = **конкретные точки** не из OSM. ## Что нужно ### Backend 1. **Схема** `data/sql/NN_user_custom_pois.sql`: ```sql CREATE TABLE user_custom_pois ( id BIGSERIAL PRIMARY KEY, user_id TEXT NOT NULL, -- сейчас admin/anonymous, после #67 — pilot user id parcel_cad TEXT, -- NULL = глобальная точка, иначе scoped к участку name TEXT NOT NULL, category TEXT, -- свободный текст или enum из 25 OSM категорий weight REAL NOT NULL CHECK (weight BETWEEN -5 AND 5), lon DOUBLE PRECISION NOT NULL, lat DOUBLE PRECISION NOT NULL, geom GEOGRAPHY(POINT, 4326) GENERATED ALWAYS AS ( ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography ) STORED, notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX user_custom_pois_geom_gist ON user_custom_pois USING GIST(geom); CREATE INDEX user_custom_pois_user_parcel ON user_custom_pois (user_id, parcel_cad); ``` 2. **CRUD endpoints** `backend/app/api/v1/custom_pois.py`: - `POST /api/v1/custom-pois` (body: name, category, weight, lon, lat, parcel_cad?) - `GET /api/v1/custom-pois?parcel_cad=...&user_id=...` — список (для отрисовки на карте) - `PATCH /api/v1/custom-pois/{id}` — изменить вес/name - `DELETE /api/v1/custom-pois/{id}` 3. **Scoring integration** `services/site_finder/scorer.py`: - В `analyze_parcel` добавить SELECT user_custom_pois WHERE ST_DWithin(geom, parcel.geom::geography, 1000) AND user_id = :uid AND (parcel_cad IS NULL OR parcel_cad = :cad) - Применить **тот же distance decay** что для OSM POI: `score += weight * max(0, 1 - distance_m / 1000)` - В `score_breakdown` добавить items с `source: "custom"`, `label: name`, `contribution: ...` ### Frontend 1. **UI на карте Leaflet** (`frontend/src/components/site-finder/SiteMap.tsx` или новый `CustomPoiLayer.tsx`): - Кнопка "+ Добавить точку" → активирует click-mode - Клик по карте → modal: name, category (dropdown 25 OSM + "Другое"), weight (-5..+5 slider), notes - Существующие custom POIs рендерятся как маркеры (цвет по знаку weight: красный отрицательный, зелёный положительный) - Click на маркер → popup с edit/delete 2. **Score breakdown UI** (`ScoreCard.tsx`): - В блоке breakdown отделить "Кастомные точки" с visual differentiation 3. **TanStack Query hooks**: - `useCustomPois(parcelCad)` + `useAddCustomPoi()` + `useUpdate/Delete` ### Tests - Backend: CRUD endpoints, scoring integration (custom POI with weight=+2 повышает score), validation (weight в [-5, 5], coords валидны) - Frontend: click-to-add UX, optimistic update ## Acceptance - [ ] Пользователь кликает на карту → создаётся custom POI с указанным весом - [ ] После создания — analyze пересчитывает score с учётом этой точки - [ ] score_breakdown показывает вклад custom POI отдельно от OSM - [ ] Custom POI sticky к user (после relogin сохраняется) - [ ] Можно scope глобально (для всех cad) или per-cad ## Severity / Effort 🟡 **P2** — high value для Макса (явное требование), но not blocking. После #114 #115 #126 fix'ов. **Effort:** ~5-7 дней (backend 2 + frontend 3 + tests 1-2) ## Dependencies / blocked-by - Желательно после **#67** (auth) — иначе custom POI привязаны к anonymous пользователю и теряются. **Workaround:** использовать stable browser session-id из localStorage (`crypto.randomUUID()`) пока auth не появится. ## Open questions 1. Scope: global (per-user) или per-parcel scope? Default = global, optional per-cad. 2. Sharing: можно ли pilot user'у поделиться своими POIs с partner'ом? Phase 2. 3. Bulk import: CSV с координатами? Phase 2. ## Links - #114 — слайдеры весов для 25 OSM категорий (foundation) - #87 — score_breakdown / explainability (где будем рендерить custom POI contribution) (Создано 2026-05-17 на основе уточнения с user про feedback Макса)
Collaborator

Ground-truth 2026-06-13. DRIFT: Ad-hoc custom POI с весом — полностью E2E. Backend data/sql/101_user_custom_pois.sql + custom_pois.py (CRUD + get_overlaps_for_scoring ST_DWithin 1000) + router. Scoring parcels.py:2501 distance-decay, surfaced custom_poi_score_items. Frontend useCustomPois.ts + CustomPoiLayer/AddModal/EditModal + SiteMap wired. Tests test_custom_pois.py. PRs #257/#258. Все acceptance закрыты.

**Ground-truth 2026-06-13.** DRIFT: Ad-hoc custom POI с весом — полностью E2E. Backend data/sql/101_user_custom_pois.sql + custom_pois.py (CRUD + get_overlaps_for_scoring ST_DWithin 1000) + router. Scoring parcels.py:2501 distance-decay, surfaced custom_poi_score_items. Frontend useCustomPois.ts + CustomPoiLayer/AddModal/EditModal + SiteMap wired. Tests test_custom_pois.py. PRs #257/#258. Все acceptance закрыты.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#254
No description provided.