diff --git a/apps/photo-geolocation/src/v1/App.jsx b/apps/photo-geolocation/src/v1/App.jsx index 1847199..ef5371f 100644 --- a/apps/photo-geolocation/src/v1/App.jsx +++ b/apps/photo-geolocation/src/v1/App.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import styles from './App.module.css' import PhotoUpload from './components/PhotoUpload' import Map2D from './components/Map2D' @@ -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 ( -
+
πŸ“ GeoPhoto Tool Tel Aviv photo geolocation @@ -22,6 +36,21 @@ export default function App() {
+ +
diff --git a/apps/photo-geolocation/src/v1/App.module.css b/apps/photo-geolocation/src/v1/App.module.css index 0f46f67..20fb3dd 100644 --- a/apps/photo-geolocation/src/v1/App.module.css +++ b/apps/photo-geolocation/src/v1/App.module.css @@ -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; @@ -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; } +} diff --git a/apps/photo-geolocation/src/v1/components/Map2D.jsx b/apps/photo-geolocation/src/v1/components/Map2D.jsx index a1e0726..d76299d 100644 --- a/apps/photo-geolocation/src/v1/components/Map2D.jsx +++ b/apps/photo-geolocation/src/v1/components/Map2D.jsx @@ -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) @@ -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, @@ -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 @@ -579,8 +631,26 @@ 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 @@ -588,11 +658,10 @@ export default function Map2D() { 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() } @@ -611,10 +680,11 @@ export default function Map2D() {
{ onPointerUp(e); setReadout(null) }} onDoubleClick={onDoubleClick} /> diff --git a/apps/photo-geolocation/src/v1/components/PhotoUpload.jsx b/apps/photo-geolocation/src/v1/components/PhotoUpload.jsx index 14b2be4..52a6bf1 100644 --- a/apps/photo-geolocation/src/v1/components/PhotoUpload.jsx +++ b/apps/photo-geolocation/src/v1/components/PhotoUpload.jsx @@ -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) @@ -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, @@ -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 @@ -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 }) @@ -270,6 +340,7 @@ export default function PhotoUpload() { overflow: 'hidden', background: '#0a0a0a', cursor: canPlaceAnchor ? 'crosshair' : 'auto', + touchAction: 'none', }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove}