@honua/app-platform 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,8 +22,46 @@
22
22
  *
23
23
  * @module
24
24
  */
25
+ import { esriGeometryToGeoJSON } from "../core/esri-geojson.js";
25
26
  import { HonuaFeatureLayer } from "../core/surfaces.js";
26
27
  import { parseFeatureLayerUrl } from "../esri-compat/url.js";
28
+ /** Validate an unambiguous shape when the service cannot declare its geometry type. */
29
+ function isValidGenericGeometry(geometry) {
30
+ const shapes = [
31
+ "x" in geometry || "y" in geometry,
32
+ "points" in geometry,
33
+ "paths" in geometry,
34
+ "rings" in geometry,
35
+ ["xmin", "ymin", "xmax", "ymax"].some((key) => key in geometry),
36
+ ];
37
+ if (shapes.filter(Boolean).length !== 1)
38
+ return false;
39
+ if (shapes[0])
40
+ return asFiniteNumber(geometry.x) !== undefined && asFiniteNumber(geometry.y) !== undefined;
41
+ if (shapes[1])
42
+ return (Array.isArray(geometry.points) &&
43
+ geometry.points.length > 0 &&
44
+ geometry.points.every((point) => asPosition(point) !== undefined));
45
+ if (shapes[2] || shapes[3]) {
46
+ const parts = shapes[2] ? geometry.paths : geometry.rings;
47
+ const polygon = shapes[3];
48
+ return (Array.isArray(parts) &&
49
+ parts.length > 0 &&
50
+ parts.every((part) => {
51
+ if (!Array.isArray(part) ||
52
+ part.length < (polygon ? 4 : 2) ||
53
+ !part.every((point) => asPosition(point) !== undefined))
54
+ return false;
55
+ if (!polygon)
56
+ return true;
57
+ const first = asPosition(part[0]);
58
+ const last = asPosition(part[part.length - 1]);
59
+ return first[0] === last[0] && first[1] === last[1];
60
+ }));
61
+ }
62
+ const bounds = [geometry.xmin, geometry.ymin, geometry.xmax, geometry.ymax].map(asFiniteNumber);
63
+ return bounds.every((value) => value !== undefined) && bounds[0] <= bounds[2] && bounds[1] <= bounds[3];
64
+ }
27
65
  /**
28
66
  * Convert a single Esri geometry into a GeoJSON geometry.
29
67
  *
@@ -31,75 +69,68 @@ import { parseFeatureLayerUrl } from "../esri-compat/url.js";
31
69
  * multipoint, polyline, polygon, envelope). Returns `null` when the geometry is
32
70
  * absent or unrecognised so the feature still renders attribute-only.
33
71
  */
