This commit is contained in:
parent
509cecfa6a
commit
b5f8f7b437
5 changed files with 109 additions and 31 deletions
|
|
@ -97,15 +97,17 @@ export function ConceptResultMap({
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
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. */}
|
||||||
<GeoJSON
|
<GeoJSON
|
||||||
key={`parcel-${variantKey}`}
|
key={`parcel-${variantKey}`}
|
||||||
data={parcel}
|
data={parcel}
|
||||||
style={{
|
style={{
|
||||||
color: "#D1D5DB",
|
color: "#1D4ED8", // --accent
|
||||||
weight: 2,
|
weight: 2,
|
||||||
dashArray: "6 4",
|
fillColor: "#DBEAFE", // --accent-soft
|
||||||
fillOpacity: 0,
|
fillOpacity: 0.35,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ const FALLBACK_FLOORS = 12;
|
||||||
const COLOR_BG = 0xf6f7f9; // --bg-app
|
const COLOR_BG = 0xf6f7f9; // --bg-app
|
||||||
const COLOR_SLATE = 0x33424f; // building body
|
const COLOR_SLATE = 0x33424f; // building body
|
||||||
const COLOR_ACCENT = 0x1d4ed8; // --accent (edge lines)
|
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_GROUND_FALLBACK = 0xfafbfc; // --bg-card-alt
|
||||||
const COLOR_GRID = 0xe6e8ec; // --border-card
|
const COLOR_GRID = 0xe6e8ec; // --border-card
|
||||||
|
|
||||||
|
|
@ -165,8 +166,11 @@ function buildModel(
|
||||||
floorHeightM,
|
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 rawBounds = ringBounds(parcelRingWgs);
|
||||||
const groundBounds = rawBounds ? padBounds(rawBounds, 0.3) : null;
|
const groundBounds = rawBounds ? padBounds(rawBounds, 0.7) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildings,
|
buildings,
|
||||||
|
|
@ -271,18 +275,61 @@ function buildBuildingsGroup(model: MassingModel): THREE.Group {
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parcel outline as a LineLoop at y=0.05 in world space. */
|
/**
|
||||||
function buildParcelOutline(model: MassingModel): THREE.Line {
|
* Parcel highlight (#2179): a translucent accent-soft FILL just above the ground
|
||||||
const pts: number[] = [];
|
* plane plus a prominent accent OUTLINE. Returns a Group so the caller adds one
|
||||||
for (const p of model.parcelRing) {
|
* object. WebGL ignores LineBasicMaterial.linewidth, so the outline is doubled at
|
||||||
pts.push(p.x, 0.05, p.z);
|
* 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));
|
// Outline: accent LineLoop, doubled at y=0.05 and y=0.10 (linewidth is a no-op
|
||||||
return new THREE.Line(
|
// in WebGL) so the parcel edge reads prominently.
|
||||||
geo,
|
const outlineMat = new THREE.LineBasicMaterial({
|
||||||
new THREE.LineBasicMaterial({ color: COLOR_OUTLINE }),
|
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) ──────────────────────────
|
// ── OSM ground tile (fetch + stitch + CanvasTexture) ──────────────────────────
|
||||||
|
|
@ -317,7 +364,9 @@ async function loadGroundTexture(
|
||||||
signal: { cancelled: boolean },
|
signal: { cancelled: boolean },
|
||||||
): Promise<GroundTexResult | null> {
|
): Promise<GroundTexResult | null> {
|
||||||
if (!model.groundBounds) return null;
|
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");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = 256 * range.cols;
|
canvas.width = 256 * range.cols;
|
||||||
|
|
@ -503,9 +552,9 @@ export default function Massing3DScene({
|
||||||
gridMat.transparent = true;
|
gridMat.transparent = true;
|
||||||
scene.add(grid);
|
scene.add(grid);
|
||||||
|
|
||||||
// parcel outline
|
// parcel highlight (accent-soft fill + accent outline, #2179)
|
||||||
const outline = buildParcelOutline(model);
|
const parcel = buildParcelHighlight(model);
|
||||||
scene.add(outline);
|
scene.add(parcel);
|
||||||
|
|
||||||
// buildings
|
// buildings
|
||||||
const buildings = buildBuildingsGroup(model);
|
const buildings = buildBuildingsGroup(model);
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@
|
||||||
* • land_cost_rub ← egrn.cadastral_value_rub
|
* • land_cost_rub ← egrn.cadastral_value_rub
|
||||||
* Falls back to comfort / mid_rise when those are null.
|
* Falls back to comfort / mid_rise when those are null.
|
||||||
*
|
*
|
||||||
* The section defaults to collapsed (above-the-fold rule). On first expand it
|
* The section is OPEN by default (Anton feedback 2026-07-02, issue #2179): the
|
||||||
* auto-runs once with the default program (count = 3) so the user lands on a
|
* concept is the report's key deliverable. It auto-runs once on mount with the
|
||||||
* result, not an empty form.
|
* default program (count = 3) so the user lands on a result, not an empty form.
|
||||||
|
* The <details> can still be collapsed via its summary.
|
||||||
*
|
*
|
||||||
* СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a
|
* СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a
|
||||||
* polygon + form inputs (independent of the financial_estimate regulatory gate),
|
* 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
|
// when it becomes ready (still only once, still only after first open). The
|
||||||
// <details onToggle> flips `expanded`, which (re)triggers the effect.
|
// <details onToggle> flips `expanded`, which (re)triggers the effect.
|
||||||
const autoRanRef = useRef(false);
|
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
|
||||||
|
// <details> via the summary; onToggle keeps `expanded` in sync when reopened.
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRanRef.current || !expanded) return;
|
if (autoRanRef.current || !expanded) return;
|
||||||
|
|
@ -242,9 +247,10 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
subtitle="Единая программа застройки: задайте число корпусов, этажность и класс — движок разложит ровно эту программу и посчитает ТЭП и финмодель по геометрии участка."
|
subtitle="Единая программа застройки: задайте число корпусов, этажность и класс — движок разложит ровно эту программу и посчитает ТЭП и финмодель по геометрии участка."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Collapsed by default (above-the-fold rule). Auto-runs once on first
|
{/* Open by default (Anton feedback 2026-07-02, issue #2179): концепция —
|
||||||
expand so the user lands on a result, not an empty form. */}
|
ключевой deliverable отчёта, поэтому §7 раскрыт и авторасчёт стартует
|
||||||
<details style={{ marginTop: 12 }} onToggle={handleToggle}>
|
сразу на маунте. Пользователь может свернуть блок через summary. */}
|
||||||
|
<details open style={{ marginTop: 12 }} onToggle={handleToggle}>
|
||||||
<summary
|
<summary
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
|
|
||||||
|
|
@ -135,4 +135,24 @@ describe("osmTileRangeForBbox", () => {
|
||||||
expect(r.northLat).toBeGreaterThanOrEqual(bbox.maxLat);
|
expect(r.northLat).toBeGreaterThanOrEqual(bbox.maxLat);
|
||||||
expect(r.southLat).toBeLessThanOrEqual(bbox.minLat);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,10 @@ export interface TileRange {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the OSM zoom for a bbox such that the stitched tile block stays ≤ maxTiles
|
* 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
|
* clamped to [minZ, maxZ]. Starts at maxZ and steps the zoom down until the tile
|
||||||
* zoom down until the tile count fits.
|
* 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
|
* 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
|
* block (tile2lon/lat of the inclusive range), which the scene projects with the
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue