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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Single-page app. One route (`/`), one page component (`src/pages/Index.tsx`).
**Data flow:**
1. User uploads a photo → `extractPhotoMetadata` (`src/lib/exif.ts`) runs two concurrent `exifr.parse` calls plus `exifr.gps()` to extract timestamp, compass bearing, focal length, and GPS coords into `PhotoMetadata`.
2. User marks 3 points on the image (object base, object top, shadow tip) via `InteractiveImage`.
3. "Estimate Location" → `generateShadowFinderGrid` + `analyzeShadowMeasurements` (`src/lib/shadowfinder.ts`) sweep a 0.5° global grid using SunCalc, scoring each point by how well its theoretical shadow ratio matches the measured one.
3. "Estimate Location" → `generateShadowFinderGrid` + `analyzeShadowMeasurements` (`src/lib/shadowfinder.ts`) sweep a 0.5° global grid using inlined sun-position math (verified against SunCalc in tests), scoring each point by the symmetric angular error `|atan(height/shadow) − sun_altitude|` in radians. Band thresholds (`ULTRA_TIGHT_BAND_RAD`, `MAIN_BAND_RAD`, `VISIBLE_BAND_RAD`, etc.) are exported from `shadowfinder.ts` and consumed by the visualization. `estimateBestLocation` splits bimodal posteriors at the largest latitude gap and uses a circular mean for longitudes so the antimeridian doesn't break the centroid.
4. Results pass as props to `ShadowFinderVisualization`, which renders a Leaflet heatmap plus optional GPS marker and FOV cone.

**Dual-photo mode:** `Index.tsx` maintains mirrored state for first/second photos (`firstPhotoMeta`/`secondPhotoMeta`, etc.). When both analyses exist, `ShadowFinderVisualization` intersects their high-probability grids and renders a single combined heatmap.
Expand Down
249 changes: 7 additions & 242 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"exifr": "^7.1.3",
"geomagnetism": "^0.2.0",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"lucide-react": "^0.462.0",
Expand Down
17 changes: 8 additions & 9 deletions src/components/AnalysisPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
formatDateInput,
formatTimeInput,
} from '@/lib/exif';
import { AzimuthConstraint } from '@/lib/shadowfinder';
import { AzimuthConstraint, DEFAULT_AZIMUTH_TOLERANCE_DEG } from '@/lib/shadowfinder';

interface AnalysisPanelProps {
points: ClickPoint[];
Expand Down Expand Up @@ -80,19 +80,18 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
if (photoMetadata.utcTime) {
setSelectedDate(formatDateInput(photoMetadata.utcTime));
setSelectedTime(formatTimeInput(photoMetadata.utcTime));
} else if (photoMetadata.localTime) {
// Pre-fill local time — user must pick offset
setSelectedDate(formatDateInput(photoMetadata.localTime));
setSelectedTime(formatTimeInput(photoMetadata.localTime));
} else if (photoMetadata.localWallClock) {
setSelectedDate(formatDateInput(photoMetadata.localWallClock));
setSelectedTime(formatTimeInput(photoMetadata.localWallClock));
}
}, [photoMetadata]);

// Recompute displayed UTC time when the user adjusts the offset picker.
// MUST use useEffect — calling setters during render would cause infinite re-renders.
// Deps: [manualOffsetMinutes, photoMetadata] — recalculate when either changes.
useEffect(() => {
if (!photoMetadata?.localTime || photoMetadata.utcTime) return;
const utc = applyUtcOffset(photoMetadata.localTime, manualOffsetMinutes);
if (!photoMetadata?.localWallClock || photoMetadata.utcTime) return;
const utc = applyUtcOffset(photoMetadata.localWallClock, manualOffsetMinutes);
setSelectedDate(formatDateInput(utc));
setSelectedTime(formatTimeInput(utc));
}, [manualOffsetMinutes, photoMetadata]);
Expand All @@ -119,7 +118,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
// Deps: [sunBearingDeg, azimuthEnabled, onAzimuthConstraint] — recalculate on any change.
useEffect(() => {
if (sunBearingDeg !== null && azimuthEnabled) {
onAzimuthConstraint({ sunBearingDeg, toleranceDeg: 10, enabled: true });
onAzimuthConstraint({ sunBearingDeg, toleranceDeg: DEFAULT_AZIMUTH_TOLERANCE_DEG, enabled: true });
} else {
onAzimuthConstraint(null);
}
Expand Down Expand Up @@ -149,7 +148,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
!photoMetadata ? 'none'
: photoMetadata.hasGPSTime ? 'gps'
: photoMetadata.utcOffset ? 'offset'
: photoMetadata.localTime ? 'local'
: photoMetadata.localWallClock ? 'local'
: 'none';

return (
Expand Down
15 changes: 11 additions & 4 deletions src/components/ShadowFinderVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ import 'leaflet.heat';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { MapPin, Zap } from 'lucide-react';
import { ShadowFinderPoint, ShadowAnalysisResult, AzimuthConstraint, applyAzimuthConstraint } from '@/lib/shadowfinder';
import {
ShadowFinderPoint,
ShadowAnalysisResult,
AzimuthConstraint,
applyAzimuthConstraint,
VISIBLE_BAND_RAD,
NIGHT_LIKELIHOOD,
} from '@/lib/shadowfinder';
import { computeFovDeg } from '@/lib/exif';

type HeatPoint = [number, number, number];

const LIKELIHOOD_CUTOFF = 0.15;
const LIKELIHOOD_CUTOFF = VISIBLE_BAND_RAD;

const WARM_GRADIENT: Record<string, string> = { 0.4: '#FF4500', 0.65: '#FF9500', 0.85: '#FFD700', 1.0: '#FFFF33' };
const COOL_GRADIENT: Record<string, string> = { 0.4: '#1E90FF', 0.7: '#00E5FF', 1.0: '#FFFFFF' };
Expand Down Expand Up @@ -45,7 +52,7 @@ function filterPoints(
points: ShadowFinderPoint[],
constraint: AzimuthConstraint | null | undefined
): { visible: ShadowFinderPoint[]; fallback: boolean } {
const base = points.filter(p => p.likelihood !== -1 && p.likelihood <= LIKELIHOOD_CUTOFF);
const base = points.filter(p => p.likelihood !== NIGHT_LIKELIHOOD && p.likelihood <= LIKELIHOOD_CUTOFF);
if (!constraint?.enabled) return { visible: base, fallback: false };
const filtered = applyAzimuthConstraint(base, constraint);
if (filtered.length === 0) return { visible: base, fallback: true };
Expand Down Expand Up @@ -246,7 +253,7 @@ export const ShadowFinderVisualization: React.FC<ShadowFinderVisualizationProps>
const firstPoint = firstMap.get(key);
if (firstPoint) {
const combinedLikelihood = Math.max(firstPoint.likelihood, p.likelihood);
intersectionPoints.push([p.lat, p.lng, 1 - combinedLikelihood / 0.15]);
intersectionPoints.push([p.lat, p.lng, 1 - combinedLikelihood / LIKELIHOOD_CUTOFF]);
}
}

Expand Down
Loading