403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react'
|
||
import { holosphereService, HoloSphereService, HolonData, HolonLens } from '@/lib/HoloSphereService'
|
||
import * as h3 from 'h3-js'
|
||
|
||
interface HolonBrowserProps {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
onSelectHolon: (holonData: HolonData) => void
|
||
shapeMode?: boolean
|
||
}
|
||
|
||
interface HolonInfo {
|
||
id: string
|
||
name: string
|
||
description?: string
|
||
latitude: number
|
||
longitude: number
|
||
resolution: number
|
||
resolutionName: string
|
||
data: Record<string, any>
|
||
lastUpdated: number
|
||
}
|
||
|
||
export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false }: HolonBrowserProps) {
|
||
const [holonId, setHolonId] = useState('')
|
||
const [holonInfo, setHolonInfo] = useState<HolonInfo | null>(null)
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [lenses, setLenses] = useState<string[]>([])
|
||
const [selectedLens, setSelectedLens] = useState<string>('')
|
||
const [lensData, setLensData] = useState<any>(null)
|
||
const [isLoadingData, setIsLoadingData] = useState(false)
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
|
||
useEffect(() => {
|
||
if (isOpen && inputRef.current) {
|
||
inputRef.current.focus()
|
||
}
|
||
}, [isOpen])
|
||
|
||
const handleSearchHolon = async () => {
|
||
if (!holonId.trim()) {
|
||
setError('Please enter a Holon ID')
|
||
return
|
||
}
|
||
|
||
setIsLoading(true)
|
||
setError(null)
|
||
setHolonInfo(null)
|
||
|
||
try {
|
||
// Check if it's a valid H3 cell ID
|
||
const isH3Cell = h3.isValidCell(holonId)
|
||
|
||
// Check if it's a numeric Holon ID (workspace/group identifier)
|
||
const isNumericId = /^\d{6,20}$/.test(holonId)
|
||
|
||
// Check if it's an alphanumeric identifier
|
||
const isAlphanumericId = /^[a-zA-Z0-9_-]{3,50}$/.test(holonId)
|
||
|
||
if (!isH3Cell && !isNumericId && !isAlphanumericId) {
|
||
throw new Error('Invalid Holon ID. Enter an H3 cell ID (e.g., 872a1070bffffff) or a numeric Holon ID (e.g., 1002848305066)')
|
||
}
|
||
|
||
// Get holon information based on ID type
|
||
let resolution: number
|
||
let lat: number
|
||
let lng: number
|
||
|
||
if (isH3Cell) {
|
||
resolution = h3.getResolution(holonId)
|
||
;[lat, lng] = h3.cellToLatLng(holonId)
|
||
} else {
|
||
// For non-H3 IDs, use default values
|
||
resolution = -1 // Indicates non-geospatial holon
|
||
lat = 0
|
||
lng = 0
|
||
}
|
||
|
||
// Try to get metadata from the holon
|
||
let metadata = null
|
||
try {
|
||
metadata = await holosphereService.getData(holonId, 'metadata')
|
||
} catch (error) {
|
||
console.log('No metadata found for holon')
|
||
}
|
||
|
||
// Get available lenses by trying to fetch data from common lens types
|
||
// Use the improved categories from HolonShapeUtil
|
||
const commonLenses = [
|
||
'active_users', 'users', 'rankings', 'stats', 'tasks', 'progress',
|
||
'events', 'activities', 'items', 'shopping', 'active_items',
|
||
'proposals', 'offers', 'requests', 'checklists', 'roles',
|
||
'general', 'metadata', 'environment', 'social', 'economic', 'cultural', 'data'
|
||
]
|
||
const availableLenses: string[] = []
|
||
|
||
for (const lens of commonLenses) {
|
||
try {
|
||
// Use getDataWithWait for better Gun data retrieval (shorter timeout for browser)
|
||
const data = await holosphereService.getDataWithWait(holonId, lens, 1000)
|
||
if (data && (Array.isArray(data) ? data.length > 0 : Object.keys(data).length > 0)) {
|
||
availableLenses.push(lens)
|
||
console.log(`✓ Found lens: ${lens} with ${Object.keys(data).length} keys`)
|
||
}
|
||
} catch (error) {
|
||
// Lens doesn't exist or is empty, skip
|
||
}
|
||
}
|
||
|
||
// If no lenses found, add 'general' as default
|
||
if (availableLenses.length === 0) {
|
||
availableLenses.push('general')
|
||
}
|
||
|
||
const holonData: HolonInfo = {
|
||
id: holonId,
|
||
name: metadata?.name || `Holon ${holonId.slice(-8)}`,
|
||
description: metadata?.description || '',
|
||
latitude: lat,
|
||
longitude: lng,
|
||
resolution: resolution,
|
||
resolutionName: resolution >= 0
|
||
? HoloSphereService.getResolutionName(resolution)
|
||
: 'Workspace / Group',
|
||
data: {},
|
||
lastUpdated: metadata?.lastUpdated || Date.now()
|
||
}
|
||
|
||
setHolonInfo(holonData)
|
||
setLenses(availableLenses)
|
||
setSelectedLens(availableLenses[0])
|
||
|
||
} catch (error) {
|
||
console.error('Error searching holon:', error)
|
||
setError(`Failed to load holon: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleLoadLensData = async (lens: string) => {
|
||
if (!holonInfo) return
|
||
|
||
setIsLoadingData(true)
|
||
try {
|
||
// Use getDataWithWait for better Gun data retrieval
|
||
const data = await holosphereService.getDataWithWait(holonInfo.id, lens, 2000)
|
||
setLensData(data)
|
||
console.log(`📊 Loaded lens data for ${lens}:`, data)
|
||
} catch (error) {
|
||
console.error('Error loading lens data:', error)
|
||
setLensData(null)
|
||
} finally {
|
||
setIsLoadingData(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (selectedLens && holonInfo) {
|
||
handleLoadLensData(selectedLens)
|
||
}
|
||
}, [selectedLens, holonInfo])
|
||
|
||
const handleSelectHolon = () => {
|
||
if (holonInfo) {
|
||
const holonData: HolonData = {
|
||
id: holonInfo.id,
|
||
name: holonInfo.name,
|
||
description: holonInfo.description,
|
||
latitude: holonInfo.latitude,
|
||
longitude: holonInfo.longitude,
|
||
resolution: holonInfo.resolution,
|
||
data: holonInfo.data,
|
||
timestamp: holonInfo.lastUpdated
|
||
}
|
||
onSelectHolon(holonData)
|
||
onClose()
|
||
}
|
||
}
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter') {
|
||
handleSearchHolon()
|
||
} else if (e.key === 'Escape') {
|
||
onClose()
|
||
}
|
||
}
|
||
|
||
if (!isOpen) return null
|
||
|
||
const contentStyle: React.CSSProperties = shapeMode ? {
|
||
width: '100%',
|
||
height: '100%',
|
||
overflow: 'auto',
|
||
padding: '20px',
|
||
position: 'relative',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
} : {}
|
||
|
||
const renderContent = () => (
|
||
<>
|
||
{!shapeMode && (
|
||
<div className="p-6 border-b border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xl font-bold text-gray-900">🌐 Holon Browser</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mt-2">
|
||
Enter a Holon ID (numeric like 1002848305066 or H3 cell like 872a1070bffffff) to browse its data
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div style={shapeMode ? { display: 'flex', flexDirection: 'column', gap: '24px', flex: 1, overflow: 'auto' } : { padding: '24px', display: 'flex', flexDirection: 'column', gap: '24px', maxHeight: 'calc(90vh - 120px)', overflowY: 'auto' }}>
|
||
{/* Holon ID Input */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Holon ID
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={holonId}
|
||
onChange={(e) => setHolonId(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="e.g., 1002848305066 or 872a1070bffffff"
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 z-[10001] relative"
|
||
disabled={isLoading}
|
||
style={{ zIndex: 10001 }}
|
||
/>
|
||
<button
|
||
onClick={handleSearchHolon}
|
||
disabled={isLoading || !holonId.trim()}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed z-[10001] relative"
|
||
style={{ zIndex: 10001 }}
|
||
>
|
||
{isLoading ? 'Searching...' : 'Search'}
|
||
</button>
|
||
</div>
|
||
{error && (
|
||
<p className="text-red-600 text-sm mt-2">{error}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Holon Information */}
|
||
{holonInfo && (
|
||
<div className="border border-gray-200 rounded-lg p-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||
📍 {holonInfo.name}
|
||
</h3>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||
{holonInfo.resolution >= 0 ? (
|
||
<>
|
||
<div>
|
||
<p className="text-sm text-gray-600">Coordinates</p>
|
||
<p className="font-mono text-sm">
|
||
{holonInfo.latitude.toFixed(6)}, {holonInfo.longitude.toFixed(6)}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-600">Resolution</p>
|
||
<p className="text-sm">
|
||
{holonInfo.resolutionName} (Level {holonInfo.resolution})
|
||
</p>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div>
|
||
<p className="text-sm text-gray-600">Type</p>
|
||
<p className="text-sm font-medium text-green-600">
|
||
{holonInfo.resolutionName}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<p className="text-sm text-gray-600">Holon ID</p>
|
||
<p className="font-mono text-xs break-all">{holonInfo.id}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-600">Last Updated</p>
|
||
<p className="text-sm">
|
||
{new Date(holonInfo.lastUpdated).toLocaleString()}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{holonInfo.description && (
|
||
<div className="mb-4">
|
||
<p className="text-sm text-gray-600">Description</p>
|
||
<p className="text-sm text-gray-800">{holonInfo.description}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Available Lenses */}
|
||
<div className="mb-4">
|
||
<p className="text-sm text-gray-600 mb-2">Available Data Categories</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{lenses.map((lens) => (
|
||
<button
|
||
key={lens}
|
||
onClick={() => setSelectedLens(lens)}
|
||
className={`px-3 py-1 rounded-full text-sm z-[10001] relative ${
|
||
selectedLens === lens
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||
}`}
|
||
style={{ zIndex: 10001 }}
|
||
>
|
||
{lens}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Lens Data */}
|
||
{selectedLens && (
|
||
<div className="border-t border-gray-200 pt-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h4 className="text-md font-medium text-gray-900">
|
||
Data: {selectedLens}
|
||
</h4>
|
||
{isLoadingData && (
|
||
<span className="text-sm text-gray-500">Loading...</span>
|
||
)}
|
||
</div>
|
||
|
||
{lensData && (
|
||
<div className="bg-gray-50 rounded-md p-3 max-h-48 overflow-y-auto">
|
||
<pre className="text-xs text-gray-800 whitespace-pre-wrap">
|
||
{JSON.stringify(lensData, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
{!lensData && !isLoadingData && (
|
||
<p className="text-sm text-gray-500 italic">
|
||
No data available for this category
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex gap-3 mt-6 pt-4 border-t border-gray-200">
|
||
<button
|
||
onClick={handleSelectHolon}
|
||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 z-[10001] relative"
|
||
style={{ zIndex: 10001 }}
|
||
>
|
||
Import to Canvas
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setHolonInfo(null)
|
||
setHolonId('')
|
||
setError(null)
|
||
}}
|
||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 z-[10001] relative"
|
||
style={{ zIndex: 10001 }}
|
||
>
|
||
Search Another
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)
|
||
|
||
// If in shape mode, return content without modal overlay
|
||
if (shapeMode) {
|
||
return (
|
||
<div style={contentStyle}>
|
||
{renderContent()}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Otherwise, return with modal overlay
|
||
return (
|
||
<div
|
||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden z-[10000]"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{renderContent()}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|