This commit is contained in:
parent
53b160ff0a
commit
1fa99812af
8 changed files with 405 additions and 79 deletions
|
|
@ -9,7 +9,12 @@
|
|||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
|
||||
import type { FeatureCollection, Polygon, Position } from "geojson";
|
||||
import type {
|
||||
MultiPolygon,
|
||||
Polygon,
|
||||
FeatureCollection,
|
||||
Position,
|
||||
} from "geojson";
|
||||
import L from "leaflet";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
|
@ -18,7 +23,16 @@ import { useNeighborBuildings } from "@/hooks/useNeighborBuildings";
|
|||
|
||||
// ── Bounds helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ringBounds(rings: Position[][]): L.LatLngBoundsExpression | null {
|
||||
/**
|
||||
* Union LatLng bounds over the parcel geometry. Handles Polygon (rings =
|
||||
* Position[][]) AND MultiPolygon (parts = Position[][][], flattened to all rings)
|
||||
* so a multi-part parcel fits to its WHOLE extent, not just part 0.
|
||||
*/
|
||||
function geometryBounds(
|
||||
geom: Polygon | MultiPolygon,
|
||||
): L.LatLngBoundsExpression | null {
|
||||
const rings: Position[][] =
|
||||
geom.type === "MultiPolygon" ? geom.coordinates.flat() : geom.coordinates;
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
|
|
@ -56,7 +70,8 @@ function FitBounds({ bounds }: FitBoundsProps) {
|
|||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
parcel: Polygon;
|
||||
/** Parcel geometry — Polygon OR MultiPolygon (all parts drawn + fit to). */
|
||||
parcel: Polygon | MultiPolygon;
|
||||
buildings: FeatureCollection;
|
||||
/** Keying makes react-leaflet remount the buildings layer on variant switch. */
|
||||
variantKey: string;
|
||||
|
|
@ -78,10 +93,7 @@ export function ConceptResultMap({
|
|||
// Memoize so FitBounds only re-fits when the parcel geometry actually
|
||||
// changes — recomputing inline returns a new array each render, which would
|
||||
// make the fitBounds effect fire on every re-render and discard manual zoom/pan.
|
||||
const bounds = useMemo(
|
||||
() => ringBounds(parcel.coordinates as Position[][]),
|
||||
[parcel.coordinates],
|
||||
);
|
||||
const bounds = useMemo(() => geometryBounds(parcel), [parcel]);
|
||||
const hasBuildings = buildings.features.length > 0;
|
||||
|
||||
// Соседние здания (#2180): контекст квартала под слоем корпусов. enabled только
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
type ConceptVariant,
|
||||
type FinancialModel,
|
||||
} from "@/lib/concept-api";
|
||||
import type { Polygon } from "geojson";
|
||||
import type { MultiPolygon, Polygon } from "geojson";
|
||||
|
||||
import { ConceptExportButtons } from "./ConceptExportButtons";
|
||||
|
||||
|
|
@ -214,7 +214,8 @@ function PlacementTab({
|
|||
// ── Variant panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface PanelProps {
|
||||
parcel: Polygon;
|
||||
/** Parcel geometry — Polygon OR MultiPolygon (whole geometry, all parts). */
|
||||
parcel: Polygon | MultiPolygon;
|
||||
variant: ConceptVariant;
|
||||
/** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */
|
||||
floorHeightM?: number;
|
||||
|
|
@ -723,7 +724,14 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
|||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
parcel: Polygon;
|
||||
/**
|
||||
* Parcel geometry for the placement views — Polygon OR MultiPolygon. The §7
|
||||
* report passes the WHOLE geometry (`extractParcelGeometry`) so the 2D/3D
|
||||
* scenes render every part and stay geo-aligned with backend-anchored
|
||||
* neighbours; the standalone /concept page passes its single drawn/resolved
|
||||
* Polygon.
|
||||
*/
|
||||
parcel: Polygon | MultiPolygon;
|
||||
variants: ConceptVariant[];
|
||||
/**
|
||||
* Visual floor height (m) for the 3D massing viewer; default 3.0 = backend's
|
||||
|
|
|
|||
|
|
@ -33,8 +33,9 @@ import {
|
|||
makeProjector,
|
||||
osmTileRangeForBbox,
|
||||
padBounds,
|
||||
ringBounds,
|
||||
ringCentroid,
|
||||
polygonOuterRings,
|
||||
ringsBounds,
|
||||
ringsCentroid,
|
||||
type Bounds,
|
||||
type Projector,
|
||||
} from "@/lib/geo-local";
|
||||
|
|
@ -73,8 +74,13 @@ const TILE_TIMEOUT_MS = 6000;
|
|||
// ── props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Massing3DSceneProps {
|
||||
/** WGS84 parcel polygon — outer ring used for centroid + outline. */
|
||||
parcel: Polygon;
|
||||
/**
|
||||
* WGS84 parcel geometry — Polygon OR MultiPolygon. Every part's outer ring
|
||||
* drives the projection origin, OSM ground bounds and outline, so a multi-part
|
||||
* parcel shares the whole-geometry frame the backend anchors neighbours to
|
||||
* (otherwise context around a distant part lands offset from part 0).
|
||||
*/
|
||||
parcel: Polygon | MultiPolygon;
|
||||
/** The variant — `buildings_geojson` is the single source of corpuses. */
|
||||
variant: ConceptVariant;
|
||||
/** Visual floor height in metres; default 3.0 (the backend's constant). */
|
||||
|
|
@ -97,8 +103,9 @@ interface BuildingLocal {
|
|||
|
||||
interface MassingModel {
|
||||
buildings: BuildingLocal[];
|
||||
parcelRing: { x: number; z: number }[];
|
||||
/** Local-metre bbox over all building rings (falls back to parcel ring). */
|
||||
/** Every parcel part's outer ring, projected to local metres (one per part). */
|
||||
parcelRings: { x: number; z: number }[][];
|
||||
/** Local-metre bbox over all parcel parts + building rings. */
|
||||
bbox: { minX: number; maxX: number; minZ: number; maxZ: number };
|
||||
centerX: number;
|
||||
centerZ: number;
|
||||
|
|
@ -121,12 +128,13 @@ function featureFloors(
|
|||
}
|
||||
|
||||
function buildModel(
|
||||
parcel: Polygon,
|
||||
parcel: Polygon | MultiPolygon,
|
||||
variant: ConceptVariant,
|
||||
floorHeightM: number,
|
||||
): MassingModel {
|
||||
const parcelRingWgs = (parcel.coordinates?.[0] ?? []) as Position[];
|
||||
const [lon0, lat0] = ringCentroid(parcelRingWgs);
|
||||
// Whole-parcel-aware: outer ring of EVERY part (a bare Polygon → one ring).
|
||||
const parcelRingsWgs = polygonOuterRings(parcel);
|
||||
const [lon0, lat0] = ringsCentroid(parcelRingsWgs);
|
||||
const project = makeProjector(lon0, lat0);
|
||||
|
||||
const features = variant.buildings_geojson?.features ?? [];
|
||||
|
|
@ -155,15 +163,15 @@ function buildModel(
|
|||
});
|
||||
}
|
||||
|
||||
const parcelRing = parcelRingWgs.map(project);
|
||||
const parcelRings = parcelRingsWgs.map((ring) => ring.map(project));
|
||||
|
||||
// bbox over building rings (preferred) or the parcel ring as a fallback.
|
||||
// bbox over EVERY parcel part + all building rings, so the camera frames the
|
||||
// whole lot and its corpuses (a distant part is no longer cropped out).
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minZ = Infinity;
|
||||
let maxZ = -Infinity;
|
||||
const pts =
|
||||
buildings.length > 0 ? buildings.flatMap((b) => b.ring) : parcelRing;
|
||||
const pts = [...parcelRings.flat(), ...buildings.flatMap((b) => b.ring)];
|
||||
for (const p of pts) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
|
|
@ -186,13 +194,15 @@ function buildModel(
|
|||
|
||||
// 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);
|
||||
// sharp at the same zoom. Union bounds over ALL parts → the tile covers every
|
||||
// part so neighbours around any of them land on it (a genuinely disjoint
|
||||
// multi-part parcel yields a large, lower-detail tile — correct, not a bug).
|
||||
const rawBounds = ringsBounds(parcelRingsWgs);
|
||||
const groundBounds = rawBounds ? padBounds(rawBounds, 0.7) : null;
|
||||
|
||||
return {
|
||||
buildings,
|
||||
parcelRing,
|
||||
parcelRings,
|
||||
bbox: { minX, maxX, minZ, maxZ },
|
||||
centerX,
|
||||
centerZ,
|
||||
|
|
@ -295,23 +305,33 @@ function buildBuildingsGroup(model: MassingModel): THREE.Group {
|
|||
|
||||
/**
|
||||
* 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
|
||||
* plane plus a prominent accent OUTLINE — drawn for EVERY parcel part (a bare
|
||||
* Polygon → one ring; a MultiPolygon → one fill + outline per part) so the whole
|
||||
* lot reads, not just part 0. 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
|
||||
// One shared outline material across every part (linewidth is a no-op in WebGL,
|
||||
// so each part's outline is doubled at y=0.05 and y=0.10 to read prominently).
|
||||
const outlineMat = new THREE.LineBasicMaterial({
|
||||
color: COLOR_PARCEL_OUTLINE,
|
||||
});
|
||||
|
||||
for (const ring of model.parcelRings) {
|
||||
if (ring.length < 3) continue;
|
||||
|
||||
// Fill: THREE.Shape from the part 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.moveTo(ring[0].x, ring[0].z);
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
shape.lineTo(ring[i].x, ring[i].z);
|
||||
}
|
||||
shape.closePath();
|
||||
const fillGeo = new THREE.ShapeGeometry(shape);
|
||||
|
|
@ -330,22 +350,18 @@ function buildParcelHighlight(model: MassingModel): THREE.Group {
|
|||
fill.rotation.x = -Math.PI / 2;
|
||||
fill.position.y = 0.03;
|
||||
group.add(fill);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
// Outline: accent LineLoop, doubled at y=0.05 and y=0.10.
|
||||
for (const y of [0.05, 0.1]) {
|
||||
const pts: number[] = [];
|
||||
for (const p of model.parcelRing) {
|
||||
for (const p of ring) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
type ConceptProgramFormState,
|
||||
} from "@/components/concept/ConceptProgramForm";
|
||||
import {
|
||||
extractParcelGeometry,
|
||||
extractPolygon,
|
||||
polygonAreaSqm,
|
||||
useCreateConcept,
|
||||
|
|
@ -169,13 +170,25 @@ export function Section7Concept({
|
|||
onProgramApplied,
|
||||
onPendingChange,
|
||||
}: Props) {
|
||||
// Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon /
|
||||
// null → null; MultiPolygon collapses to its first ring per extractPolygon).
|
||||
// Parcel polygon for the /concepts REQUEST — a single Polygon (MultiPolygon
|
||||
// collapses to its LARGEST-area part per extractPolygon, so the generated corpus
|
||||
// sits on the dominant part, not a sliver).
|
||||
const polygon = useMemo(
|
||||
() => extractPolygon(analysis.geom_geojson),
|
||||
[analysis.geom_geojson],
|
||||
);
|
||||
|
||||
// WHOLE parcel geometry for the 2D/3D placement scenes — Polygon OR
|
||||
// MultiPolygon preserving every part. The scenes derive their projection origin,
|
||||
// OSM tile bounds and outline over ALL parts so neighbour context (anchored by
|
||||
// the backend to the entire parcel geometry) lands geo-aligned rather than
|
||||
// offset from part 0. Null only when the geometry itself is missing (same as
|
||||
// `polygon`), so the parcel-present branch below always has both.
|
||||
const parcelGeometry = useMemo(
|
||||
() => extractParcelGeometry(analysis.geom_geojson),
|
||||
[analysis.geom_geojson],
|
||||
);
|
||||
|
||||
// Seed defaults from the analysis: inferred class/type + cadastral value.
|
||||
// financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет
|
||||
// зоны) — fall back to comfort / mid_rise so a manual run is still possible.
|
||||
|
|
@ -455,7 +468,7 @@ export function Section7Concept({
|
|||
|
||||
{concept.isSuccess && (
|
||||
<ConceptVariantsResult
|
||||
parcel={polygon}
|
||||
parcel={parcelGeometry ?? polygon}
|
||||
variants={concept.data.variants}
|
||||
cadNum={analysis.cad_num}
|
||||
/>
|
||||
|
|
|
|||
102
frontend/src/lib/__tests__/concept-api.test.ts
Normal file
102
frontend/src/lib/__tests__/concept-api.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Unit tests for the parcel-geometry narrowers in concept-api.ts.
|
||||
*
|
||||
* These guard the §7 «Размещение застройки» multi-part fix:
|
||||
* • `extractPolygon` (the /concepts REQUEST geometry) must collapse a
|
||||
* MultiPolygon to its LARGEST-area part, not `coordinates[0]` (which can be a
|
||||
* sliver), so the generated corpus sits on the dominant part.
|
||||
* • `extractParcelGeometry` (the SCENE geometry) must preserve EVERY part so the
|
||||
* outline / projection origin / OSM tile cover the whole parcel and stay
|
||||
* geo-aligned with backend-anchored neighbours.
|
||||
*/
|
||||
import type { MultiPolygon, Polygon, Position } from "geojson";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractParcelGeometry, extractPolygon } from "../concept-api";
|
||||
|
||||
// Part 0: a small sliver near the origin (≈55 m × 55 m).
|
||||
const SLIVER: Position[] = [
|
||||
[60.6, 56.83],
|
||||
[60.6005, 56.83],
|
||||
[60.6005, 56.8305],
|
||||
[60.6, 56.8305],
|
||||
[60.6, 56.83],
|
||||
];
|
||||
// Part 1: a much larger square ~1 km east (the dominant part).
|
||||
const MAIN: Position[] = [
|
||||
[60.616, 56.83],
|
||||
[60.62, 56.83],
|
||||
[60.62, 56.834],
|
||||
[60.616, 56.834],
|
||||
[60.616, 56.83],
|
||||
];
|
||||
|
||||
const MULTI: MultiPolygon = {
|
||||
type: "MultiPolygon",
|
||||
coordinates: [[SLIVER], [MAIN]],
|
||||
};
|
||||
|
||||
describe("extractPolygon — /concepts request geometry", () => {
|
||||
it("returns a bare Polygon unchanged", () => {
|
||||
const poly: Polygon = { type: "Polygon", coordinates: [MAIN] };
|
||||
expect(extractPolygon(poly)).toEqual(poly);
|
||||
});
|
||||
|
||||
it("unwraps a Feature wrapping a Polygon", () => {
|
||||
const result = extractPolygon({
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: { type: "Polygon", coordinates: [MAIN] },
|
||||
});
|
||||
expect(result).toEqual({ type: "Polygon", coordinates: [MAIN] });
|
||||
});
|
||||
|
||||
it("collapses a MultiPolygon to its LARGEST part, not coordinates[0]", () => {
|
||||
const result = extractPolygon(MULTI);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe("Polygon");
|
||||
// Must pick MAIN (part 1), NOT the SLIVER at index 0.
|
||||
expect(result?.coordinates).toEqual([MAIN]);
|
||||
expect(result?.coordinates).not.toEqual([SLIVER]);
|
||||
});
|
||||
|
||||
it("returns null on a non-polygonal / malformed shape", () => {
|
||||
expect(extractPolygon(null)).toBeNull();
|
||||
expect(extractPolygon({ type: "Point", coordinates: [1, 2] })).toBeNull();
|
||||
expect(extractPolygon("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractParcelGeometry — whole scene geometry", () => {
|
||||
it("returns a bare Polygon unchanged", () => {
|
||||
const poly: Polygon = { type: "Polygon", coordinates: [MAIN] };
|
||||
expect(extractParcelGeometry(poly)).toEqual(poly);
|
||||
});
|
||||
|
||||
it("preserves EVERY part of a MultiPolygon (both parts, not just part 0)", () => {
|
||||
const result = extractParcelGeometry(MULTI);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe("MultiPolygon");
|
||||
const mp = result as MultiPolygon;
|
||||
expect(mp.coordinates).toHaveLength(2);
|
||||
expect(mp.coordinates[0]).toEqual([SLIVER]);
|
||||
expect(mp.coordinates[1]).toEqual([MAIN]);
|
||||
});
|
||||
|
||||
it("unwraps a Feature wrapping a MultiPolygon", () => {
|
||||
const result = extractParcelGeometry({
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: MULTI,
|
||||
});
|
||||
expect(result?.type).toBe("MultiPolygon");
|
||||
expect((result as MultiPolygon).coordinates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns null on a non-polygonal / malformed shape", () => {
|
||||
expect(extractParcelGeometry(null)).toBeNull();
|
||||
expect(
|
||||
extractParcelGeometry({ type: "LineString", coordinates: [] }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -15,8 +15,11 @@ import {
|
|||
lon2tile,
|
||||
makeProjector,
|
||||
osmTileRangeForBbox,
|
||||
polygonOuterRings,
|
||||
ringBounds,
|
||||
ringCentroid,
|
||||
ringsBounds,
|
||||
ringsCentroid,
|
||||
tile2lat,
|
||||
tile2lon,
|
||||
} from "../geo-local";
|
||||
|
|
@ -84,6 +87,77 @@ describe("ringCentroid + ringBounds", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("whole-parcel helpers (multi-part MultiPolygon)", () => {
|
||||
// A MultiPolygon whose FIRST part is a small sliver at the origin and whose
|
||||
// SECOND part is a larger square ~1 km east — the shape that displaced context
|
||||
// in the §7 massing scene when only part 0 drove the frame (163 prod parcels,
|
||||
// max 1611 m offset). Rings are closed (first == last).
|
||||
const PART0_SLIVER: Position[] = [
|
||||
[60.6, 56.83],
|
||||
[60.6005, 56.83],
|
||||
[60.6005, 56.8305],
|
||||
[60.6, 56.8305],
|
||||
[60.6, 56.83],
|
||||
];
|
||||
// Larger square ~1 km east (lon +0.016 ≈ 970 m at this latitude).
|
||||
const PART1_MAIN: Position[] = [
|
||||
[60.616, 56.83],
|
||||
[60.62, 56.83],
|
||||
[60.62, 56.834],
|
||||
[60.616, 56.834],
|
||||
[60.616, 56.83],
|
||||
];
|
||||
const RINGS = [PART0_SLIVER, PART1_MAIN];
|
||||
|
||||
it("polygonOuterRings returns every part's outer ring for a MultiPolygon", () => {
|
||||
const rings = polygonOuterRings({
|
||||
type: "MultiPolygon",
|
||||
coordinates: [[PART0_SLIVER], [PART1_MAIN]],
|
||||
});
|
||||
expect(rings).toHaveLength(2);
|
||||
expect(rings[0]).toBe(PART0_SLIVER);
|
||||
expect(rings[1]).toBe(PART1_MAIN);
|
||||
});
|
||||
|
||||
it("polygonOuterRings returns the single outer ring for a bare Polygon", () => {
|
||||
const rings = polygonOuterRings({
|
||||
type: "Polygon",
|
||||
coordinates: [PART1_MAIN],
|
||||
});
|
||||
expect(rings).toHaveLength(1);
|
||||
expect(rings[0]).toBe(PART1_MAIN);
|
||||
});
|
||||
|
||||
it("ringsCentroid is the WHOLE-geometry centre, well east of part 0's", () => {
|
||||
const whole = ringsCentroid(RINGS);
|
||||
const part0 = ringCentroid(PART0_SLIVER);
|
||||
// Part 0 sits at lon ≈ 60.60025; the whole-geometry centroid is pulled east
|
||||
// toward the larger second part — the core of the fix.
|
||||
expect(part0[0]).toBeCloseTo(60.60025, 4);
|
||||
expect(whole[0]).toBeGreaterThan(part0[0] + 0.005);
|
||||
// It must fall between the two parts, not on either alone.
|
||||
expect(whole[0]).toBeGreaterThan(60.6);
|
||||
expect(whole[0]).toBeLessThan(60.62);
|
||||
});
|
||||
|
||||
it("ringsBounds unions ALL parts (so the OSM tile covers every part)", () => {
|
||||
const b = ringsBounds(RINGS);
|
||||
expect(b).not.toBeNull();
|
||||
// Spans from part 0's west edge to part 1's east edge.
|
||||
expect(b?.minLon).toBeCloseTo(60.6, 6);
|
||||
expect(b?.maxLon).toBeCloseTo(60.62, 6);
|
||||
expect(b?.minLat).toBeCloseTo(56.83, 6);
|
||||
expect(b?.maxLat).toBeCloseTo(56.834, 6);
|
||||
// A part-0-only bound would stop at 60.6005 — assert we went well past it.
|
||||
expect(b?.maxLon).toBeGreaterThan(60.6005 + 0.01);
|
||||
});
|
||||
|
||||
it("ringBounds / ringCentroid still delegate correctly for one ring", () => {
|
||||
expect(ringBounds(PART1_MAIN)).toEqual(ringsBounds([PART1_MAIN]));
|
||||
expect(ringCentroid(PART1_MAIN)).toEqual(ringsCentroid([PART1_MAIN]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("slippy-tile math", () => {
|
||||
it("known tile indices at z=0 / z=1", () => {
|
||||
// The whole world is one tile at z=0.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@
|
|||
*/
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
MultiPolygon,
|
||||
Polygon,
|
||||
} from "geojson";
|
||||
|
||||
import { apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||
|
||||
|
|
@ -377,8 +382,15 @@ export function useCadastreGeom() {
|
|||
|
||||
/**
|
||||
* Narrows an unknown GeoJSON value (e.g. the analyze endpoint's `geom_geojson`)
|
||||
* into a Polygon. Accepts a bare Polygon, a Feature wrapping a Polygon, or the
|
||||
* first polygon ring of a MultiPolygon. Returns null on any other shape.
|
||||
* into a single Polygon — the geometry POSTed as `parcel_geojson` to /concepts.
|
||||
* Accepts a bare Polygon, a Feature wrapping one, or a MultiPolygon (collapsed to
|
||||
* its LARGEST-area sub-polygon, NOT `coordinates[0]`: for a multi-part parcel the
|
||||
* first part can be a sliver, so the generative corpus must sit on the dominant
|
||||
* part). Returns null on any other shape.
|
||||
*
|
||||
* NB: the massing SCENE (outline / projection origin / OSM tile) must instead use
|
||||
* the WHOLE geometry via `extractParcelGeometry`, so it shares the frame the
|
||||
* backend neighbour endpoint anchors context to.
|
||||
*/
|
||||
export function extractPolygon(geom: unknown): Polygon | null {
|
||||
if (geom == null || typeof geom !== "object") return null;
|
||||
|
|
@ -398,8 +410,55 @@ export function extractPolygon(geom: unknown): Polygon | null {
|
|||
};
|
||||
}
|
||||
if (g.type === "MultiPolygon" && Array.isArray(g.coordinates)) {
|
||||
const first = (g.coordinates as Polygon["coordinates"][])[0];
|
||||
if (first) return { type: "Polygon", coordinates: first };
|
||||
const parts = g.coordinates as Polygon["coordinates"][];
|
||||
let best: Polygon["coordinates"] | null = null;
|
||||
let bestArea = -Infinity;
|
||||
for (const part of parts) {
|
||||
if (!Array.isArray(part) || part.length === 0) continue;
|
||||
const area = polygonAreaSqm({ type: "Polygon", coordinates: part });
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = part;
|
||||
}
|
||||
}
|
||||
if (best) return { type: "Polygon", coordinates: best };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows an unknown GeoJSON value into the WHOLE parcel geometry — a Polygon or a
|
||||
* MultiPolygon preserving ALL sub-polygons (unlike `extractPolygon`, which drops
|
||||
* to one part for the /concepts request). The §7 massing scene feeds this to
|
||||
* derive its projection origin, OSM ground-tile bounds and drawn outline over
|
||||
* every part, so neighbour context (anchored by the backend to the entire parcel
|
||||
* geometry) lands geo-aligned instead of offset from part 0. Returns null on any
|
||||
* non-polygonal shape.
|
||||
*/
|
||||
export function extractParcelGeometry(
|
||||
geom: unknown,
|
||||
): Polygon | MultiPolygon | null {
|
||||
if (geom == null || typeof geom !== "object") return null;
|
||||
const g = geom as {
|
||||
type?: unknown;
|
||||
coordinates?: unknown;
|
||||
geometry?: unknown;
|
||||
};
|
||||
|
||||
if (g.type === "Feature") {
|
||||
return extractParcelGeometry((g as Feature).geometry);
|
||||
}
|
||||
if (g.type === "Polygon" && Array.isArray(g.coordinates)) {
|
||||
return {
|
||||
type: "Polygon",
|
||||
coordinates: g.coordinates as Polygon["coordinates"],
|
||||
};
|
||||
}
|
||||
if (g.type === "MultiPolygon" && Array.isArray(g.coordinates)) {
|
||||
return {
|
||||
type: "MultiPolygon",
|
||||
coordinates: g.coordinates as MultiPolygon["coordinates"],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
* `polygonAreaSqm` in concept-api.ts (WGS84 equatorial radius) for consistency.
|
||||
*/
|
||||
|
||||
import type { Position } from "geojson";
|
||||
import type { MultiPolygon, Polygon, Position } from "geojson";
|
||||
|
||||
/** WGS84 equatorial radius (m) — same constant as `polygonAreaSqm`. */
|
||||
export const R = 6_378_137;
|
||||
|
|
@ -27,42 +27,84 @@ export interface Bounds {
|
|||
maxLat: number;
|
||||
}
|
||||
|
||||
/** Axis-aligned WGS84 bounds of a ring (or rings). Null on an empty/degenerate ring. */
|
||||
export function ringBounds(ring: Position[]): Bounds | null {
|
||||
/** Axis-aligned WGS84 union bounds over several rings. Null when all are empty. */
|
||||
export function ringsBounds(rings: Position[][]): Bounds | null {
|
||||
let minLon = Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
let maxLat = -Infinity;
|
||||
for (const ring of rings) {
|
||||
for (const [lon, lat] of ring) {
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(minLon) || !Number.isFinite(minLat)) return null;
|
||||
return { minLon, minLat, maxLon, maxLat };
|
||||
}
|
||||
|
||||
/** Axis-aligned WGS84 bounds of a single ring. Null on an empty/degenerate ring. */
|
||||
export function ringBounds(ring: Position[]): Bounds | null {
|
||||
return ringsBounds([ring]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Average of unique vertices (skips each ring's closing vertex) across ALL rings,
|
||||
* so a multi-part parcel projects about its whole-geometry centre rather than one
|
||||
* part's — the frame the backend neighbour endpoint anchors context to. Vertex-
|
||||
* count weighted (good enough as a projection origin at lot scale). Falls back to
|
||||
* [0, 0] when every ring is empty.
|
||||
*/
|
||||
export function ringsCentroid(rings: Position[][]): [number, number] {
|
||||
let sLon = 0;
|
||||
let sLat = 0;
|
||||
let n = 0;
|
||||
for (const ring of rings) {
|
||||
if (ring.length === 0) continue;
|
||||
// Drop the closing vertex when the ring is closed (first == last).
|
||||
const closed =
|
||||
ring.length > 1 &&
|
||||
ring[0][0] === ring[ring.length - 1][0] &&
|
||||
ring[0][1] === ring[ring.length - 1][1];
|
||||
const pts = closed ? ring.slice(0, ring.length - 1) : ring;
|
||||
for (const [lon, lat] of pts) {
|
||||
sLon += lon;
|
||||
sLat += lat;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n === 0) return [0, 0];
|
||||
return [sLon / n, sLat / n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring-average of unique vertices (skips the closing vertex), mirroring
|
||||
* `polygonCentroidWkt`. Returns [lon, lat]. Falls back to [0, 0] on an empty ring.
|
||||
*/
|
||||
export function ringCentroid(ring: Position[]): [number, number] {
|
||||
if (ring.length === 0) return [0, 0];
|
||||
// Drop the closing vertex when the ring is closed (first == last).
|
||||
const closed =
|
||||
ring.length > 1 &&
|
||||
ring[0][0] === ring[ring.length - 1][0] &&
|
||||
ring[0][1] === ring[ring.length - 1][1];
|
||||
const pts = closed ? ring.slice(0, ring.length - 1) : ring;
|
||||
if (pts.length === 0) return [0, 0];
|
||||
let sLon = 0;
|
||||
let sLat = 0;
|
||||
for (const [lon, lat] of pts) {
|
||||
sLon += lon;
|
||||
sLat += lat;
|
||||
return ringsCentroid([ring]);
|
||||
}
|
||||
return [sLon / pts.length, sLat / pts.length];
|
||||
|
||||
/**
|
||||
* Outer ring(s) of a parcel geometry — one per Polygon part. A bare Polygon
|
||||
* yields its single outer ring; a MultiPolygon yields EVERY part's outer ring, so
|
||||
* the massing scene can project origin, ground bounds and outline over the WHOLE
|
||||
* parcel (the frame the neighbour endpoint anchors to). Inner rings (holes) are
|
||||
* dropped — origin/bounds/outline only need the outer boundary.
|
||||
*/
|
||||
export function polygonOuterRings(geom: Polygon | MultiPolygon): Position[][] {
|
||||
if (geom.type === "MultiPolygon") {
|
||||
const out: Position[][] = [];
|
||||
for (const part of geom.coordinates) {
|
||||
const outer = part[0];
|
||||
if (outer && outer.length > 0) out.push(outer);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const outer = geom.coordinates[0];
|
||||
return outer && outer.length > 0 ? [outer] : [];
|
||||
}
|
||||
|
||||
// ── projection (WGS84 → local metres) ─────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue