forked from d70-t/gridlook
-
Notifications
You must be signed in to change notification settings - Fork 0
Vector layer injection, styling, and picking (phase 2 of issue #1) #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
405fb80
feat: vector layer injection from url and file
espg 4147e37
feat: vector layer styling controls and panel ui
espg c16dfef
feat: vector feature hover readout and drag-drop injection
espg 846e7f8
fix: drop null-geometry features at vector layer ingest
espg b8e29ac
fix: skip pole-enclosing rings in vector picking
espg db15310
fix: route dropped texture files to the texture upload path
espg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; | ||
| 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]; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 whoselongitudeWindingis>= 359.9is explicitly skipped ("triangulates to 0 faces; skip it") so no fill is drawn.buildPickPolygonhere has no such guard — it only checksouter.length < 4, then builds a bbox and adds the ring to the pick index. Consequences:pointInRingtest 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 inbuildPickPolygonand returnnullwhen|winding| >= 359.9, so picking and rendering agree on which rings are renderable. Add a pole-ring case tovectorPicking.test.ts.There was a problem hiding this comment.
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
longitudeWindinghelper from geojsonPolygons.ts and reused it inbuildPickPolygon, which now returnsnullwhen|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.