Skip to content
Open
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
31 changes: 30 additions & 1 deletion apps/photo-geolocation/src/v1/App.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'
import styles from './App.module.css'
import PhotoUpload from './components/PhotoUpload'
import Map2D from './components/Map2D'
Expand All @@ -11,9 +12,22 @@ import SessionControls from './components/SessionControls'
// frame-derived size with a heuristic `window.innerWidth * 0.5`, leaving
// the photo at half-pane forever in view mode.

// Mobile shows one pane at a time, chosen by the tab bar below; desktop keeps
// the side-by-side layout and ignores this state (the tab bar is hidden via
// CSS). Every pane stays mounted regardless — only `data-mobile-view` toggles
// which one is visible — so the map's render loop and each view's pan/zoom
// state survive switching tabs.
const TABS = [
{ id: 'photo', icon: '📷', label: 'Photo' },
{ id: 'map', icon: '🗺️', label: 'Map' },
{ id: 'anchors', icon: '📍', label: 'Anchors' },
]

export default function App() {
const [mobileView, setMobileView] = useState('photo')

return (
<div className={styles.shell}>
<div className={styles.shell} data-mobile-view={mobileView}>
<header className={styles.header}>
<span className={styles.logo}>📍 GeoPhoto Tool</span>
<span className={styles.subtitle}>Tel Aviv photo geolocation</span>
Expand All @@ -22,6 +36,21 @@ export default function App() {
</span>
</header>

<nav className={styles.tabs} aria-label="Switch view">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`${styles.tab} ${mobileView === tab.id ? styles.tabActive : ''}`}
aria-pressed={mobileView === tab.id}
onClick={() => setMobileView(tab.id)}
>
<span className={styles.tabIcon} aria-hidden="true">{tab.icon}</span>
{tab.label}
</button>
))}
</nav>

<main className={styles.body}>
<section className={styles.photoPane}><PhotoUpload /></section>
<section className={styles.mapPane}><Map2D /></section>
Expand Down
65 changes: 65 additions & 0 deletions apps/photo-geolocation/src/v1/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,42 @@
font-weight: 700;
}

/* Mobile pane switcher. Hidden on desktop (side-by-side layout); shown as a
segmented control on narrow screens — see the media query at the bottom. */
.tabs {
display: none;
flex-shrink: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 6px;
gap: 6px;
}

.tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 38px;
padding: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
background: var(--surface2);
border: 1px solid var(--border);
}

.tabActive {
color: #fff;
background: var(--accent-dim);
border-color: var(--accent);
}

.tabActive:hover { background: var(--accent-dim); border-color: var(--accent); }

.tabIcon { font-size: 15px; line-height: 1; }

.body {
display: flex;
flex: 1;
Expand Down Expand Up @@ -91,3 +127,32 @@
.footer {
flex-shrink: 0;
}

/* ---- Mobile: one pane at a time, chosen by the tab bar ------------------- */
@media (max-width: 768px) {
.tabs { display: flex; }

/* Keep the header tight — drop the wordy subtitle, the tab bar already
labels each view. */
.subtitle { display: none; }

/* Stack vertically and let the active pane fill the remaining height. All
three panes are always mounted; the data-mobile-view selectors below pick
exactly one to display. */
.body { flex-direction: column; }

.photoPane,
.mapPane,
.sidebar {
display: none;
flex: 1 1 auto;
width: 100%;
min-width: 0;
min-height: 0;
border-left: none;
}

.shell[data-mobile-view='photo'] .photoPane { display: block; }
.shell[data-mobile-view='map'] .mapPane { display: block; }
.shell[data-mobile-view='anchors'] .sidebar { display: flex; }
}
84 changes: 77 additions & 7 deletions apps/photo-geolocation/src/v1/components/Map2D.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ export default function Map2D() {

const dragRef = useRef({ id: null })
const panRef = useRef({ active: false, startX: 0, startY: 0, panX: 0, panY: 0, moved: false, pointerId: null })
// Live touch points (pointerId → client x/y) for multi-touch gestures, the
// in-flight pinch state, and a flag so the final lift of a two-finger gesture
// isn't mistaken for a tap that would drop an anchor.
const pointersRef = useRef(new Map())
const pinchRef = useRef({ active: false, dist: 0, midX: 0, midY: 0 })
const multiTouchRef = useRef(false)
const viewRef = useRef({ centerX: 0, centerZ: 0, panX: 0, panY: 0, zoom: 1 })
const autoFitKeyRef = useRef(null)
const loadTimerRef = useRef(0)
Expand Down Expand Up @@ -517,14 +523,32 @@ export default function Map2D() {
const canvas = canvasRef.current
const width = canvas.clientWidth
const height = canvas.clientHeight
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
canvas.setPointerCapture?.(event.pointerId)

// Second finger down → start a pinch, dropping any anchor drag or pan that
// was in progress with the first finger.
if (pointersRef.current.size === 2) {
multiTouchRef.current = true
panRef.current.active = false
dragRef.current = { id: null }
const [a, b] = [...pointersRef.current.values()]
pinchRef.current = {
active: true,
dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
midX: (a.x + b.x) / 2,
midY: (a.y + b.y) / 2,
}
return
}

const { px, py } = getLocalPoint(event)
const hit = anchorAtPixel(anchors, px, py, width, height, viewRef.current, worldHalf)
if (hit) {
dragRef.current = { id: hit.id }
canvas.setPointerCapture?.(event.pointerId)
return
}
if (event.button !== 0 && event.button !== 1) return
if (event.pointerType === 'mouse' && event.button !== 0 && event.button !== 1) return
panRef.current = {
active: true,
startX: event.clientX,
Expand All @@ -534,13 +558,41 @@ export default function Map2D() {
moved: false,
pointerId: event.pointerId,
}
canvas.setPointerCapture?.(event.pointerId)
}

const onPointerMove = (event) => {
const canvas = canvasRef.current
const width = canvas.clientWidth
const height = canvas.clientHeight

if (pointersRef.current.has(event.pointerId)) {
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
}

// Two-finger pinch: zoom around the gesture midpoint (keeping the world
// point under it fixed, like the ctrl+wheel path) and pan with its drift.
if (pinchRef.current.active && pointersRef.current.size >= 2) {
const rect = canvas.getBoundingClientRect()
const [a, b] = [...pointersRef.current.values()]
const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1
const midX = (a.x + b.x) / 2
const midY = (a.y + b.y) / 2
const mx = midX - rect.left
const my = midY - rect.top
const before = makeTransform(width, height, viewRef.current, worldHalf).toWorld(mx, my)
viewRef.current.zoom *= dist / pinchRef.current.dist
if (viewRef.current.zoom <= 1e-6) viewRef.current.zoom = 1e-6
const after = makeTransform(width, height, viewRef.current, worldHalf).toWorld(mx, my)
viewRef.current.centerX += before.x - after.x
viewRef.current.centerZ += before.z - after.z
viewRef.current.panX += midX - pinchRef.current.midX
viewRef.current.panY += midY - pinchRef.current.midY
pinchRef.current = { active: true, dist, midX, midY }
setCursor('grabbing')
loaderRef.current()
return
}

const { px, py } = getLocalPoint(event)

// Live cursor readout: lat/lon + the height an anchor here would take
Expand Down Expand Up @@ -579,20 +631,37 @@ export default function Map2D() {
}

const onPointerUp = (event) => {
pointersRef.current.delete(event.pointerId)
try { canvasRef.current?.releasePointerCapture?.(event.pointerId) } catch { /* ignore */ }

// Winding down a pinch. If one finger is still down, hand the gesture back
// to single-finger panning without a jump (and not as a tap).
if (pinchRef.current.active) {
if (pointersRef.current.size < 2) {
pinchRef.current.active = false
const [id, p] = [...pointersRef.current.entries()][0] ?? []
if (id !== undefined) {
panRef.current = { active: true, startX: p.x, startY: p.y, panX: viewRef.current.panX, panY: viewRef.current.panY, moved: true, pointerId: id }
}
}
if (pointersRef.current.size === 0) { multiTouchRef.current = false; scheduleViewportLoad() }
return
}

const pan = panRef.current
if (pan.active && !pan.moved && event.button === 0 && activeAnchorId !== null) {
const wasMulti = multiTouchRef.current
if (pan.active && !pan.moved && !wasMulti && event.button === 0 && activeAnchorId !== null) {
const canvas = canvasRef.current
const width = canvas.clientWidth
const height = canvas.clientHeight
const { px, py } = getLocalPoint(event)
const { toWorld } = makeTransform(width, height, viewRef.current, worldHalf)
setAnchorMapPoint(activeAnchorId, toWorld(px, py))
}
if (pan.pointerId !== null) canvasRef.current.releasePointerCapture?.(pan.pointerId)
const wasPan = pan.active && pan.moved
panRef.current = { active: false, startX: 0, startY: 0, panX: viewRef.current.panX, panY: viewRef.current.panY, moved: false, pointerId: null }
if (dragRef.current.id !== null) canvasRef.current.releasePointerCapture?.(event.pointerId)
dragRef.current = { id: null }
if (pointersRef.current.size === 0) multiTouchRef.current = false
if (wasPan) scheduleViewportLoad()
}

Expand All @@ -611,10 +680,11 @@ export default function Map2D() {
<div style={{ width: '100%', height: '100%', position: 'relative', background: '#e8e6e1' }}>
<canvas
ref={canvasRef}
style={{ width: '100%', height: '100%', display: 'block', cursor }}
style={{ width: '100%', height: '100%', display: 'block', cursor, touchAction: 'none' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={(e) => { onPointerUp(e); setReadout(null) }}
onDoubleClick={onDoubleClick}
/>
Expand Down
81 changes: 76 additions & 5 deletions apps/photo-geolocation/src/v1/components/PhotoUpload.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export default function PhotoUpload() {
const fileInputRef = useRef(null)
const viewRef = useRef({ zoom: 1, panX: 0, panY: 0 })
const panRef = useRef({ active: false, startX: 0, startY: 0, panX: 0, panY: 0, moved: false, pointerId: null })
// Live touch points (pointerId → client x/y) for multi-touch gestures, the
// in-flight pinch state, and a flag so the final lift of a two-finger gesture
// isn't mistaken for a tap that would drop an anchor.
const pointersRef = useRef(new Map())
const pinchRef = useRef({ active: false, dist: 0, midX: 0, midY: 0 })
const multiTouchRef = useRef(false)
const [, rerender] = useState(0)

const hasPhoto = Boolean(photoUrl)
Expand Down Expand Up @@ -134,12 +140,31 @@ export default function PhotoUpload() {

const handlePointerDown = (event) => {
if (!hasPhoto) return
if (event.button !== 0 && event.button !== 1) return
// Don't hijack pointer-downs that land on the overlay controls (Remove
// button, etc.). Capturing the pointer on the frame here would redirect the
// pointerup and swallow the control's click, so those buttons would appear
// dead.
if (event.target.closest?.('button, input, label, select, a')) return
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
frameRef.current?.setPointerCapture?.(event.pointerId)

// Second finger down → start a pinch, abandoning any single-finger pan so
// the view doesn't lurch as the gesture changes character.
if (pointersRef.current.size === 2) {
multiTouchRef.current = true
panRef.current.active = false
const [a, b] = [...pointersRef.current.values()]
pinchRef.current = {
active: true,
dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
midX: (a.x + b.x) / 2,
midY: (a.y + b.y) / 2,
}
return
}

// Single pointer → pan. For a mouse, only the left/middle buttons pan.
if (event.pointerType === 'mouse' && event.button !== 0 && event.button !== 1) return
panRef.current = {
active: true,
startX: event.clientX,
Expand All @@ -149,10 +174,38 @@ export default function PhotoUpload() {
moved: false,
pointerId: event.pointerId,
}
frameRef.current?.setPointerCapture?.(event.pointerId)
}

const handlePointerMove = (event) => {
if (pointersRef.current.has(event.pointerId)) {
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
}

// Two-finger pinch: scale around the gesture midpoint (same anchor-point
// math as the ctrl+wheel zoom) and pan along with the midpoint's drift.
if (pinchRef.current.active && pointersRef.current.size >= 2) {
const [a, b] = [...pointersRef.current.values()]
const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1
const midX = (a.x + b.x) / 2
const midY = (a.y + b.y) / 2
const prev = viewRef.current.zoom
const next = Math.max(0.05, prev * (dist / pinchRef.current.dist))
const ratio = next / prev
viewRef.current.zoom = next
const rect = stageRef.current?.getBoundingClientRect()
if (rect && rect.width > 0 && rect.height > 0) {
const fx = (midX - rect.left) / rect.width - 0.5
const fy = (midY - rect.top) / rect.height - 0.5
viewRef.current.panX -= fx * rect.width * (ratio - 1)
viewRef.current.panY -= fy * rect.height * (ratio - 1)
}
viewRef.current.panX += midX - pinchRef.current.midX
viewRef.current.panY += midY - pinchRef.current.midY
pinchRef.current = { active: true, dist, midX, midY }
rerender((v) => v + 1)
return
}

if (panRef.current.active) {
const dx = event.clientX - panRef.current.startX
const dy = event.clientY - panRef.current.startY
Expand All @@ -171,12 +224,29 @@ export default function PhotoUpload() {
const handlePointerLeave = () => setHoverPos(null)

const handlePointerUp = (event) => {
const pointerId = panRef.current.pointerId
pointersRef.current.delete(event.pointerId)
try { frameRef.current?.releasePointerCapture?.(event.pointerId) } catch { /* ignore */ }

// Winding down a pinch. If one finger is still down, hand the gesture back
// to single-finger panning without a jump (and not as a tap).
if (pinchRef.current.active) {
if (pointersRef.current.size < 2) {
pinchRef.current.active = false
const [id, p] = [...pointersRef.current.entries()][0] ?? []
if (id !== undefined) {
panRef.current = { active: true, startX: p.x, startY: p.y, panX: viewRef.current.panX, panY: viewRef.current.panY, moved: true, pointerId: id }
}
}
if (pointersRef.current.size === 0) multiTouchRef.current = false
return
}

const panned = panRef.current.moved
const wasActive = panRef.current.active
const wasMulti = multiTouchRef.current
panRef.current = { active: false, startX: 0, startY: 0, panX: viewRef.current.panX, panY: viewRef.current.panY, moved: false, pointerId: null }
if (pointerId !== null) frameRef.current?.releasePointerCapture?.(pointerId)
if (!wasActive || panned || !canPlaceAnchor) return
if (pointersRef.current.size === 0) multiTouchRef.current = false
if (!wasActive || panned || wasMulti || !canPlaceAnchor) return
const p = stagePointFromClient(event)
if (!p) return
setAnchorPhotoPixel(activeAnchorId, { x: p.x, y: p.y })
Expand Down Expand Up @@ -270,6 +340,7 @@ export default function PhotoUpload() {
overflow: 'hidden',
background: '#0a0a0a',
cursor: canPlaceAnchor ? 'crosshair' : 'auto',
touchAction: 'none',
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
Expand Down
Loading