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
2 changes: 1 addition & 1 deletion examples/website/hierarchy/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const App: React.FC<{showCellId?: boolean}> = ({showCellId = true}) => {
}, []);

// Calculate resolution based on zoom level
let resolution = Math.min(Math.floor(2 * viewState.zoom - 4), Math.floor(viewState.zoom + 3));
let resolution = Math.min(Math.floor(2 * viewState.zoom - 4), Math.floor(viewState.zoom + 1));
resolution = Math.max(1, Math.min(MAX_RESOLUTION, resolution));

// Memoize the entire cells calculation
Expand Down
108 changes: 108 additions & 0 deletions examples/website/spherical-polygon/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React, { Suspense, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { hexToBigInt, cellToChildren, cellToParent } from 'a5/index';
import { A5Pentagon, Marker, Sphere, sphericalPentagonFromCell } from './components';
import { toCartesian } from 'a5/core/coordinate-transforms';
import { Cartesian, Spherical } from 'a5/core/coordinate-systems';
import { vec3 } from 'gl-matrix';

const initialSpherical = [2 * Math.PI * Math.random(), Math.PI * Math.random()] as Spherical;
const initialPoint = toCartesian(initialSpherical);
const camera = toCartesian(initialSpherical);
vec3.scale(camera, camera, 2);

const a5CellHex = '1200000000000000';
const a5cell = hexToBigInt(a5CellHex);

function Scene({ resolution }: { resolution: number }) {
const [point, setPoint] = useState<Cartesian>(initialPoint);

// Get cells at current resolution
const a5cells = cellToChildren(cellToParent(a5cell), resolution);

// Filter cells based on current point
const filteredCells = a5cells.filter(cell => {
const pentagon = sphericalPentagonFromCell(cell);
return pentagon.containsPoint(point) > 0;
});

const handleSphereClick = (intersection: Spherical) => {
setPoint(toCartesian(intersection));
};

return (
<>
<ambientLight intensity={0.3} />
<directionalLight position={[10, 10, 10]} intensity={1.5} />
<directionalLight position={[-10, -8, -10]} intensity={0.4} />
<Sphere onSphereClick={handleSphereClick} />
{filteredCells.map(cell => <A5Pentagon key={cell.toString()} cell={cell} disabled={false} />)}
{a5cells.map(cell => <A5Pentagon key={cell.toString()} cell={cell} disabled={true} />)}
<Marker cartesian={point} />
<OrbitControls enableDamping enableZoom={true} minPolarAngle={0} maxPolarAngle={Math.PI} minDistance={1.02} maxDistance={10} enablePan={false} />
</>
);
}

const App: React.FC = () => {
const [resolution, setResolution] = useState(3);

return (
<div style={{
position: 'absolute',
height: '100%',
width: '100%',
top: 0,
left: 0,
background: 'linear-gradient(0, #000, #223)'
}}>
<div style={{
position: 'absolute',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
background: 'white',
padding: '10px',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
zIndex: 1,
width: '300px',
textAlign: 'center'
}}>
<div style={{ marginBottom: '5px' }}>
Resolution: {resolution}
</div>
<input
type="range"
min="1"
max="5"
value={resolution}
onChange={(e) => setResolution(Number(e.target.value))}
style={{
width: '100%',
height: '20px',
WebkitAppearance: 'none',
background: 'rgba(135, 206, 235, 0.2)',
borderRadius: '10px',
outline: 'none'
}}
/>
</div>

<Canvas camera={{ position: camera, near: 0.001, far: 1000 }}>
<Suspense fallback={null}>
<Scene resolution={resolution} />
</Suspense>
</Canvas>
</div>
);
};

export default App;

export async function renderToDOM(container: HTMLDivElement) {
const root = createRoot(container);
root.render(<App />);
}
73 changes: 73 additions & 0 deletions examples/website/spherical-polygon/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useMemo } from 'react';
import { BufferGeometry, Float32BufferAttribute, DoubleSide, Vector3, Raycaster, Sphere as ThreeSphere } from 'three';
import { SphericalPentagonShape } from 'a5/core/spherical-pentagon';
import { toCartesian, fromLonLat, toSpherical } from 'a5/core/coordinate-transforms';
import { cellToBoundary } from 'a5/index';
import type { Spherical, Radians, Cartesian } from 'a5/core/coordinate-systems';
import { useThree } from '@react-three/fiber';

export function Sphere({ onSphereClick }: { onSphereClick: (point: Spherical) => void }) {
const { camera} = useThree();

const handleClick = (event: { clientX: number; clientY: number }) => {
const spherical = toSpherical(camera.position.toArray() as Cartesian);
onSphereClick(spherical);
};

return (
<mesh onClick={handleClick}>
<sphereGeometry args={[0.999, 64, 64]} />
<meshPhysicalMaterial
color="#00aa55"
opacity={0.05}
metalness={0.5}
roughness={0.7}
emissive="#00aa55"
emissiveIntensity={0.1}
/>
</mesh>
);
}