34
- export function esriGeometryToGeoJson(geometry, geometryType) {
35
- if (!geometry) {
72
+ export function esriGeometryToGeoJson(geometry, geometryType, dimensions) {
73
+ if (!geometry || typeof geometry !== "object" || Array.isArray(geometry))
36
74
  return null;
75
+ let shape;
76
+ if (geometryType === undefined || geometryType === null || geometryType === "esriGeometryNull") {
77
+ shape = geometry;
37
78
  }
38
- // Point geometries are recognisable structurally even without a declared
39
- // type (queries that elide geometryType still return {x, y}).
40
- if (geometryType === "esriGeometryPoint" || (geometry.x !== undefined && geometry.y !== undefined)) {
41
- const x = asFiniteNumber(geometry.x);
42
- const y = asFiniteNumber(geometry.y);
43
- if (x === undefined || y === undefined)
44
- return null;
45
- return { type: "Point", coordinates: [x, y] };
79
+ else if (geometryType === "esriGeometryPoint" || (geometry.x !== undefined && geometry.y !== undefined)) {
80
+ shape = { x: geometry.x, y: geometry.y, z: geometry.z };
46
81
  }
47
- if (geometryType === "esriGeometryMultipoint") {
48
- const points = asArray(geometry.points)
49
- .map(asCoordinatePair)
50
- .filter((value) => value !== undefined);
51
- return { type: "MultiPoint", coordinates: points };
82
+ else if (geometryType === "esriGeometryMultipoint") {
83
+ shape = { points: geometry.points };
52
84
  }
53
- if (geometryType === "esriGeometryPolyline") {
54
- const paths = asArray(geometry.paths)
55
- .map(asCoordinateArray)
56
- .filter((path) => path.length > 0);
57
- if (paths.length === 1) {
58
- return { type: "LineString", coordinates: paths[0] };
59
- }
60
- return { type: "MultiLineString", coordinates: paths };
85
+ else if (geometryType === "esriGeometryPolyline") {
86
+ shape = { paths: geometry.paths };
87
+ }
88
+ else if (geometryType === "esriGeometryPolygon") {
89
+ shape = { rings: geometry.rings };
61
90
  }
62
- if (geometryType === "esriGeometryPolygon") {
63
- const rings = asArray(geometry.rings)
64
- .map(asCoordinateArray)
65
- .filter((ring) => ring.length > 0);
66
- // MapLibre does not enforce GeoJSON ring winding for rendering, so the
67
- // adapter passes Esri rings through as a Polygon without re-nesting.
68
- return { type: "Polygon", coordinates: rings };
91
+ else if (geometryType === "esriGeometryEnvelope") {
92
+ shape = { xmin: geometry.xmin, ymin: geometry.ymin, xmax: geometry.xmax, ymax: geometry.ymax };
69
93
  }
70
- if (geometryType === "esriGeometryEnvelope") {
71
- const xmin = asFiniteNumber(geometry.xmin);
72
- const ymin = asFiniteNumber(geometry.ymin);
73
- const xmax = asFiniteNumber(geometry.xmax);
74
- const ymax = asFiniteNumber(geometry.ymax);
75
- if (xmin === undefined || ymin === undefined || xmax === undefined || ymax === undefined) {
76
- return null;
94
+ else
95
+ return null;
96
+ // One converter owns exterior/hole grouping, winding and coordinate ordinates
97
+ // for both metadata-declared and structurally inferred feature geometry.
98
+ if (!isValidGenericGeometry(shape))
99
+ return null;
100
+ // Optional Esri Z/M can be null. Keep finite spatial coordinates, omit
101
+ // absent Z, and never treat a measure as GeoJSON altitude.
102
+ const converted = esriGeometryToGeoJSON({ ...shape, z: asFiniteNumber(shape.z) });
103
+ if (!converted || !("coordinates" in converted))
104
+ return null;
105
+ const hasZ = asFiniteNumber(shape.z) !== undefined || (geometry.hasZ ?? dimensions?.hasZ) === true;
106
+ const coordinates = projectCoordinates(converted.coordinates, hasZ);
107
+ const polygons = converted.type === "Polygon"
108
+ ? [coordinates]
109
+ : converted.type === "MultiPolygon"
110
+ ? coordinates
111
+ : [];
112
+ for (const polygon of polygons) {
113
+ for (const ring of polygon) {
114
+ const first = ring[0];
115
+ const last = ring[ring.length - 1];
116
+ // Close after dimension projection, comparing every retained ordinate.
117
+ // Append the first position so a distinct final Z is not discarded.
118
+ if (first && last && (first.length !== last.length || first.some((value, index) => value !== last[index])))
119
+ ring.push([...first]);
77
120
  }
78
- return {
79
- type: "Polygon",
80
- coordinates: [
81
- [
82
- [xmin, ymin],
83
- [xmax, ymin],
84
- [xmax, ymax],
85
- [xmin, ymax],
86
- [xmin, ymin],
87
- ],
88
- ],
89
- };
90
121
  }
91
- return null;
122
+ return { type: converted.type, coordinates };
92
123
  }
93
124
  /**
94
125
  * Convert an array of Esri features (attributes + geometry) into a GeoJSON
95
126
  * `FeatureCollection`.
96
127
  */
97
- export function esriFeaturesToGeoJson(features, geometryType) {
128
+ export function esriFeaturesToGeoJson(features, geometryType, dimensions) {
98
129
  return {
99
130
  type: "FeatureCollection",
100
131
  features: features.map((feature) => ({
101
132
  type: "Feature",
102
- geometry: esriGeometryToGeoJson(feature.geometry ?? null, geometryType),
133
+ geometry: esriGeometryToGeoJson(feature.geometry ?? null, geometryType, dimensions),
103
134
  properties: { ...(feature.attributes ?? {}) },
104
135
  })),
105
136
  };
@@ -136,8 +167,8 @@ export async function loadHonuaFeatureServiceGeoJson(client, url, options = {})
136
167
  };
137
168
  const metadataPromise = layer.metadata(options.signal ? { signal: options.signal } : {}).catch(() => undefined);
138
169
  const features = await layer.queryFeaturesAll(request);
139
- const geometryType = (await metadataPromise)?.geometryType;
140
- const data = esriFeaturesToGeoJson(features, geometryType);
170
+ const metadata = await metadataPromise;
171
+ const data = esriFeaturesToGeoJson(features, metadata?.geometryType, metadata);
141
172
  return {
142
173
  type: "geojson",
143
174
  data,
@@ -260,20 +291,19 @@ async function allSettledLimited(values, concurrency, worker) {
260
291
  function asFiniteNumber(value) {
261
292
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
262
293
  }
263
- function asArray(value) {
264
- return Array.isArray(value) ? value : [];
265
- }
266
- function asCoordinatePair(value) {
267
- if (!Array.isArray(value) || value.length < 2)
268
- return undefined;
269
- const x = asFiniteNumber(value[0]);
270
- const y = asFiniteNumber(value[1]);
271
- if (x === undefined || y === undefined)
272
- return undefined;
273
- return [x, y];
294
+ function asPosition(value) {
295
+ return Array.isArray(value) &&
296
+ value.length >= 2 &&
297
+ asFiniteNumber(value[0]) !== undefined &&
298
+ asFiniteNumber(value[1]) !== undefined
299
+ ? value
300
+ : undefined;
274
301
  }
275
- function asCoordinateArray(value) {
276
- return asArray(value)
277
- .map(asCoordinatePair)
278
- .filter((pair) => pair !== undefined);
302
+ /** GeoJSON has spatial coordinates, not Esri measures; absent optional Z stays 2D. */
303
+ function projectCoordinates(coordinates, hasZ) {
304
+ if (!Array.isArray(coordinates))
305
+ return coordinates;
306
+ if (Array.isArray(coordinates[0]))
307
+ return coordinates.map((part) => projectCoordinates(part, hasZ));
308
+ return coordinates.slice(0, hasZ && asFiniteNumber(coordinates[2]) !== undefined ? 3 : 2);
279
309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@honua/app-platform",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Honua application-platform surfaces: app-shell, workspace, scene, operator, studio, and hosted-product clients",
5
5
  "keywords": [
6
6
  "gis",
@@ -121,7 +121,7 @@
121
121
  "proj4": "^2.20.9"
122
122
  },
123
123
  "peerDependencies": {
124
- "@honua/sdk": "0.2.5",
124
+ "@honua/sdk": "0.2.7",
125
125
  "@bufbuild/protobuf": "^2.0.0",
126
126
  "@connectrpc/connect": "^2.0.0",
127
127
  "@connectrpc/connect-web": "^2.0.0",