From b5f8f7b437d36466d7e840e970f0305e9b4b502d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 17:52:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(report):=20=C2=A77=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB?= =?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20+=20=D0=B2=D1=8B=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=BA=D0=B5=20+=20=D1=88=D0=B8=D1=80=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B0=20(#2179)=20(#2183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/concept/ConceptResultMap.tsx | 10 ++- .../src/components/concept/Massing3DScene.tsx | 83 +++++++++++++++---- .../site-finder/analysis/Section7Concept.tsx | 20 +++-- frontend/src/lib/__tests__/geo-local.test.ts | 20 +++++ frontend/src/lib/geo-local.ts | 7 +- 5 files changed, 109 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/concept/ConceptResultMap.tsx b/frontend/src/components/concept/ConceptResultMap.tsx index 0d50990e..e4563827 100644 --- a/frontend/src/components/concept/ConceptResultMap.tsx +++ b/frontend/src/components/concept/ConceptResultMap.tsx @@ -97,15 +97,17 @@ export function ConceptResultMap({ url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> - {/* Parcel boundary — outline only so buildings read on top. */} + {/* Parcel boundary — accent-soft fill + accent outline (#2179) so the + lot reads at a glance; buildings (opacity 0.55) still read on top of + the lighter 0.35 parcel fill. Colours mirror --accent / --accent-soft. */} diff --git a/frontend/src/components/concept/Massing3DScene.tsx b/frontend/src/components/concept/Massing3DScene.tsx index 48bcea65..21596f31 100644 --- a/frontend/src/components/concept/Massing3DScene.tsx +++ b/frontend/src/components/concept/Massing3DScene.tsx @@ -50,7 +50,8 @@ const FALLBACK_FLOORS = 12; const COLOR_BG = 0xf6f7f9; // --bg-app const COLOR_SLATE = 0x33424f; // building body const COLOR_ACCENT = 0x1d4ed8; // --accent (edge lines) -const COLOR_OUTLINE = 0xd1d5db; // --border-strong (parcel outline) +const COLOR_PARCEL_OUTLINE = 0x1d4ed8; // --accent (parcel outline — accent, #2179) +const COLOR_PARCEL_FILL = 0xdbeafe; // --accent-soft (parcel fill — #2179) const COLOR_GROUND_FALLBACK = 0xfafbfc; // --bg-card-alt const COLOR_GRID = 0xe6e8ec; // --border-card @@ -165,8 +166,11 @@ function buildModel( floorHeightM, ); + // Pad the ground tile generously (0.7 = +70% context each side, #2179) so the + // OSM substrate extends well beyond the parcel; the 4×4 tile cap below keeps it + // sharp at the same zoom. const rawBounds = ringBounds(parcelRingWgs); - const groundBounds = rawBounds ? padBounds(rawBounds, 0.3) : null; + const groundBounds = rawBounds ? padBounds(rawBounds, 0.7) : null; return { buildings, @@ -271,18 +275,61 @@ function buildBuildingsGroup(model: MassingModel): THREE.Group { return group; } -/** Parcel outline as a LineLoop at y=0.05 in world space. */ -function buildParcelOutline(model: MassingModel): THREE.Line { - const pts: number[] = []; - for (const p of model.parcelRing) { - pts.push(p.x, 0.05, p.z); +/** + * Parcel highlight (#2179): a translucent accent-soft FILL just above the ground + * plane plus a prominent accent OUTLINE. Returns a Group so the caller adds one + * object. WebGL ignores LineBasicMaterial.linewidth, so the outline is doubled at + * two heights (y=0.05, y=0.10) to read thick from any camera angle without a new + * dependency. + */ +function buildParcelHighlight(model: MassingModel): THREE.Group { + const group = new THREE.Group(); + group.name = "parcel"; + + // Fill: THREE.Shape from the parcel ring → ShapeGeometry, laid into the ground + // plane (rotate −π/2 about X so the XY shape sits on the XZ world plane) and + // lifted to y≈0.03 (above the ground substrate at y=−0.02, under the outline). + if (model.parcelRing.length >= 3) { + const shape = new THREE.Shape(); + shape.moveTo(model.parcelRing[0].x, model.parcelRing[0].z); + for (let i = 1; i < model.parcelRing.length; i++) { + shape.lineTo(model.parcelRing[i].x, model.parcelRing[i].z); + } + shape.closePath(); + const fillGeo = new THREE.ShapeGeometry(shape); + const fill = new THREE.Mesh( + fillGeo, + new THREE.MeshBasicMaterial({ + color: COLOR_PARCEL_FILL, // --accent-soft + transparent: true, + opacity: 0.35, + depthWrite: false, + side: THREE.DoubleSide, + }), + ); + // Shape is built in XY; rotate to lie in the ground (XZ) plane. After the + // −π/2 X-rotation shape.y (= parcel z) maps to +z, so no mirroring. + fill.rotation.x = -Math.PI / 2; + fill.position.y = 0.03; + group.add(fill); } - const geo = new THREE.BufferGeometry(); - geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); - return new THREE.Line( - geo, - new THREE.LineBasicMaterial({ color: COLOR_OUTLINE }), - ); + + // Outline: accent LineLoop, doubled at y=0.05 and y=0.10 (linewidth is a no-op + // in WebGL) so the parcel edge reads prominently. + const outlineMat = new THREE.LineBasicMaterial({ + color: COLOR_PARCEL_OUTLINE, + }); + for (const y of [0.05, 0.1]) { + const pts: number[] = []; + for (const p of model.parcelRing) { + pts.push(p.x, y, p.z); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); + group.add(new THREE.LineLoop(geo, outlineMat)); + } + + return group; } // ── OSM ground tile (fetch + stitch + CanvasTexture) ────────────────────────── @@ -317,7 +364,9 @@ async function loadGroundTexture( signal: { cancelled: boolean }, ): Promise { if (!model.groundBounds) return null; - const range = osmTileRangeForBbox(model.groundBounds); + // Allow up to a 4×4 (16-tile) block (#2179): a larger extent WITHOUT dropping + // zoom, so the substrate stays sharp while covering more surroundings. + const range = osmTileRangeForBbox(model.groundBounds, { maxTiles: 16 }); const canvas = document.createElement("canvas"); canvas.width = 256 * range.cols; @@ -503,9 +552,9 @@ export default function Massing3DScene({ gridMat.transparent = true; scene.add(grid); - // parcel outline - const outline = buildParcelOutline(model); - scene.add(outline); + // parcel highlight (accent-soft fill + accent outline, #2179) + const parcel = buildParcelHighlight(model); + scene.add(parcel); // buildings const buildings = buildBuildingsGroup(model); diff --git a/frontend/src/components/site-finder/analysis/Section7Concept.tsx b/frontend/src/components/site-finder/analysis/Section7Concept.tsx index c2993e5d..7824c007 100644 --- a/frontend/src/components/site-finder/analysis/Section7Concept.tsx +++ b/frontend/src/components/site-finder/analysis/Section7Concept.tsx @@ -23,9 +23,10 @@ * • land_cost_rub ← egrn.cadastral_value_rub * Falls back to comfort / mid_rise when those are null. * - * The section defaults to collapsed (above-the-fold rule). On first expand it - * auto-runs once with the default program (count = 3) so the user lands on a - * result, not an empty form. + * The section is OPEN by default (Anton feedback 2026-07-02, issue #2179): the + * concept is the report's key deliverable. It auto-runs once on mount with the + * default program (count = 3) so the user lands on a result, not an empty form. + * The
can still be collapsed via its summary. * * СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a * polygon + form inputs (independent of the financial_estimate regulatory gate), @@ -205,7 +206,11 @@ export function Section7Concept({ analysis }: Props) { // when it becomes ready (still only once, still only after first open). The //
flips `expanded`, which (re)triggers the effect. const autoRanRef = useRef(false); - const [expanded, setExpanded] = useState(false); + // §7 «Концепция» is OPEN by default (Anton feedback 2026-07-02, issue #2179): + // the concept is the report's key deliverable, so the auto-run fires on mount + // instead of waiting for a first expand. The user can still collapse the + //
via the summary; onToggle keeps `expanded` in sync when reopened. + const [expanded, setExpanded] = useState(true); useEffect(() => { if (autoRanRef.current || !expanded) return; @@ -242,9 +247,10 @@ export function Section7Concept({ analysis }: Props) { subtitle="Единая программа застройки: задайте число корпусов, этажность и класс — движок разложит ровно эту программу и посчитает ТЭП и финмодель по геометрии участка." /> - {/* Collapsed by default (above-the-fold rule). Auto-runs once on first - expand so the user lands on a result, not an empty form. */} -
+ {/* Open by default (Anton feedback 2026-07-02, issue #2179): концепция — + ключевой deliverable отчёта, поэтому §7 раскрыт и авторасчёт стартует + сразу на маунте. Пользователь может свернуть блок через summary. */} +
{ expect(r.northLat).toBeGreaterThanOrEqual(bbox.maxLat); expect(r.southLat).toBeLessThanOrEqual(bbox.minLat); }); + + it("honours a 4×4 (16-tile) cap and holds a higher zoom than the 2×2 cap (#2179)", () => { + // A wider ~700 m-padded bbox around EKB centre (mirrors padBounds(_, 0.7)). + const bbox = { + minLon: LON0 - 0.006, + maxLon: LON0 + 0.006, + minLat: LAT0 - 0.004, + maxLat: LAT0 + 0.004, + }; + const r16 = osmTileRangeForBbox(bbox, { maxTiles: 16 }); + const r4 = osmTileRangeForBbox(bbox, { maxTiles: 4 }); + expect(r16.cols * r16.rows).toBeLessThanOrEqual(16); + // A larger tile budget keeps a zoom ≥ the 2×2 cap (never sharper-losing). + expect(r16.z).toBeGreaterThanOrEqual(r4.z); + // The stitched block must still fully contain the bbox. + expect(r16.westLon).toBeLessThanOrEqual(bbox.minLon); + expect(r16.eastLon).toBeGreaterThanOrEqual(bbox.maxLon); + expect(r16.northLat).toBeGreaterThanOrEqual(bbox.maxLat); + expect(r16.southLat).toBeLessThanOrEqual(bbox.minLat); + }); }); diff --git a/frontend/src/lib/geo-local.ts b/frontend/src/lib/geo-local.ts index bb83b781..6ba06e61 100644 --- a/frontend/src/lib/geo-local.ts +++ b/frontend/src/lib/geo-local.ts @@ -130,9 +130,10 @@ export interface TileRange { } /** - * Pick the OSM zoom for a bbox such that the stitched tile block stays ≤ maxTiles - * (default 4 → a 2×2 cap), clamped to [minZ, maxZ]. Starts at maxZ and steps the - * zoom down until the tile count fits. + * Pick the OSM zoom for a bbox such that the stitched tile block stays ≤ maxTiles, + * clamped to [minZ, maxZ]. Starts at maxZ and steps the zoom down until the tile + * count fits. `maxTiles` defaults to 4 (a 2×2 cap); the massing scene passes 16 + * (a 4×4 cap, #2179) so a larger surrounding extent renders WITHOUT dropping zoom. * * Returns the integer tile range plus the EXACT WGS84 corners of the stitched * block (tile2lon/lat of the inclusive range), which the scene projects with the