@ifc-lite/viewer 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.turbo/turbo-build.log +59 -43
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +496 -0
  4. package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  7. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  8. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  9. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  10. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  11. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  12. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  13. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  14. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  15. package/dist/assets/index-CSWgTe1s.css +1 -0
  16. package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
  17. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  18. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  19. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
  20. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  21. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
  22. package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
  23. package/dist/assets/three-CDRZThFA.js +4057 -0
  24. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
  25. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  26. package/dist/index.html +10 -9
  27. package/dist/samples/building-architecture.ifc +453 -0
  28. package/dist/samples/hello-wall.ifc +1054 -0
  29. package/dist/samples/infra-bridge.ifc +962 -0
  30. package/index.html +1 -1
  31. package/package.json +15 -10
  32. package/public/samples/building-architecture.ifc +453 -0
  33. package/public/samples/hello-wall.ifc +1054 -0
  34. package/public/samples/infra-bridge.ifc +962 -0
  35. package/src/App.tsx +37 -3
  36. package/src/components/mcp/HeroScene.tsx +876 -0
  37. package/src/components/mcp/McpLanding.tsx +1318 -0
  38. package/src/components/mcp/McpPlayground.tsx +524 -0
  39. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  40. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  41. package/src/components/mcp/README.md +171 -0
  42. package/src/components/mcp/data.ts +659 -0
  43. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  44. package/src/components/mcp/playground-files.ts +107 -0
  45. package/src/components/mcp/playground-uploads.ts +122 -0
  46. package/src/components/mcp/types.ts +65 -0
  47. package/src/components/mcp/use-mcp-page.ts +109 -0
  48. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  49. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  50. package/src/components/viewer/DeviationPanel.tsx +172 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  52. package/src/components/viewer/HoverTooltip.tsx +5 -0
  53. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  54. package/src/components/viewer/IDSPanel.tsx +80 -26
  55. package/src/components/viewer/MainToolbar.tsx +79 -7
  56. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  57. package/src/components/viewer/MobileToolbar.tsx +326 -0
  58. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  59. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  60. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  61. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  62. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  63. package/src/components/viewer/StatusBar.tsx +14 -0
  64. package/src/components/viewer/ViewerLayout.tsx +288 -95
  65. package/src/components/viewer/Viewport.tsx +86 -18
  66. package/src/components/viewer/ViewportContainer.tsx +60 -15
  67. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  68. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  69. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  70. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  71. package/src/components/viewer/selectionHandlers.ts +41 -0
  72. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  73. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  74. package/src/components/viewer/useAnimationLoop.ts +22 -0
  75. package/src/components/viewer/useMouseControls.ts +296 -3
  76. package/src/components/viewer/usePointCloudSync.ts +8 -1
  77. package/src/components/viewer/useRenderUpdates.ts +21 -1
  78. package/src/components/viewer/useTouchControls.ts +100 -41
  79. package/src/generated/mcp-catalog.json +82 -0
  80. package/src/hooks/federationLoadGate.test.ts +90 -0
  81. package/src/hooks/federationLoadGate.ts +127 -0
  82. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  83. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  84. package/src/hooks/useDrawingGeneration.ts +81 -8
  85. package/src/hooks/useIDS.ts +90 -10
  86. package/src/hooks/useIfcFederation.ts +94 -16
  87. package/src/hooks/useIfcLoader.ts +289 -64
  88. package/src/hooks/useViewerSelectors.ts +10 -0
  89. package/src/lib/geo/cesium-bridge.ts +84 -67
  90. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  91. package/src/lib/geo/clamp-anchor.ts +57 -0
  92. package/src/lib/geo/effective-georef.test.ts +79 -1
  93. package/src/lib/geo/effective-georef.ts +83 -0
  94. package/src/lib/geo/reproject.ts +26 -13
  95. package/src/lib/geo/terrain-elevation.ts +166 -0
  96. package/src/lib/lens/adapter.ts +1 -1
  97. package/src/lib/llm/context-builder.ts +1 -1
  98. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  99. package/src/lib/perf/memoryAccounting.ts +235 -0
  100. package/src/sdk/adapters/mutation-view.ts +1 -1
  101. package/src/store/constants.ts +39 -2
  102. package/src/store/index.ts +6 -1
  103. package/src/store/slices/cesiumSlice.ts +1 -1
  104. package/src/store/slices/idsSlice.ts +24 -0
  105. package/src/store/slices/loadingSlice.ts +12 -0
  106. package/src/store/slices/pointCloudSlice.ts +72 -1
  107. package/src/store/slices/sectionSlice.test.ts +590 -1
  108. package/src/store/slices/sectionSlice.ts +344 -17
  109. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  110. package/src/store/slices/uiSlice.ts +60 -2
  111. package/src/store/types.ts +42 -0
  112. package/src/store.ts +13 -0
  113. package/src/utils/acquireFileBuffer.test.ts +231 -0
  114. package/src/utils/acquireFileBuffer.ts +128 -0
  115. package/src/utils/ifcConfig.ts +24 -0
  116. package/src/utils/nativeSpatialDataStore.ts +20 -2
  117. package/src/utils/spatialHierarchy.test.ts +116 -0
  118. package/src/utils/spatialHierarchy.ts +23 -0
  119. package/tailwind.config.js +5 -0
  120. package/tsconfig.json +1 -0
  121. package/vite.config.ts +12 -0
  122. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  123. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  124. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  125. package/dist/assets/exporters-BraHBeoi.js +0 -81583
  126. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  127. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  128. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  129. package/dist/assets/index-0XpVr_S5.css +0 -1