export function PentagonLines(props: { sphericalPentagon: SphericalPentagonShape, disabled: boolean }) {
const { sphericalPentagon, disabled } = props;
const geometry = useMemo(() => {
// Use 20 segments per edge for smooth curves
const vertices = sphericalPentagon.getBoundary(20);
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(vertices.flatMap(p => [...p]), 3));
return geometry;
}, [sphericalPentagon]);

return (
<line geometry={geometry}>
<lineBasicMaterial color="#ffffff" opacity={0.1} transparent={disabled} linewidth={2} />
</line>
);
}

export function sphericalPentagonFromCell(cell: bigint): SphericalPentagonShape {
const boundary = cellToBoundary(cell);
const cartesianBoundary = boundary.map(lonlat => toCartesian(fromLonLat(lonlat)));
return new SphericalPentagonShape(cartesianBoundary);
}

export function A5Pentagon(props: { cell: bigint, disabled: boolean }) {
const {cell, disabled} = props;
const a5Pentagon = sphericalPentagonFromCell(cell);
return <PentagonLines sphericalPentagon={a5Pentagon} disabled={disabled} />;
}

export function Marker(props: { cartesian: Cartesian }) {
const cartesian = props.cartesian;
return (
<mesh position={cartesian}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshPhysicalMaterial
color="#ff0000"
metalness={0.2}
roughness={0}
/>
</mesh>
);
}
59 changes: 25 additions & 34 deletions modules/core/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ import { mat2, vec2, glMatrix } from "gl-matrix";
glMatrix.setMatrixArrayType(Float64Array as any);

import type { Face, LonLat } from "./coordinate-systems";
import { FaceToIJ, fromLonLat, toFace } from "./coordinate-transforms";
import { FaceToIJ, fromLonLat, toCartesian, toFace } from "./coordinate-transforms";
import { findNearestOrigin, quintantToSegment, segmentToQuintant } from "./origin";
import { unprojectDodecahedron } from "./dodecahedron";
import { A5Cell, Pentagon, PentagonShape } from "./utils";
import { getFaceVertices, getPentagonVertices, getQuintant, getQuintantPolar, getQuintantVertices } from "./tiling";
import { A5Cell, PentagonShape } from "./utils";
import { getFaceVertices, getPentagonVertices, getQuintantPolar, getQuintantVertices } from "./tiling";
import { PI_OVER_5 } from "./constants";
import { IJToS, sToAnchor } from "./hilbert";
import { projectPentagon, projectPoint, reprojectPentagon } from "./project";
import { projectPentagon, projectPoint } from "./project";
import { deserialize, serialize, FIRST_HILBERT_RESOLUTION } from "./serialization";
import { SphericalPentagonShape } from "./spherical-pentagon";

