Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 0 additions & 79 deletions src/lib/layers/devVectorSample.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/lib/layers/geojsonPolygons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,4 @@ function polygonsToOutlines(geojson: FeatureCollection): FeatureCollection {
return { ...geojson, features };
}

export { geojson2gpuPolygonFillGeometry, polygonsToOutlines };
export { geojson2gpuPolygonFillGeometry, longitudeWinding, polygonsToOutlines };
128 changes: 128 additions & 0 deletions src/lib/layers/vectorLayerFormats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Validation and naming for user-injected GeoJSON vector layers
// (mirrors textureLayerFormats.ts for the texture-layer upload path).

import type { FeatureCollection } from "geojson";

export const VECTOR_LAYER_MIME_TYPES = {
GEOJSON: "application/geo+json",
JSON: "application/json",
} as const;

export type TVectorLayerMimeType =
(typeof VECTOR_LAYER_MIME_TYPES)[keyof typeof VECTOR_LAYER_MIME_TYPES];

export const VECTOR_LAYER_FILE_EXTENSIONS = {
GEOJSON: ".geojson",
JSON: ".json",
} as const;

export type TVectorLayerFileExtension =
(typeof VECTOR_LAYER_FILE_EXTENSIONS)[keyof typeof VECTOR_LAYER_FILE_EXTENSIONS];

const SUPPORTED_VECTOR_LAYER_MIME_TYPES = Object.values(
VECTOR_LAYER_MIME_TYPES
) as TVectorLayerMimeType[];

const SUPPORTED_VECTOR_LAYER_FILE_EXTENSIONS = Object.values(
VECTOR_LAYER_FILE_EXTENSIONS
) as TVectorLayerFileExtension[];

export const VECTOR_LAYER_UPLOAD_ACCEPT = [
...SUPPORTED_VECTOR_LAYER_MIME_TYPES,
...SUPPORTED_VECTOR_LAYER_FILE_EXTENSIONS,
].join(",");

// generous cap: shardmap/footprint collections are a few MB; anything larger
// is a mistake and would stall triangulation
export const MAX_VECTOR_LAYER_BYTES = 50 * 1024 * 1024;

export function isSupportedVectorLayerFile(file: File) {
const lowerName = file.name.toLowerCase();
return (
SUPPORTED_VECTOR_LAYER_MIME_TYPES.includes(
file.type as TVectorLayerMimeType
) ||
SUPPORTED_VECTOR_LAYER_FILE_EXTENSIONS.some((ext) =>
lowerName.endsWith(ext)
)
);
}

function assertVectorLayerSize(bytes: number, label: string) {
if (bytes > MAX_VECTOR_LAYER_BYTES) {
const limitMb = MAX_VECTOR_LAYER_BYTES / (1024 * 1024);
throw new Error(`${label} exceeds the ${limitMb} MB vector layer limit`);
}
}

export function parseFeatureCollection(text: string): FeatureCollection {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("the file is not valid JSON");
}
const candidate = parsed as { type?: unknown; features?: unknown } | null;
if (
!candidate ||
typeof candidate !== "object" ||
candidate.type !== "FeatureCollection"
) {
throw new Error("the root object must be a GeoJSON FeatureCollection");
}
if (!Array.isArray(candidate.features)) {
throw new Error("the FeatureCollection has no features array");
}
const collection = parsed as FeatureCollection;
// GeoJSON permits `geometry: null` (an "unlocated" feature), but the
// rendering and picking paths dereference `feature.geometry.type`
// unconditionally. Drop such features here so a mostly-good file still
// loads and neither path ever sees a null geometry.
const usable = collection.features.filter(
(feature) =>
feature.geometry !== null &&
feature.geometry !== undefined &&
"type" in feature.geometry
);
const dropped = collection.features.length - usable.length;
if (dropped > 0) {
console.warn(`dropped ${dropped} feature(s) with null or missing geometry`);
}
return { ...collection, features: usable };
}

// layer display name from the URL basename (query/hash stripped)
export function vectorLayerNameFromUrl(url: string): string {
const path = url.split(/[?#]/)[0].replace(/\/+$/, "");
const base = path.split("/").pop() ?? "";
let decoded = base;
try {
decoded = decodeURIComponent(base);
} catch {
/* keep the raw basename */
}
return decoded || "GeoJSON layer";
}

export async function readVectorLayerFile(
file: File
): Promise<FeatureCollection> {
assertVectorLayerSize(file.size, `"${file.name}"`);
return parseFeatureCollection(await file.text());
}

export async function loadVectorLayerFromUrl(
url: string
): Promise<FeatureCollection> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`the request failed with HTTP ${response.status}`);
}
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > 0) {
assertVectorLayerSize(contentLength, "the response");
}
const text = await response.text();
assertVectorLayerSize(text.length, "the response");
return parseFeatureCollection(text);
}
144 changes: 144 additions & 0 deletions src/lib/layers/vectorPicking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Data-space picking for GeoJSON vector layers: the scene raycaster already
// inverts the pointer to lat/lon (useGridScene's hoveredGeoPoint), so feature
// lookup is a point-in-polygon test over the source FeatureCollection. This
// stays correct across projection switches, where the fill meshes' CPU-side
// positions are stale (only shader uniforms update on projection change).
// Longitudes are unwrapped per polygon against its first vertex, mirroring
// the fill triangulation, so antimeridian-crossing polygons pick correctly.

import type { Feature, FeatureCollection, Position } from "geojson";

import { unwrapLongitude } from "./geojson.ts";
import { longitudeWinding } from "./geojsonPolygons.ts";

type TPickPolygon = {
featureIndex: number;
rings: Position[][];
referenceLon: number;
minLon: number;
maxLon: number;
minLat: number;
maxLat: number;
};

const pickIndexCache = new WeakMap<FeatureCollection, TPickPolygon[]>();

function buildPickPolygon(
featureIndex: number,
rings: Position[][]
): TPickPolygon | null {
const outer = rings[0];
if (!outer || outer.length < 4) {
return null;
}
// Mirror the fill's phase-1 guard: a pole-enclosing ring winds ~360 degrees
// of longitude, so the unwrapped even-odd test doubles back on itself and is
// undefined there. Such rings render no fill, so picking must skip them too.
if (Math.abs(longitudeWinding(outer)) >= 359.9) {
return null;
}
const referenceLon = outer[0][0];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Picking does not skip pole-enclosing rings, but the fill does — you can hover-hit a polygon that renders no fill, and the even-odd test over it is undefined. In addPolygonFill (geojsonPolygons.ts) a ring whose longitudeWinding is >= 359.9 is explicitly skipped ("triangulates to 0 faces; skip it") so no fill is drawn. buildPickPolygon here has no such guard — it only checks outer.length < 4, then builds a bbox and adds the ring to the pick index. Consequences:

  1. The visual/pick contract diverges: hovering inside a pole-enclosing outline reports a feature whose fill is intentionally absent.
  2. Worse, the even-odd pointInRing test is run in unwrapped-longitude space on a ring that winds a full 360° of longitude — the unwrapped contour doubles back on itself, so the inside/outside result there is not well-defined (the same degeneracy that makes the fill un-triangulatable). So the hit is not just visually inconsistent, it is unreliable.

Fix: mirror the phase-1 guard — compute longitudeWinding (or reuse it) on the outer ring in buildPickPolygon and return null when |winding| >= 359.9, so picking and rendering agree on which rings are renderable. Add a pole-ring case to vectorPicking.test.ts.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude
Fixed in b8e29ac: exported the phase-1 longitudeWinding helper from geojsonPolygons.ts and reused it in buildPickPolygon, which now returns null when |winding| >= 359.9, so picking and rendering agree on which rings are renderable; added a pole-ring case to vectorPicking.test.ts asserting no hit anywhere near the ring.

let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const [lon, lat] of outer) {
const unwrapped = unwrapLongitude(lon, referenceLon);
minLon = Math.min(minLon, unwrapped);
maxLon = Math.max(maxLon, unwrapped);
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
}
return { featureIndex, rings, referenceLon, minLon, maxLon, minLat, maxLat };
}

function buildPickIndex(collection: FeatureCollection): TPickPolygon[] {
const polygons: TPickPolygon[] = [];
for (const [featureIndex, feature] of collection.features.entries()) {
const geometry = feature.geometry;
const parts =
geometry.type === "Polygon"
? [geometry.coordinates]
: geometry.type === "MultiPolygon"
? geometry.coordinates
: [];
for (const rings of parts) {
const polygon = buildPickPolygon(featureIndex, rings);
if (polygon) {
polygons.push(polygon);
}
}
}
return polygons;
}

// even-odd ray cast in unwrapped-longitude space; `lon` is pre-unwrapped
// against the polygon's reference longitude
function pointInRing(
lon: number,
lat: number,
ring: Position[],
referenceLon: number
): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = unwrapLongitude(ring[i][0], referenceLon);
const yi = ring[i][1];
const xj = unwrapLongitude(ring[j][0], referenceLon);
const yj = ring[j][1];
if (
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
) {
inside = !inside;
}
}
return inside;
}

function polygonContainsPoint(polygon: TPickPolygon, lat: number, lon: number) {
const unwrapped = unwrapLongitude(lon, polygon.referenceLon);
if (
lat < polygon.minLat ||
lat > polygon.maxLat ||
unwrapped < polygon.minLon ||
unwrapped > polygon.maxLon
) {
return false;
}
if (!pointInRing(unwrapped, lat, polygon.rings[0], polygon.referenceLon)) {
return false;
}
for (let i = 1; i < polygon.rings.length; i++) {
if (pointInRing(unwrapped, lat, polygon.rings[i], polygon.referenceLon)) {
return false;
}
}
return true;
}

/**
* Find the topmost feature containing the given point. Features later in the
* collection draw on top of earlier ones, so the last match wins.
*/
export function findVectorFeatureAtPoint(
collection: FeatureCollection,
lat: number,
lon: number
): Feature | null {
let polygons = pickIndexCache.get(collection);
if (!polygons) {
polygons = buildPickIndex(collection);
pickIndexCache.set(collection, polygons);
}
let matchIndex = -1;
for (const polygon of polygons) {
if (
polygon.featureIndex > matchIndex &&
polygonContainsPoint(polygon, lat, lon)
) {
matchIndex = polygon.featureIndex;
}
}
return matchIndex === -1 ? null : collection.features[matchIndex];
}
Loading
Loading