@@ -2,11 +2,12 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
5
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
6
6
  import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from 'react-resizable-panels';
7
7
  import type { PanelImperativeHandle } from 'react-resizable-panels';
8
8
  import { TooltipProvider } from '@/components/ui/tooltip';
9
9
  import { MainToolbar } from './MainToolbar';
10
+ import { MobileToolbar } from './MobileToolbar';
10
11
  import { HierarchyPanel } from './HierarchyPanel';
11
12
  import { PropertiesPanel } from './PropertiesPanel';
12
13
  import { AddElementPanel } from './AddElementPanel';
@@ -14,6 +15,7 @@ import { StatusBar } from './StatusBar';
14
15
  import { ViewportContainer } from './ViewportContainer';
15
16
  import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
16
17
  import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
18
+ import { useIfc } from '@/hooks/useIfc';
17
19
  import { useViewerStore } from '@/store';
18
20
  import { EntityContextMenu } from './EntityContextMenu';
19
21
  import { useDuplicateShortcut } from './useDuplicateShortcut';
@@ -176,10 +178,22 @@ export function ViewerLayout() {
176
178
  cleanupRef.current = cleanup;
177
179
  }, [bottomHeight]);
178
180
 
179
- // Detect mobile viewport
181
+ // Track the gap between the layout viewport (innerHeight) and the visual viewport.
182
+ // On iOS Safari with bottom URL bar, dvh/innerHeight INCLUDES the URL bar area,
183
+ // so anything at `bottom: 0` lands behind it. visualViewport.height excludes
184
+ // the URL bar overlay, giving us the real visible bottom.
185
+ const bottomViewportInset = useVisualViewportBottomInset();
186
+
187
+ // Hide mobile floating buttons when the empty-state "Load IFC" card is showing.
188
+ const { models, geometryResult } = useIfc();
189
+ const hasModelsLoaded = models.size > 0 || ((geometryResult?.meshes?.length ?? 0) > 0);
190
+
191
+ // Detect mobile viewport — use both width check AND touch capability
180
192
  useEffect(() => {
181
193
  const checkMobile = () => {
182
- const mobile = window.innerWidth < 768;
194
+ const narrowScreen = window.innerWidth < 768;
195
+ const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
196
+ const mobile = narrowScreen || (hasTouchScreen && window.innerWidth < 1024);
183
197
  setIsMobile(mobile);
184
198
  // Auto-collapse panels on mobile
185
199
  if (mobile) {
@@ -202,7 +216,7 @@ export function ViewerLayout() {
202
216
 
203
217
  return (
204
218
  <TooltipProvider delayDuration={300}>
205
- <div className="flex flex-col h-screen w-screen overflow-hidden bg-background text-foreground">
219
+ <div className="flex flex-col h-screen h-[100dvh] w-screen overflow-hidden bg-background text-foreground">
206
220
  {/* Keyboard Shortcuts Dialog */}
207
221
  <KeyboardShortcutsDialog open={shortcutsDialog.open} onClose={shortcutsDialog.close} />
208
222
 
@@ -212,9 +226,9 @@ export function ViewerLayout() {
212
226
  <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
213
227
  <SearchModal />
214
228
 
215
- {/* Main Toolbar */}
216
- <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
217
- <DesktopEntitlementBanner />
229
+ {/* Main Toolbar — use compact MobileToolbar on mobile */}
230
+ {isMobile ? <MobileToolbar /> : <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />}
231
+ {!isMobile && <DesktopEntitlementBanner />}
218
232
 
219
233
  {/* Main Content Area - Desktop Layout */}
220
234
  {!isMobile && (
@@ -309,112 +323,291 @@ export function ViewerLayout() {
309
323
 
310
324
  {/* Main Content Area - Mobile Layout */}
311
325
  {isMobile && (
312
- <div className="flex-1 min-h-0 relative">
326
+ <div className="flex-1 min-h-0 relative overflow-hidden">
313
327
  {/* Full-screen Viewport */}
314
328
  <div className="h-full w-full">
315
329
  <ViewportContainer />
316
330
  </div>
317
331
 
332
+ {/* Backdrop overlay when sheet is open */}
333
+ {(!leftPanelCollapsed || !rightPanelCollapsed) && (
334
+ <div
335
+ className="absolute inset-0 bg-black/40 z-30 animate-in fade-in duration-200"
336
+ onClick={() => {
337
+ setLeftPanelCollapsed(true);
338
+ setRightPanelCollapsed(true);
339
+ }}
340
+ />
341
+ )}
342
+
318
343
  {/* Mobile Bottom Sheet - Hierarchy */}
319
344
  {!leftPanelCollapsed && (
320
- <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
321
- <div className="flex items-center justify-between p-2 border-b">
322
- <span className="font-medium text-sm">Hierarchy</span>
323
- <button
324
- className="p-1 hover:bg-muted rounded"
325
- onClick={() => setLeftPanelCollapsed(true)}
326
- >
327
- <span className="sr-only">Close</span>
328
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
329
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
330
- </svg>
331
- </button>
332
- </div>
333
- <div className="h-[calc(50vh-48px)] overflow-auto">
334
- <HierarchyPanel />
335
- </div>
336
- </div>
345
+ <MobileBottomSheet
346
+ title="Hierarchy"
347
+ bottomInset={bottomViewportInset}
348
+ onClose={() => setLeftPanelCollapsed(true)}
349
+ >
350
+ <HierarchyPanel />
351
+ </MobileBottomSheet>
337
352
  )}
338
353
 
339
354
  {/* Mobile Bottom Sheet - Properties, BCF, IDS, or Lists */}
340
355
  {!rightPanelCollapsed && (
341
- <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
342
- <div className="flex items-center justify-between p-2 border-b">
343
- <span className="font-medium text-sm">
344
- {activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Inspector'}
345
- </span>
346
- <button
347
- className="p-1 hover:bg-muted rounded"
348
- onClick={() => {
349
- setRightPanelCollapsed(true);
350
- if (scriptPanelVisible) setScriptPanelVisible(false);
351
- if (listPanelVisible) setListPanelVisible(false);
352
- if (ganttPanelVisible) setGanttPanelVisible(false);
353
- if (bcfPanelVisible) setBcfPanelVisible(false);
354
- if (lensPanelVisible) setLensPanelVisible(false);
355
- if (idsPanelVisible) setIdsPanelVisible(false);
356
- if (activeAnalysisExtension) closeActiveAnalysisExtension();
357
- }}
358
- >
359
- <span className="sr-only">Close</span>
360
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
361
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
362
- </svg>
363
- </button>
364
- </div>
365
- <div className="h-[calc(50vh-48px)] overflow-auto">
366
- {activeBottomAnalysisExtension ? (
367
- activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
368
- ) : activeRightAnalysisExtension ? (
369
- activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
370
- ) : ganttPanelVisible ? (
371
- <GanttPanel onClose={() => setGanttPanelVisible(false)} />
372
- ) : scriptPanelVisible ? (
373
- <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
374
- ) : listPanelVisible ? (
375
- <ListPanel onClose={() => setListPanelVisible(false)} />
376
- ) : activeTool === 'addElement' ? (
377
- <AddElementPanel onClose={() => setActiveTool('select')} />
378
- ) : lensPanelVisible ? (
379
- <LensPanel onClose={() => setLensPanelVisible(false)} />
380
- ) : idsPanelVisible ? (
381
- <IDSPanel onClose={() => setIdsPanelVisible(false)} />
382
- ) : bcfPanelVisible ? (
383
- <BCFPanel onClose={() => setBcfPanelVisible(false)} />
384
- ) : (
385
- <PropertiesPanel />
386
- )}
387
- </div>
388
- </div>
389
- )}
390
-
391
- {/* Mobile Action Buttons */}
392
- <div className="absolute bottom-4 left-4 right-4 flex justify-center gap-2 z-30">
393
- <button
394
- className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
395
- onClick={() => {
356
+ <MobileBottomSheet
357
+ title={activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : activeTool === 'addElement' ? 'Add element' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
358
+ bottomInset={bottomViewportInset}
359
+ onClose={() => {
396
360
  setRightPanelCollapsed(true);
397
- setLeftPanelCollapsed(!leftPanelCollapsed);
398
- }}
399
- >
400
- Hierarchy
401
- </button>
402
- <button
403
- className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
404
- onClick={() => {
405
- setLeftPanelCollapsed(true);
406
- setRightPanelCollapsed(!rightPanelCollapsed);
361
+ if (scriptPanelVisible) setScriptPanelVisible(false);
362
+ if (listPanelVisible) setListPanelVisible(false);
363
+ if (ganttPanelVisible) setGanttPanelVisible(false);
364
+ if (bcfPanelVisible) setBcfPanelVisible(false);
365
+ if (lensPanelVisible) setLensPanelVisible(false);
366
+ if (idsPanelVisible) setIdsPanelVisible(false);
367
+ if (activeAnalysisExtension) closeActiveAnalysisExtension();
368
+ if (activeTool === 'addElement') setActiveTool('select');
407
369
  }}
408
370
  >
409
- Inspector
410
- </button>
411
- </div>
371
+ {activeBottomAnalysisExtension ? (
372
+ activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
373
+ ) : activeRightAnalysisExtension ? (
374
+ activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
375
+ ) : ganttPanelVisible ? (
376
+ <GanttPanel onClose={() => setGanttPanelVisible(false)} />
377
+ ) : scriptPanelVisible ? (
378
+ <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
379
+ ) : listPanelVisible ? (
380
+ <ListPanel onClose={() => setListPanelVisible(false)} />
381
+ ) : activeTool === 'addElement' ? (
382
+ <AddElementPanel onClose={() => setActiveTool('select')} />
383
+ ) : lensPanelVisible ? (
384
+ <LensPanel onClose={() => setLensPanelVisible(false)} />
385
+ ) : idsPanelVisible ? (
386
+ <IDSPanel onClose={() => setIdsPanelVisible(false)} />
387
+ ) : bcfPanelVisible ? (
388
+ <BCFPanel onClose={() => setBcfPanelVisible(false)} />
389
+ ) : (
390
+ <PropertiesPanel />
391
+ )}
392
+ </MobileBottomSheet>
393
+ )}
394
+
395
+ {/* Mobile Floating Buttons — top-left, brutalist vocabulary (tight radii, visible
396
+ borders, uppercase caption) matching panel headers across the app.
397
+ Hidden in the empty state so the "Load IFC" card stays unobstructed. */}
398
+ {leftPanelCollapsed && rightPanelCollapsed && hasModelsLoaded && (
399
+ <div className="absolute top-4 left-4 flex flex-col gap-2.5 z-20">
400
+ <button
401
+ className="flex flex-col items-center gap-1 group touch-manipulation"
402
+ onClick={() => {
403
+ setRightPanelCollapsed(true);
404
+ setLeftPanelCollapsed(false);
405
+ }}
406
+ aria-label="Open Hierarchy"
407
+ >
408
+ <span className="grid place-items-center min-h-[44px] min-w-[44px] bg-background/90 backdrop-blur-sm border border-border rounded-md group-active:bg-foreground group-active:text-background transition-colors">
409
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h10M4 18h7" /></svg>
410
+ </span>
411
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground leading-none">Hierarchy</span>
412
+ </button>
413
+ <button
414
+ className="flex flex-col items-center gap-1 group touch-manipulation"
415
+ onClick={() => {
416
+ setLeftPanelCollapsed(true);
417
+ setRightPanelCollapsed(false);
418
+ }}
419
+ aria-label="Open Properties"
420
+ >
421
+ <span className="grid place-items-center min-h-[44px] min-w-[44px] bg-background/90 backdrop-blur-sm border border-border rounded-md group-active:bg-foreground group-active:text-background transition-colors">
422
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
423
+ </span>
424
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground leading-none">Properties</span>
425
+ </button>
426
+ </div>
427
+ )}
412
428
  </div>
413
429
  )}
414
430
 
415
- {/* Status Bar */}
416
- <StatusBar />
431
+ {/* Status Bar — hidden on mobile to maximize viewport space */}
432
+ {!isMobile && <StatusBar />}
417
433
  </div>
418
434
  </TooltipProvider>
419
435
  );
420
436
  }
437
+
438
+ /**
439
+ * Tracks the gap between the layout viewport (innerHeight) and the visual viewport.
440
+ * Returns the number of pixels the layout viewport extends below the visible area —
441
+ * i.e. how tall the iOS Safari URL bar overlay (or virtual keyboard) is.
442
+ */
443
+ function useVisualViewportBottomInset(): number {
444
+ const [inset, setInset] = useState(0);
445
+ useEffect(() => {
446
+ const vv = window.visualViewport;
447
+ if (!vv) return;
448
+ const update = () => {
449
+ const gap = window.innerHeight - vv.height - vv.offsetTop;
450
+ setInset(Math.max(0, Math.round(gap)));
451
+ };
452
+ update();
453
+ vv.addEventListener('resize', update);
454
+ vv.addEventListener('scroll', update);
455
+ return () => {
456
+ vv.removeEventListener('resize', update);
457
+ vv.removeEventListener('scroll', update);
458
+ };
459
+ }, []);
460
+ return inset;
461
+ }
462
+
463
+ /**
464
+ * Mobile bottom sheet with three snap states (dismissed / default / expanded).
465
+ * Drag the handle: down to shrink/dismiss, up to enlarge. Velocity-based flicks
466
+ * cross thresholds instantly; otherwise the sheet snaps to the closest state.
467
+ *
468
+ * `bottomInset` lifts the sheet above the iOS Safari URL bar overlay.
469
+ */
470
+ function MobileBottomSheet({
471
+ title,
472
+ onClose,
473
+ bottomInset,
474
+ children,
475
+ }: {
476
+ title: ReactNode;
477
+ onClose: () => void;
478
+ bottomInset: number;
479
+ children: ReactNode;
480
+ }) {
481
+ const sheetRef = useRef<HTMLDivElement>(null);
482
+ const dragRef = useRef<{ startY: number; startT: number; startHeight: number; active: boolean }>({
483
+ startY: 0,
484
+ startT: 0,
485
+ startHeight: 0,
486
+ active: false,
487
+ });
488
+
489
+ const SPRING = 'height 220ms cubic-bezier(0.2, 0, 0, 1)';
490
+
491
+ const getSnapPoints = useCallback(() => {
492
+ const h = window.visualViewport?.height ?? window.innerHeight;
493
+ return {
494
+ collapsed: 0,
495
+ defaultH: Math.round(h * 0.6),
496
+ expanded: Math.round(h * 0.92),
497
+ };
498
+ }, []);
499
+
500
+ const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
501
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
502
+ const sheet = sheetRef.current;
503
+ if (!sheet) return;
504
+ dragRef.current = {
505
+ startY: e.clientY,
506
+ startT: performance.now(),
507
+ startHeight: sheet.getBoundingClientRect().height,
508
+ active: true,
509
+ };
510
+ sheet.style.transition = 'none';
511
+ e.currentTarget.setPointerCapture(e.pointerId);
512
+ }, []);
513
+
514
+ const onPointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
515
+ const sheet = sheetRef.current;
516
+ if (!dragRef.current.active || !sheet) return;
517
+ const dy = e.clientY - dragRef.current.startY;
518
+ const { expanded } = getSnapPoints();
519
+ const newHeight = Math.max(0, Math.min(expanded, dragRef.current.startHeight - dy));
520
+ sheet.style.height = `${newHeight}px`;
521
+ }, [getSnapPoints]);
522
+
523
+ const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
524
+ const sheet = sheetRef.current;
525
+ if (!dragRef.current.active || !sheet) return;
526
+ dragRef.current.active = false;
527
+ const dy = e.clientY - dragRef.current.startY;
528
+ const dt = Math.max(1, performance.now() - dragRef.current.startT);
529
+ // Positive velocity = upward drag (intent: enlarge).
530
+ const upwardVelocity = -dy / dt; // px/ms
531
+ const { collapsed, defaultH, expanded } = getSnapPoints();
532
+ const currentHeight = sheet.getBoundingClientRect().height;
533
+
534
+ sheet.style.transition = SPRING;
535
+
536
+ const snapTo = (h: number) => {
537
+ sheet.style.height = `${h}px`;
538
+ };
539
+
540
+ // Velocity-driven decisions take precedence over position.
541
+ if (upwardVelocity > 0.5) {
542
+ snapTo(expanded);
543
+ return;
544
+ }
545
+ if (upwardVelocity < -0.5) {
546
+ // Downward flick: from expanded → default, from default → dismiss.
547
+ if (dragRef.current.startHeight >= expanded - 8) {
548
+ snapTo(defaultH);
549
+ } else {
550
+ snapTo(collapsed);
551
+ window.setTimeout(onClose, 200);
552
+ }
553
+ return;
554
+ }
555
+
556
+ // Position-based snap: closest of the three targets.
557
+ const targets: Array<{ state: 'collapsed' | 'default' | 'expanded'; h: number }> = [
558
+ { state: 'collapsed', h: collapsed },
559
+ { state: 'default', h: defaultH },
560
+ { state: 'expanded', h: expanded },
561
+ ];
562
+ let closest = targets[1];
563
+ for (const t of targets) {
564
+ if (Math.abs(currentHeight - t.h) < Math.abs(currentHeight - closest.h)) closest = t;
565
+ }
566
+ snapTo(closest.h);
567
+ if (closest.state === 'collapsed') window.setTimeout(onClose, 200);
568
+ }, [getSnapPoints, onClose]);
569
+
570
+ // Initial height = default snap. Recompute when viewport changes (URL bar collapses).
571
+ useEffect(() => {
572
+ const sheet = sheetRef.current;
573
+ if (!sheet) return;
574
+ const { defaultH } = getSnapPoints();
575
+ sheet.style.height = `${defaultH}px`;
576
+ }, [getSnapPoints]);
577
+
578
+ return (
579
+ <div
580
+ ref={sheetRef}
581
+ className="absolute inset-x-0 flex flex-col bg-background border-t rounded-t-2xl shadow-2xl z-40 animate-in slide-in-from-bottom duration-300"
582
+ style={{ bottom: `${bottomInset}px` }}
583
+ >
584
+ {/* Drag affordance — generously sized for touch */}
585
+ <div
586
+ className="grid place-items-center pt-3 pb-2 cursor-grab active:cursor-grabbing touch-none select-none"
587
+ onPointerDown={onPointerDown}
588
+ onPointerMove={onPointerMove}
589
+ onPointerUp={onPointerUp}
590
+ onPointerCancel={onPointerUp}
591
+ role="button"
592
+ aria-label="Drag to resize or dismiss"
593
+ >
594
+ <div className="w-10 h-1.5 rounded-full bg-muted-foreground/40" />
595
+ </div>
596
+ <div className="flex items-center justify-between px-4 pb-2 shrink-0">
597
+ <span className="font-semibold text-sm">{title}</span>
598
+ <button
599
+ className="p-2 -mr-2 hover:bg-muted rounded-full active:bg-muted/80 touch-manipulation"
600
+ onClick={onClose}
601
+ aria-label="Close"
602
+ >
603
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
604
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
605
+ </svg>
606
+ </button>
607
+ </div>
608
+ <div className="flex-1 min-h-0 overflow-auto overscroll-contain border-t">
609
+ {children}
610
+ </div>
611
+ </div>
612
+ );
613
+ }