// Reuse these objects to avoid allocation
const rotation = mat2.create();
Expand All @@ -27,7 +28,7 @@ export function lonLatToCell(lonLat: LonLat, resolution: number): bigint {

const hilbertResolution = 1 + resolution - FIRST_HILBERT_RESOLUTION;
const samples: LonLat[] = [lonLat];
const N = 100;
const N = 25;
const scale = 50 / Math.pow(2, hilbertResolution);
for (let i = 0; i < N; i++) {
const R = (i / N) * scale;
Expand All @@ -36,35 +37,31 @@ export function lonLatToCell(lonLat: LonLat, resolution: number): bigint {
samples.push(coordinate as LonLat);
}

const cells: {cell: A5Cell, distance: number}[] = [];
const estimates: {estimate: A5Cell, sample: LonLat}[] = [];
for (const sample of samples) {
const estimate = _lonLatToEstimate(sample, resolution);
estimates.push({estimate, sample});
}

// Deduplicate estimates
const estimateSet = new Set<bigint>();
const uniqueEstimates: A5Cell[] = [];
for (const {estimate, sample} of estimates) {

const cells: {cell: A5Cell, distance: number}[] = [];
for (const sample of samples) {
const estimate = _lonLatToEstimate(sample, resolution);
const estimateKey = serialize(estimate);
if (!estimateSet.has(estimateKey)) {
// Have new estimate, add to set and list
estimateSet.add(estimateKey);
uniqueEstimates.push(estimate);
}
}

for (const estimate of uniqueEstimates) {
const distance = a5cellContainsPoint(estimate, lonLat);
if (distance < 0) {
return serialize(estimate);
} else {
cells.push({cell: estimate, distance});
// Check if we have a hit, storing distance if not
const distance = a5cellContainsPoint(estimate, lonLat);
if (distance > 0) {
return serialize(estimate);
} else {
cells.push({cell: estimate, distance});
}
}
}

// Sort cells by distance and use the closest one
cells.sort((a, b) => a.distance - b.distance);
// As fallback, sort cells by distance and use the closest one
cells.sort((a, b) => b.distance - a.distance);
return serialize(cells[0].cell);
}

Expand Down Expand Up @@ -129,17 +126,11 @@ export function cellToBoundary(cellId: bigint): LonLat[] {
}

export function a5cellContainsPoint(cell: A5Cell, point: LonLat): number {
const spherical = fromLonLat(point);

// Important to use the same origin as the cell, so we unproject onto correct face
const {origin} = cell;
const polar = unprojectDodecahedron(spherical, origin.quat, origin.angle);
const face = toFace(polar);
const boundary = cellToBoundary(serialize(cell));

// Required for points on pentagon that cross the origin boundary
const pentagon = _getPentagon(cell);
const reprojectedPentagon = reprojectPentagon(pentagon, origin);
const cartesian = toCartesian(fromLonLat(point));
const sphericalBoundary = boundary.map(vertex => toCartesian(fromLonLat(vertex)));

// Perform containment test in Face coordinates, where cell edges are straight lines
return reprojectedPentagon.containsPoint(face);
const sphericalPentagon = new SphericalPentagonShape(sphericalBoundary);
return sphericalPentagon.containsPoint(cartesian);
}
12 changes: 9 additions & 3 deletions modules/core/coordinate-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ export function toSpherical(xyz: Cartesian): Spherical {
}

export function toCartesian([theta, phi]: Spherical): Cartesian {
const x = Math.sin(phi) * Math.cos(theta);
const y = Math.sin(phi) * Math.sin(theta);
const sinPhi = Math.sin(phi);
const x = sinPhi * Math.cos(theta);
const y = sinPhi * Math.sin(theta);
const z = Math.cos(phi);
return [x, y, z] as Cartesian;
}


/**
* Determine the offset longitude for the spherical coordinate system
* This is the angle between the Greenwich meridian and vector between the centers
Expand Down Expand Up @@ -83,6 +83,12 @@ export function toLonLat([theta, phi]: Spherical): LonLat {
return [longitude, latitude] as LonLat;
}

/**
* Creates a quaternion representing a rotation
* from the north pole to a given axis.
* @param axis Spherical coordinate of axis to rotate to
* @returns quaternion
*/
export function quatFromSpherical(axis: Spherical): quat {
const cartesian = toCartesian(axis);
const Q = quat.create();
Expand Down
44 changes: 5 additions & 39 deletions modules/core/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

import { vec2, mat2, glMatrix } from 'gl-matrix';
glMatrix.setMatrixArrayType(Float64Array as any);
import { Pentagon, PentagonShape } from './utils';
import { PentagonShape } from './utils';
import { Origin } from './utils';
import { movePointToFace, findNearestOrigin, isNearestOrigin } from './origin';
import { projectDodecahedron, unprojectDodecahedron } from './dodecahedron';
import type { Face, LonLat, Polar, Radians } from './coordinate-systems';
import { fromLonLat, toFace, toLonLat, toPolar } from './coordinate-transforms';
import { distanceToEdge, PI_OVER_5 } from './constants';
import { projectDodecahedron } from './dodecahedron';
import type { Face, LonLat, Radians } from './coordinate-systems';
import { toLonLat, toPolar } from './coordinate-transforms';
import { PI_OVER_5 } from './constants';

// Reusable matrices to avoid recreation
const rotation = mat2.create();
Expand Down Expand Up @@ -51,38 +51,4 @@ export function projectPentagon(pentagon: PentagonShape, origin: Origin): LonLat
// Normalize longitudes to handle antimeridian crossing
const normalizedVertices = PentagonShape.normalizeLongitudes(rotatedVertices);
return normalizedVertices;
}

/**
* Reproject a pentagon so that all vertices are defined in the same Face coordinate system
* of a single origin. This is required for containment testing as the vertices of a cell
* can span multiple origins, which are warped differently. Applying this function allows
* us to perform containment testing in Face coordinates, where cell edges are straight lines.
* @param pentagon
* @param origin
* @returns
*/
export function reprojectPentagon(pentagon: PentagonShape, origin: Origin): PentagonShape {
// If all vertices are within a single origin, we can just return the pentagon
let withinSingleOrigin = true;
for (const vertex of pentagon.getVertices()) {
const D = vec2.length(vertex);
if (D > distanceToEdge) {
withinSingleOrigin = false;
break;
}
}

if (withinSingleOrigin) {
return pentagon;
}

// Project to sphere, projectPoint will handle "folding" vertices across the dodecahedron edges
const projectedPentagon = projectPentagon(pentagon, origin);
const sphericalPentagon = projectedPentagon.map(fromLonLat);

// Unproject to dodecahedron, with all vertices now defined in the same Face coordinate system
const polarPentagon = sphericalPentagon.map(spherical => unprojectDodecahedron(spherical, origin.quat, origin.angle));
const facePentagon = polarPentagon.map(polar => toFace(polar)) as Pentagon;
return new PentagonShape(facePentagon);
}
Loading
Loading