fix(site-finder): MiniMap не клипает layer-controls — рыночные слои доступны (#1218)
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 49s
CI / frontend-tests (pull_request) Successful in 48s

Section 1 mini-map (/site-finder/analysis/[cad] — единственная карта
страницы) оборачивал SiteMap в height:320 + overflow:hidden. SiteMap
рендерит карту фиксированной высотой 420px + ПОД ней (flow) кнопку
"Добавить POI", легенду и CpLayerControlPanel с тумблерами
competitors/pipeline/risk/opportunity/redlines. Нижние 100px карты и
ВСЕ контролы обрезались clip-обёрткой → недостижимы кликом.

visibleMarketLayers init = {"competitors"} — единственный способ
включить остальные слои (#999 zone-of-risk, #958 §12.1-13 opportunity
parcels / red lines) — те самые скрытые чекбоксы → слои физически
невозможно было включить на странице анализа, хотя данные пробрасывались
props'ами ради них.

Patch (Вариант A из issue):
- SiteMap.tsx: новый prop mapHeight?: number (default 420 — backward-
  compat для /site-finder/[cad]).
- MiniMap.tsx: снято height:320 + overflow:hidden; передаём mapHeight=280
  (компактнее но не клипает); только width-constraint (maxWidth 400)
  на обёртке.

2 новых vitest: mapHeight=280 пробрасывается в SiteMap (mocked stub),
clipping-стили отсутствуют. 82/82 frontend тестов зелёные. tsc + lint clean.

Closes #1218
This commit is contained in:
Light1YT 2026-06-13 10:56:43 +05:00
parent 8f3e461959
commit ab45cacfbf
3 changed files with 100 additions and 18 deletions

View file

@ -126,6 +126,11 @@ interface Props {
// §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки (geom_wkt). // §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки (geom_wkt).
opportunityParcels?: OpportunityParcel[]; opportunityParcels?: OpportunityParcel[];
redLines?: RedLine[]; redLines?: RedLine[];
// #1218 — высота тайла карты (px). Default 420 — режим обычной странички
// /site-finder/[cad]. MiniMap на /analysis передаёт меньше (≈280), чтобы
// карта была компактнее. На высоту НЕ влияет на flow-контролы ниже
// (POI add button, легенда, CpLayerControlPanel) — они идут потоком.
mapHeight?: number;
} }
// Ключи переключаемых рыночных слоёв (#999 + §12.1-13 #958). // Ключи переключаемых рыночных слоёв (#999 + §12.1-13 #958).
@ -167,6 +172,7 @@ export function SiteMap({
riskZones, riskZones,
opportunityParcels, opportunityParcels,
redLines, redLines,
mapHeight = 420,
}: Props) { }: Props) {
// Fix Leaflet default icon paths broken by webpack bundler // Fix Leaflet default icon paths broken by webpack bundler
useEffect(() => { useEffect(() => {
@ -310,7 +316,7 @@ export function SiteMap({
border: "1px solid #e5e7eb", border: "1px solid #e5e7eb",
borderRadius: 12, borderRadius: 12,
overflow: "hidden", overflow: "hidden",
height: 420, height: mapHeight,
cursor: addMode ? "crosshair" : "grab", cursor: addMode ? "crosshair" : "grab",
}} }}
> >

View file

@ -1,9 +1,15 @@
"use client"; "use client";
/** /**
* MiniMap compact Leaflet map (400×320) showing parcel polygon + top POI. * MiniMap compact Leaflet map (400px wide) showing parcel polygon + top POI.
* Dynamic import of react-leaflet (ssr:false) to avoid SSR breakage. * Dynamic import of react-leaflet (ssr:false) to avoid SSR breakage.
* Wraps SiteMap with fixed dimensions for Section 1 layout. * Wraps SiteMap with constrained width for Section 1 layout.
*
* #1218: ранее обёртка имела `height: 320 + overflow: hidden` это клипало
* legend + CpLayerControlPanel, рендерящиеся flow-div ПОД картой в SiteMap,
* и делало недостижимыми переключатели рыночных слоёв (Будущие проекты,
* Зоны риска, Перспективные ЗУ, Красные линии). Фикс: задаём только ширину,
* высота тайла карты через prop `mapHeight` SiteMap; контент течёт вниз.
*/ */
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -11,6 +17,10 @@ import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import type { Geometry } from "geojson"; import type { Geometry } from "geojson";
// MiniMap высота тайла карты — компромисс между плотностью Section 1 и
// читаемостью. Layer-controls/легенда вне этого размера (flow ниже).
const MINIMAP_HEIGHT = 280;
// Lazy-mount to avoid SSR breakage (Leaflet uses window) // Lazy-mount to avoid SSR breakage (Leaflet uses window)
const SiteMap = dynamic( const SiteMap = dynamic(
() => () =>
@ -22,7 +32,7 @@ const SiteMap = dynamic(
loading: () => ( loading: () => (
<div <div
style={{ style={{
height: 320, height: MINIMAP_HEIGHT,
background: "var(--bg-card-alt)", background: "var(--bg-card-alt)",
borderRadius: 12, borderRadius: 12,
border: "1px solid var(--border-card)", border: "1px solid var(--border-card)",
@ -75,28 +85,27 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
export function MiniMap({ data }: Props) { export function MiniMap({ data }: Props) {
const adapted = toSiteMapData(data); const adapted = toSiteMapData(data);
// #1218: НЕ задаём height + overflow:hidden на обёртке. SiteMap сам
// ограничивает высоту карты-тайла (mapHeight), а легенда + layer-controls
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) лежат
// под картой потоком и должны быть видны/кликабельны.
return ( return (
<div <div
style={{ style={{
width: "100%", width: "100%",
maxWidth: 400, maxWidth: 400,
height: 320,
borderRadius: 12,
overflow: "hidden",
border: "1px solid var(--border-card)",
flexShrink: 0, flexShrink: 0,
}} }}
> >
<div style={{ height: 320 }}> <SiteMap
<SiteMap data={adapted}
data={adapted} competitors={data.competitors ?? []}
competitors={data.competitors ?? []} pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
pipelineObjects={data.pipeline_24mo?.top_objects ?? []} riskZones={data.nspd_risk_zones ?? []}
riskZones={data.nspd_risk_zones ?? []} opportunityParcels={data.nspd_opportunity_parcels ?? []}
opportunityParcels={data.nspd_opportunity_parcels ?? []} redLines={data.nspd_red_lines ?? []}
redLines={data.nspd_red_lines ?? []} mapHeight={MINIMAP_HEIGHT}
/> />
</div>
</div> </div>
); );
} }

View file

@ -0,0 +1,67 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MiniMap } from "../MiniMap";
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
// Stub SiteMap so we can assert the props MiniMap passes through (mapHeight
// in particular — #1218: ранее MiniMap клипал layer-панель/легенду через
// `overflow: hidden + height: 320`; теперь высота карты задаётся через prop).
// Stub does NOT use `unknown` — we capture a typed snapshot of the relevant
// props (mapHeight) for the assertion.
interface CapturedProps {
mapHeight?: number;
}
const captured: { last: CapturedProps | null } = { last: null };
vi.mock("@/components/site-finder/SiteMap", () => ({
SiteMap: (props: CapturedProps) => {
captured.last = { mapHeight: props.mapHeight };
return <div data-testid="sitemap-stub">map(h={props.mapHeight ?? "?"})</div>;
},
}));
// Minimal ParcelAnalyzeResponse — only fields MiniMap touches (toSiteMapData).
const FIXTURE: ParcelAnalyzeResponse = {
cad_num: "66:41:0000000:1",
source: "cad_building",
geom_geojson: null,
district: null,
score: 0,
score_breakdown: {},
poi_count: 0,
competitors: [],
pipeline_24mo: null,
nspd_risk_zones: [],
nspd_opportunity_parcels: [],
nspd_red_lines: [],
};
describe("MiniMap (#1218)", () => {
it("passes mapHeight=280 to SiteMap (compact tile, controls flow below)", async () => {
render(<MiniMap data={FIXTURE} />);
// SiteMap is dynamically imported (ssr:false) — wait until our stub renders.
await waitFor(() =>
expect(screen.getByTestId("sitemap-stub")).toBeInTheDocument(),
);
expect(captured.last?.mapHeight).toBe(280);
});
it("wrapper does NOT clip overflow — layer controls must remain visible", async () => {
const { container } = render(<MiniMap data={FIXTURE} />);
await waitFor(() =>
expect(screen.getByTestId("sitemap-stub")).toBeInTheDocument(),
);
// The outer wrapper is the first element child — SiteMap stub lives inside.
const wrapper = container.firstElementChild as HTMLElement | null;
expect(wrapper).not.toBeNull();
// No fixed height (must be empty so flow children — legend + control panel —
// are not clipped to 320px), and no overflow: hidden.
expect(wrapper?.style.height).toBe("");
expect(wrapper?.style.overflow).toBe("");
// Width constraint preserved (column slot is 400px in Section 1 grid).
expect(wrapper?.style.maxWidth).toBe("400px");
});
});