The scoped reset `.pticaRoot button { font: inherit }` had specificity (0,1,1),
beating single-class button rules like .navItem (9px), .tab (11px), .detailBtn
(9px), .ptab — so every cockpit button was forced back to the root 13px. The
oversized labels overflowed the 96px left rail (icons/text shoved right of
centre, «неровное») and bloated tabs/buttons across the cockpit.
Wrap the selector in :where(button) → specificity (0,1,0); the per-component
classes (later in source) now win their font-size, matching the prototype's
bare `button {}`. Verified on a live local render: rail labels 13→9px and every
item re-centres at 47.5px; tabs 11px; detail buttons 9px.
The «Инсоляционная матрица» preview cube now gently rotates (rotateY
turntable with a constant rotateX tilt for depth) so the 3D module reads
as alive without opening the drawer. Pure CSS; honours prefers-reduced-motion.
Multi-region audit vs the prototype styles.css/dashboard.html → 22 CSS fixes +
markup. Headline: .lowerGrid/.bottomGrid align-items:start so the sparse ОКС
«нет данных» card no longer stretches into a huge void. Plus restore missing
prototype bits: topbar icon-buttons (Экспорт→Отчёты, Полный экран→fullscreen,
Настройки aria-disabled «скоро»), rail help-dot, app-footer (honest disclaimer,
no fabricated model/timestamp), invest-score sparkline (decorative, aria-hidden),
green/amber value tones, engineering status-row dots (from real nearest_m),
verdict-card gradient, scan-card status-rows, .canvas/.hbar/.mixHead spacing.
All new CSS scoped under .pticaRoot[data-theme=dark] (status-row classes
descendant-scoped under .scanCard — no collision with the drawer's). Environment
card stays honest kvrow placeholders (no fake dots). a11y: aria-labels on icon
buttons. TS strict, no any. tsc/lint/prettier/build green. code-reviewer ✅.
The cockpit rail had 6 text-only items whose scrollToAnchor targeted
non-existent IDs (clicks did nothing) and diverged from the reference.
Replace with the prototype's 7 icon+label items (Участок/Потенциал/Продукт/
Экономика/Риски/Сравнение/Отчёты, icons ported from the kit). Discriminated
union RailItem {kind:'scroll'|'tab'}: scroll items activate the analysis tab
then scrollIntoView the matching section on the next frame; tab items switch
the top tab. Add ptica-hero/-potential/-product/-economy/-risks section IDs;
port the .nav-item active state (cyan accent + glow + left bar).
Fix two regressions vs the prototype:
1. PticaMapInner: render real data layers over the dark base by REUSING the
existing analysis-map components — POI by category, competitors/pipeline
(MarketLayers), connection points + colored polylines (ConnectionPointsLayer),
ЗОУИТ (ZouitLayer), custom POI add/edit/delete (CustomPoiLayer + mutation hooks).
Add Спутник/Схема base toggle (Esri default + --map-filter-sat); legend rows
toggle layers; «Точки подключения» driven by real CP data. Isochrones left
«скоро» (ORS is on-demand, not in /analyze).
2. /site-finder/analysis/[cad] now renders the ПТИЦА cockpit — every parcel entry
opens ПТИЦА, not the old design. [cad]/ptica redirects to the parent (single
cockpit copy). Old AnalysisPageContent kept in repo; legacy UI at /legacy/site-finder.
3. Surface 3D massing: new «Инсоляция · 3D-масса» scan card + 3D legend row + 3D
map tool all open the insolation/future3d drawers.
SSR-safe (Leaflet only behind dynamic ssr:false); real POI from score_breakdown;
no redirect loop. tsc/lint/prettier/build green. code-reviewer APPROVE.
Read nspd_zoning.{max_far,max_height_m,max_floors,max_building_pct,
regulation_zone_index,regulation_source} (backend PR #1847) into the urban
scan-card, urban/buildability/potential drawers and the 3D massing КСИТ-target.
Defensive: every read is optional-chained + != null guarded → identical "—"
placeholders when fields absent, so the cockpit is unchanged until #1847 deploys,
then auto-lights-up. КСИТ density = area × max_far; пятно = area × max_building_pct
(percent 0..100, verified vs backend parser); sellable labeled estimate. 3D maxFar
= nspd_zoning.max_far ?? 3.5 (finite/>0 guarded). NspdZoning type +7 optional fields.
TS strict, no any.
PticaDrawer: proper modal focus management —
- focus-trap: Tab/Shift+Tab wrap inside the dialog (focusables re-queried per
Tab so it survives lazy panels/sub-tabs), focus-already-outside pulled back in;
- focus-restore: the trigger element is captured on open and re-focused on close
(guards null + detached node);
- onClose held in a ref so the effect depends on [open] only — fixes focus
thrashing when the host's URL/searchParams change mid-open.
Esc/scrim close, body-scroll-lock, role=dialog/aria-modal preserved.
Cleanups (code-review follow-ups):
- MassingScene: drop duplicate initial mass build (floors/sections effect owns
it); corrected the effect-ordering comment.
- Static inline styles → CSS-module classes (scan/gauge/score/spaced rows).
- DrawerPrimitives: remove !important on .dtabActive via specificity
(.dtabs button.dtabActive).
- Remove unused SoonCard component + its CSS.
tsc/lint/prettier clean; next build green (route bundled). ptica-only, no any.
Port the prototype massing.js to a client-only React component (MassingScene)
behind a dynamic(ssr:false) wrapper (MassingViewer), wired into the future3d +
insolation drawers, replacing the СКОРО SoonCards.
Generative mass from parcel area (real geometry_suitability.area_ha) × КСИТ-цель
3.5 (labelled regulation-default · НСПД — honest, real max_far not in /analyze),
OrbitControls (orbit/zoom/auto-rotate), time-of-day sun with shadows (insolation
preview), live GFA/КСИТ-факт metrics (green ≤3.5 / amber over). Full renderer/
scene/RAF dispose on drawer close; graceful WebGL-unavailable fallback.
Adds three@0.184.0 + @types/three (package.json + package-lock.json in sync).
SSR-isolated (three imported only in MassingScene). TS strict, no any.
Dark cockpit Scenarios tab wired to useParcelForecastQuery (ForecastEnvelope):
scenario cards (base/aggr/conserv), demand-vs-supply chart + horizons table,
scenario compare table, confidence panel, recommended product (квартирография
+ target price + success-score), scoring transparency. Calm polling skeleton
while the async forecast is pending — never an error.
Hero invest-score/buy-signal now surface real values once the forecast resolves
(scoring.overall×10; buy-signal from exec_summary.key_numbers.deficit_index,
derived independent of the overall score), keeping the "после прогноза"
placeholders while pending. advisory · §22 caption shown in both states.
All forecast field paths verified against types/forecast.ts. Dark theme stays
scoped under .pticaRoot[data-theme=dark]. TS strict, no any.
New non-destructive route /site-finder/analysis/[cad]/ptica rendering the existing
/analyze data in the «ПТИЦА» operator-terminal cockpit. The light analysis page,
Section1-6, globals.css and package.json are untouched.
PR#1 scope: app-shell (topbar + 4 tabs + left rail), hero (dark Leaflet map +
parcel passport KV-grid + Buildability radial gauge + invest-score block), and the
Development Scan card grid — wired to useParcelAnalyzeQuery / ParcelAnalysis.
Data honesty: real fields render live (passport egrn.*, district, utilities.summary,
median_price, pipeline_24mo); absent fields show muted placeholders with source
captions (buildability/risk = derived proxy «предв.»; invest/buy-signal «после
прогноза»; КСИТ/height/ОКС/economy «—») via typed ptica-adapt.ts — no fake numbers
presented as live.
Dark theme is a CSS-module scoped under .pticaRoot[data-theme=dark] (cannot leak to
the light app); IBM Plex Mono added via next/font (no dep/lockfile change). Leaflet
via dynamic(ssr:false). TS strict, no any. Deferred to follow-up PRs: Scenarios
(forecast), Reports+Compare tabs, 16 detail drawers, Three.js 3D massing.
Section2 «Сети и точки подключения» был только таблицей расстояний без карты — пользователь не видел ГДЕ точка. Добавлена карта (dynamic SiteMap ssr:false + useConnectionPoints + ConnectionPointsLayer/CpLayerControlPanel), label «Точки подключения и охранные зоны сетей (НСПД)», empty-state при отсутствии дампа квартала. Таблица расстояний не тронута. Frontend-only.
Полное обогащение 191-ФЗ (владелец/диаметр/мощность, единый источник истины) — остаётся за датасетом Минстроя (#1746 не закрыт целиком).
#1736 added useConnectionPoints (useQuery) to MiniMap, but MiniMap.test.tsx
rendered without a QueryClient -> 'No QueryClient set' (2 failing tests in CI
frontend-tests). Wrap renders in a QueryClientProvider (retry:false).
#1748 added format=pdf to the forecast export endpoint but didn't regenerate
the frontend OpenAPI types, failing CI openapi-codegen-check. Regenerated via
the CI recipe (app.openapi() dump -> openapi-typescript -> prettier).
- #1736 MiniMap: проброс useConnectionPoints → точки подключения на карте analyze (были только в /legacy)
- #1737 confidence: пронесено имя сервиса → RU-ярлык (Рынок/Будущее предложение/…) вместо «Компонент вкладывающий сервис»
- #1738 pipeline: self-exclusion субъекта (ST_DWithin 80м) — проект не считает сам себя будущим конкурентом
- #1739 PDF: snapshot_pdf обёрнут в try/except+logger.exception (причина 500 видна) + format=pdf в forecast export + font_url fallback
- #1740 gate↔recommendation: при can_build_mkd=False — gate_caveat на обоих рекомендаторах (противоречие явное, не молчит)
Verify: py_compile 5/5, tsc 0, ruff clean, pytest confidence/forecast 95 passed.
Closes#1736Closes#1737Closes#1738Closes#1739Closes#1740
openapi-codegen-check падал СИСТЕМНО на каждом backend-PR: committed
frontend/src/lib/api-types.ts отставал от backend OpenAPI на 2 поля,
добавленных в POI-score схемы (PR ~#1486 normalize POI weighted score),
но не перегенерённых:
- PoiScoreItem.score_contribution
- PoiScoreResponse.poi_weighted_score
Контент взят 1:1 из authoritative CI-диффа (openapi-typescript + prettier).
Возвращает gate openapi-codegen-check в зелёное для всех PR.
Refs #1709
- Rename top_poi → items in poi-score.json to match PoiScoreResponse TS type
(mock was cast as PoiScoreResponse but had wrong field name → items undefined
at runtime in MOCK_POI_SCORE mode → PoiList2Gis crashed at [...items].sort)
- Recompute all score_contribution values using backend formula
(weight / _MAX_STRAIGHT_SCORE * 100, _MAX_STRAIGHT_SCORE=0.315) and
poi_weighted_score=19.9 (was 72, which was inconsistent with the new normalization)
- Add assert result.poi_weighted_score == 0.0 to test_routing_decay_empty_db
to match the straight-line empty-db assertion
- Remove stale comment in PoiList2Gis.tsx saying normalization needs fixing in
site-finder-api.ts (already done backend-side in this PR)
Backend now computes poi_weighted_score and per-POI score_contribution in
0..100 range. Normalization denominator = sum of top-7 category weights / 100
(straight-line mode) or sum of top-7 category weights (routing-decay mode).
Frontend stops reconstructing score from raw weight × 100.
Switch portfolio source from domrf_region_aggregates (room_count_type ONE/TWO/THREE/FOUR)
to domrf_flat_area_distribution (area_bucket FROM_0_TO_25…FROM_100), aggregated into the
same 5 area buckets as the deals series. Both series now share the same axis and are
directly comparable. Frontend portfolioMap simplified to direct bucket key lookup.
Бэкенд-схема BulkGeoEnqueueRequest получила поле rate_ms (int, default 600),
но api-types.ts не был перегенерён → CI openapi-codegen-check (drift-gate) RED.
Добавлено сгенерированное поле (проверено: openapi-typescript 7.13.0 + prettier
defaults даёт байт-идентичный результат).
Дополняет UI: 5 компонентов которые импортит page.tsx (Leaflet draw, форма ConceptInput,
табы вариантов, placement-карта, экспорт). Не вошли в предыдущий commit (untracked-dir).
ConceptResultMap: Leaflet stroke #D1D5DB (CSS var не резолвится в SVG).