@ifc-lite/viewer 1.6.0 → 1.7.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.
- package/CHANGELOG.md +78 -0
- package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
- package/dist/assets/index-yTqs8kgX.css +1 -0
- package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
- package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/HierarchyPanel.tsx +110 -842
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +603 -0
- package/src/components/viewer/MainToolbar.tsx +188 -21
- package/src/components/viewer/PropertiesPanel.tsx +171 -663
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +76 -2648
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +237 -1659
- package/src/components/viewer/ViewportContainer.tsx +11 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +398 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +627 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +1 -1
- package/src/hooks/useLens.ts +129 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useViewControls.ts +218 -0
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +138 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +38 -2
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/lensSlice.ts +184 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +114 -0
- package/src/store/types.ts +5 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-v3mcCUPN.css +0 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BCFTopicList - Topic list with filtering and sorting for the BCF panel.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useCallback, useState, useMemo } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
Plus,
|
|
12
|
+
MessageSquare,
|
|
13
|
+
Camera,
|
|
14
|
+
Filter,
|
|
15
|
+
Edit2,
|
|
16
|
+
User,
|
|
17
|
+
Calendar,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
import { Button } from '@/components/ui/button';
|
|
20
|
+
import { Input } from '@/components/ui/input';
|
|
21
|
+
import { Label } from '@/components/ui/label';
|
|
22
|
+
import {
|
|
23
|
+
Select,
|
|
24
|
+
SelectContent,
|
|
25
|
+
SelectItem,
|
|
26
|
+
SelectTrigger,
|
|
27
|
+
SelectValue,
|
|
28
|
+
} from '@/components/ui/select';
|
|
29
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
30
|
+
import type { BCFTopic } from '@ifc-lite/bcf';
|
|
31
|
+
import { StatusBadge, PriorityBadge, formatDate, TOPIC_STATUSES } from './bcfHelpers';
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Types
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
export interface BCFTopicListProps {
|
|
38
|
+
topics: BCFTopic[];
|
|
39
|
+
onSelectTopic: (topicId: string) => void;
|
|
40
|
+
onCreateTopic: () => void;
|
|
41
|
+
statusFilter: string;
|
|
42
|
+
onStatusFilterChange: (status: string) => void;
|
|
43
|
+
author: string;
|
|
44
|
+
onSetAuthor: (author: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Component
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
export function BCFTopicList({
|
|
52
|
+
topics,
|
|
53
|
+
onSelectTopic,
|
|
54
|
+
onCreateTopic,
|
|
55
|
+
statusFilter,
|
|
56
|
+
onStatusFilterChange,
|
|
57
|
+
author,
|
|
58
|
+
onSetAuthor,
|
|
59
|
+
}: BCFTopicListProps) {
|
|
60
|
+
const [editingEmail, setEditingEmail] = useState(false);
|
|
61
|
+
const [emailInput, setEmailInput] = useState(author);
|
|
62
|
+
const isDefaultEmail = author === 'user@example.com';
|
|
63
|
+
|
|
64
|
+
const handleSaveEmail = useCallback(() => {
|
|
65
|
+
if (emailInput.trim() && emailInput.includes('@')) {
|
|
66
|
+
onSetAuthor(emailInput.trim());
|
|
67
|
+
setEditingEmail(false);
|
|
68
|
+
}
|
|
69
|
+
}, [emailInput, onSetAuthor]);
|
|
70
|
+
const filteredTopics = useMemo(() => {
|
|
71
|
+
if (!statusFilter || statusFilter === 'all') return topics;
|
|
72
|
+
return topics.filter(
|
|
73
|
+
(t) => t.topicStatus?.toLowerCase() === statusFilter.toLowerCase()
|
|
74
|
+
);
|
|
75
|
+
}, [topics, statusFilter]);
|
|
76
|
+
|
|
77
|
+
// Sort by creation date (newest first)
|
|
78
|
+
const sortedTopics = useMemo(() => {
|
|
79
|
+
return [...filteredTopics].sort(
|
|
80
|
+
(a, b) => new Date(b.creationDate).getTime() - new Date(a.creationDate).getTime()
|
|
81
|
+
);
|
|
82
|
+
}, [filteredTopics]);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex flex-col h-full">
|
|
86
|
+
{/* Filter */}
|
|
87
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
|
88
|
+
<Filter className="h-4 w-4 text-muted-foreground" />
|
|
89
|
+
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
|
90
|
+
<SelectTrigger className="h-8 flex-1">
|
|
91
|
+
<SelectValue placeholder="All statuses" />
|
|
92
|
+
</SelectTrigger>
|
|
93
|
+
<SelectContent>
|
|
94
|
+
<SelectItem value="all">All statuses</SelectItem>
|
|
95
|
+
{TOPIC_STATUSES.map((status) => (
|
|
96
|
+
<SelectItem key={status} value={status.toLowerCase()}>
|
|
97
|
+
{status}
|
|
98
|
+
</SelectItem>
|
|
99
|
+
))}
|
|
100
|
+
</SelectContent>
|
|
101
|
+
</Select>
|
|
102
|
+
<Button size="sm" variant="outline" onClick={onCreateTopic}>
|
|
103
|
+
<Plus className="h-4 w-4" />
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Topic List */}
|
|
108
|
+
<ScrollArea className="flex-1">
|
|
109
|
+
{sortedTopics.length === 0 ? (
|
|
110
|
+
<div className="flex flex-col items-center justify-center py-8 px-4 text-muted-foreground text-sm">
|
|
111
|
+
<MessageSquare className="h-8 w-8 mb-2 opacity-50" />
|
|
112
|
+
<p>No topics</p>
|
|
113
|
+
<Button
|
|
114
|
+
variant="link"
|
|
115
|
+
size="sm"
|
|
116
|
+
onClick={onCreateTopic}
|
|
117
|
+
className="mt-1"
|
|
118
|
+
>
|
|
119
|
+
Create first topic
|
|
120
|
+
</Button>
|
|
121
|
+
|
|
122
|
+
{/* Email setup nudge */}
|
|
123
|
+
<div className="mt-6 w-full max-w-xs">
|
|
124
|
+
<div className="border border-border rounded-lg p-3 bg-muted/30">
|
|
125
|
+
{editingEmail ? (
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
<Label className="text-xs text-muted-foreground">Your email for BCF authorship</Label>
|
|
128
|
+
<Input
|
|
129
|
+
value={emailInput}
|
|
130
|
+
onChange={(e) => setEmailInput(e.target.value)}
|
|
131
|
+
placeholder="your@email.com"
|
|
132
|
+
className="h-8 text-sm"
|
|
133
|
+
onKeyDown={(e) => {
|
|
134
|
+
if (e.key === 'Enter') handleSaveEmail();
|
|
135
|
+
if (e.key === 'Escape') setEditingEmail(false);
|
|
136
|
+
}}
|
|
137
|
+
autoFocus
|
|
138
|
+
/>
|
|
139
|
+
<div className="flex gap-2 justify-end">
|
|
140
|
+
<Button
|
|
141
|
+
variant="ghost"
|
|
142
|
+
size="sm"
|
|
143
|
+
onClick={() => {
|
|
144
|
+
setEmailInput(author);
|
|
145
|
+
setEditingEmail(false);
|
|
146
|
+
}}
|
|
147
|
+
className="h-7 text-xs"
|
|
148
|
+
>
|
|
149
|
+
Cancel
|
|
150
|
+
</Button>
|
|
151
|
+
<Button
|
|
152
|
+
size="sm"
|
|
153
|
+
onClick={handleSaveEmail}
|
|
154
|
+
disabled={!emailInput.trim() || !emailInput.includes('@')}
|
|
155
|
+
className="h-7 text-xs"
|
|
156
|
+
>
|
|
157
|
+
Save
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
) : (
|
|
162
|
+
<div className="flex items-center justify-between gap-2">
|
|
163
|
+
<div className="flex-1 min-w-0">
|
|
164
|
+
<p className="text-xs text-muted-foreground mb-0.5">Author</p>
|
|
165
|
+
<p className={`text-sm truncate ${isDefaultEmail ? 'text-amber-600 dark:text-amber-400' : 'text-foreground'}`}>
|
|
166
|
+
{author}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
<Button
|
|
170
|
+
variant={isDefaultEmail ? 'default' : 'ghost'}
|
|
171
|
+
size="sm"
|
|
172
|
+
onClick={() => {
|
|
173
|
+
setEmailInput(author);
|
|
174
|
+
setEditingEmail(true);
|
|
175
|
+
}}
|
|
176
|
+
className="h-7 text-xs shrink-0"
|
|
177
|
+
>
|
|
178
|
+
{isDefaultEmail ? 'Set email' : <Edit2 className="h-3 w-3" />}
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
{isDefaultEmail && !editingEmail && (
|
|
184
|
+
<p className="text-xs text-muted-foreground mt-2 text-center">
|
|
185
|
+
Set your email to identify your issues and comments
|
|
186
|
+
</p>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
191
|
+
<div className="divide-y divide-border">
|
|
192
|
+
{sortedTopics.map((topic) => (
|
|
193
|
+
<button
|
|
194
|
+
key={topic.guid}
|
|
195
|
+
onClick={() => onSelectTopic(topic.guid)}
|
|
196
|
+
className="w-full text-left p-3 hover:bg-accent/50 transition-colors"
|
|
197
|
+
>
|
|
198
|
+
<div className="flex items-start justify-between gap-2 mb-1">
|
|
199
|
+
<h4 className="font-medium text-sm line-clamp-1 flex-1">
|
|
200
|
+
{topic.title}
|
|
201
|
+
</h4>
|
|
202
|
+
<StatusBadge status={topic.topicStatus} />
|
|
203
|
+
</div>
|
|
204
|
+
{topic.description && (
|
|
205
|
+
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
|
206
|
+
{topic.description}
|
|
207
|
+
</p>
|
|
208
|
+
)}
|
|
209
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
210
|
+
<PriorityBadge priority={topic.priority} />
|
|
211
|
+
<span className="flex items-center gap-1">
|
|
212
|
+
<User className="h-3 w-3" />
|
|
213
|
+
{topic.creationAuthor.split('@')[0]}
|
|
214
|
+
</span>
|
|
215
|
+
<span className="flex items-center gap-1">
|
|
216
|
+
<Calendar className="h-3 w-3" />
|
|
217
|
+
{formatDate(topic.creationDate)}
|
|
218
|
+
</span>
|
|
219
|
+
{topic.comments.length > 0 && (
|
|
220
|
+
<span className="flex items-center gap-1">
|
|
221
|
+
<MessageSquare className="h-3 w-3" />
|
|
222
|
+
{topic.comments.length}
|
|
223
|
+
</span>
|
|
224
|
+
)}
|
|
225
|
+
{topic.viewpoints.length > 0 && (
|
|
226
|
+
<span className="flex items-center gap-1">
|
|
227
|
+
<Camera className="h-3 w-3" />
|
|
228
|
+
{topic.viewpoints.length}
|
|
229
|
+
</span>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
</button>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</ScrollArea>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared constants, helper components, and utility functions for BCF panel components.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useMemo } from 'react';
|
|
10
|
+
import { AlertCircle, CheckCircle, Clock } from 'lucide-react';
|
|
11
|
+
import { Badge } from '@/components/ui/badge';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Constants
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export const TOPIC_TYPES = ['Issue', 'Request', 'Comment', 'Error', 'Warning', 'Info'];
|
|
18
|
+
export const TOPIC_STATUSES = ['Open', 'In Progress', 'Resolved', 'Closed'];
|
|
19
|
+
export const PRIORITIES = ['High', 'Medium', 'Low'];
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Helper Components
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export function StatusBadge({ status }: { status?: string }) {
|
|
26
|
+
const variant = useMemo(() => {
|
|
27
|
+
switch (status?.toLowerCase()) {
|
|
28
|
+
case 'open':
|
|
29
|
+
return 'default';
|
|
30
|
+
case 'in progress':
|
|
31
|
+
return 'secondary';
|
|
32
|
+
case 'resolved':
|
|
33
|
+
case 'closed':
|
|
34
|
+
return 'outline';
|
|
35
|
+
default:
|
|
36
|
+
return 'default';
|
|
37
|
+
}
|
|
38
|
+
}, [status]);
|
|
39
|
+
|
|
40
|
+
const Icon = useMemo(() => {
|
|
41
|
+
switch (status?.toLowerCase()) {
|
|
42
|
+
case 'open':
|
|
43
|
+
return AlertCircle;
|
|
44
|
+
case 'in progress':
|
|
45
|
+
return Clock;
|
|
46
|
+
case 'resolved':
|
|
47
|
+
case 'closed':
|
|
48
|
+
return CheckCircle;
|
|
49
|
+
default:
|
|
50
|
+
return AlertCircle;
|
|
51
|
+
}
|
|
52
|
+
}, [status]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Badge variant={variant} className="text-xs gap-1">
|
|
56
|
+
<Icon className="h-3 w-3" />
|
|
57
|
+
{status || 'Open'}
|
|
58
|
+
</Badge>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function PriorityBadge({ priority }: { priority?: string }) {
|
|
63
|
+
const colorClass = useMemo(() => {
|
|
64
|
+
switch (priority?.toLowerCase()) {
|
|
65
|
+
case 'high':
|
|
66
|
+
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
|
67
|
+
case 'medium':
|
|
68
|
+
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
|
69
|
+
case 'low':
|
|
70
|
+
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
|
71
|
+
default:
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}, [priority]);
|
|
75
|
+
|
|
76
|
+
if (!priority) return null;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Badge variant="outline" className={`text-xs ${colorClass}`}>
|
|
80
|
+
{priority}
|
|
81
|
+
</Badge>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// Date Formatters
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
export function formatDate(isoDate: string): string {
|
|
90
|
+
const date = new Date(isoDate);
|
|
91
|
+
if (isNaN(date.getTime())) return isoDate;
|
|
92
|
+
return date.toLocaleDateString(undefined, {
|
|
93
|
+
year: 'numeric',
|
|
94
|
+
month: 'short',
|
|
95
|
+
day: 'numeric',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatDateTime(isoDate: string): string {
|
|
100
|
+
const date = new Date(isoDate);
|
|
101
|
+
if (isNaN(date.getTime())) return isoDate;
|
|
102
|
+
return date.toLocaleString(undefined, {
|
|
103
|
+
year: 'numeric',
|
|
104
|
+
month: 'short',
|
|
105
|
+
day: 'numeric',
|
|
106
|
+
hour: '2-digit',
|
|
107
|
+
minute: '2-digit',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ChevronRight,
|
|
7
|
+
Building2,
|
|
8
|
+
Layers,
|
|
9
|
+
MapPin,
|
|
10
|
+
FolderKanban,
|
|
11
|
+
Square,
|
|
12
|
+
Box,
|
|
13
|
+
DoorOpen,
|
|
14
|
+
Eye,
|
|
15
|
+
EyeOff,
|
|
16
|
+
FileBox,
|
|
17
|
+
X,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
20
|
+
import { cn } from '@/lib/utils';
|
|
21
|
+
import type { TreeNode } from './types';
|
|
22
|
+
import { isSpatialContainer } from './types';
|
|
23
|
+
|
|
24
|
+
const TYPE_ICONS: Record<string, React.ElementType> = {
|
|
25
|
+
'unified-storey': Layers,
|
|
26
|
+
'model-header': FileBox,
|
|
27
|
+
IfcProject: FolderKanban,
|
|
28
|
+
IfcSite: MapPin,
|
|
29
|
+
IfcBuilding: Building2,
|
|
30
|
+
IfcBuildingStorey: Layers,
|
|
31
|
+
IfcSpace: Box,
|
|
32
|
+
IfcWall: Square,
|
|
33
|
+
IfcWallStandardCase: Square,
|
|
34
|
+
IfcDoor: DoorOpen,
|
|
35
|
+
element: Box,
|
|
36
|
+
default: Box,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface HierarchyNodeProps {
|
|
40
|
+
node: TreeNode;
|
|
41
|
+
virtualRow: { size: number; start: number };
|
|
42
|
+
isSelected: boolean;
|
|
43
|
+
nodeHidden: boolean;
|
|
44
|
+
isMultiModel: boolean;
|
|
45
|
+
modelsCount: number;
|
|
46
|
+
modelVisible?: boolean;
|
|
47
|
+
onNodeClick: (node: TreeNode, e: React.MouseEvent) => void;
|
|
48
|
+
onToggleExpand: (nodeId: string) => void;
|
|
49
|
+
onVisibilityToggle: (node: TreeNode) => void;
|
|
50
|
+
onModelVisibilityToggle: (modelId: string, e: React.MouseEvent) => void;
|
|
51
|
+
onRemoveModel: (modelId: string, e: React.MouseEvent) => void;
|
|
52
|
+
onModelHeaderClick: (modelId: string, nodeId: string, hasChildren: boolean) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function HierarchyNode({
|
|
56
|
+
node,
|
|
57
|
+
virtualRow,
|
|
58
|
+
isSelected,
|
|
59
|
+
nodeHidden,
|
|
60
|
+
isMultiModel,
|
|
61
|
+
modelsCount,
|
|
62
|
+
modelVisible,
|
|
63
|
+
onNodeClick,
|
|
64
|
+
onToggleExpand,
|
|
65
|
+
onVisibilityToggle,
|
|
66
|
+
onModelVisibilityToggle,
|
|
67
|
+
onRemoveModel,
|
|
68
|
+
onModelHeaderClick,
|
|
69
|
+
}: HierarchyNodeProps) {
|
|
70
|
+
const Icon = TYPE_ICONS[node.type] || TYPE_ICONS.default;
|
|
71
|
+
|
|
72
|
+
// Model header nodes (for visibility control and expansion)
|
|
73
|
+
if (node.type === 'model-header' && node.id.startsWith('model-')) {
|
|
74
|
+
const modelId = node.modelIds[0];
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
style={{
|
|
79
|
+
position: 'absolute',
|
|
80
|
+
top: 0,
|
|
81
|
+
left: 0,
|
|
82
|
+
width: '100%',
|
|
83
|
+
height: `${virtualRow.size}px`,
|
|
84
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<div
|
|
88
|
+
className={cn(
|
|
89
|
+
'flex items-center gap-1 px-2 py-1.5 border-l-4 transition-all group',
|
|
90
|
+
'hover:bg-zinc-50 dark:hover:bg-zinc-900',
|
|
91
|
+
'border-transparent',
|
|
92
|
+
!modelVisible && 'opacity-50',
|
|
93
|
+
node.hasChildren && 'cursor-pointer'
|
|
94
|
+
)}
|
|
95
|
+
style={{ paddingLeft: '8px' }}
|
|
96
|
+
onClick={() => onModelHeaderClick(modelId, node.id, node.hasChildren)}
|
|
97
|
+
>
|
|
98
|
+
{/* Expand/collapse chevron */}
|
|
99
|
+
{node.hasChildren ? (
|
|
100
|
+
<ChevronRight
|
|
101
|
+
className={cn(
|
|
102
|
+
'h-3.5 w-3.5 text-zinc-400 transition-transform shrink-0',
|
|
103
|
+
node.isExpanded && 'rotate-90'
|
|
104
|
+
)}
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="w-3.5" />
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<FileBox className="h-3.5 w-3.5 text-primary shrink-0" />
|
|
111
|
+
<span className="flex-1 text-sm truncate ml-1.5 text-zinc-900 dark:text-zinc-100">
|
|
112
|
+
{node.name}
|
|
113
|
+
</span>
|
|
114
|
+
|
|
115
|
+
{node.elementCount !== undefined && (
|
|
116
|
+
<span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 text-zinc-500 dark:text-zinc-400 rounded-none">
|
|
117
|
+
{node.elementCount.toLocaleString()}
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
<Tooltip>
|
|
122
|
+
<TooltipTrigger asChild>
|
|
123
|
+
<button
|
|
124
|
+
onClick={(e) => {
|
|
125
|
+
e.stopPropagation();
|
|
126
|
+
onModelVisibilityToggle(modelId, e);
|
|
127
|
+
}}
|
|
128
|
+
className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
129
|
+
>
|
|
130
|
+
{modelVisible ? (
|
|
131
|
+
<Eye className="h-3.5 w-3.5 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
|
|
132
|
+
) : (
|
|
133
|
+
<EyeOff className="h-3.5 w-3.5 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
|
|
134
|
+
)}
|
|
135
|
+
</button>
|
|
136
|
+
</TooltipTrigger>
|
|
137
|
+
<TooltipContent>
|
|
138
|
+
<p className="text-xs">{modelVisible ? 'Hide model' : 'Show model'}</p>
|
|
139
|
+
</TooltipContent>
|
|
140
|
+
</Tooltip>
|
|
141
|
+
|
|
142
|
+
{modelsCount > 1 && (
|
|
143
|
+
<Tooltip>
|
|
144
|
+
<TooltipTrigger asChild>
|
|
145
|
+
<button
|
|
146
|
+
onClick={(e) => {
|
|
147
|
+
e.stopPropagation();
|
|
148
|
+
onRemoveModel(modelId, e);
|
|
149
|
+
}}
|
|
150
|
+
className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
151
|
+
>
|
|
152
|
+
<X className="h-3.5 w-3.5 text-zinc-400 hover:text-red-500" />
|
|
153
|
+
</button>
|
|
154
|
+
</TooltipTrigger>
|
|
155
|
+
<TooltipContent>
|
|
156
|
+
<p className="text-xs">Remove model</p>
|
|
157
|
+
</TooltipContent>
|
|
158
|
+
</Tooltip>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Regular node rendering (spatial hierarchy nodes and elements)
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
style={{
|
|
169
|
+
position: 'absolute',
|
|
170
|
+
top: 0,
|
|
171
|
+
left: 0,
|
|
172
|
+
width: '100%',
|
|
173
|
+
height: `${virtualRow.size}px`,
|
|
174
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<div
|
|
178
|
+
className={cn(
|
|
179
|
+
'flex items-center gap-1 px-2 py-1.5 border-l-4 transition-all group hierarchy-item',
|
|
180
|
+
// No selection styling for spatial containers in multi-model mode
|
|
181
|
+
isMultiModel && isSpatialContainer(node.type)
|
|
182
|
+
? 'border-transparent cursor-default'
|
|
183
|
+
: cn(
|
|
184
|
+
'cursor-pointer',
|
|
185
|
+
isSelected ? 'border-l-primary font-medium selected' : 'border-transparent'
|
|
186
|
+
),
|
|
187
|
+
nodeHidden && 'opacity-50 grayscale'
|
|
188
|
+
)}
|
|
189
|
+
style={{
|
|
190
|
+
paddingLeft: `${node.depth * 16 + 8}px`,
|
|
191
|
+
// No selection highlighting for spatial containers in multi-model mode
|
|
192
|
+
backgroundColor: isSelected && !(isMultiModel && isSpatialContainer(node.type))
|
|
193
|
+
? 'var(--hierarchy-selected-bg)' : undefined,
|
|
194
|
+
color: isSelected && !(isMultiModel && isSpatialContainer(node.type))
|
|
195
|
+
? 'var(--hierarchy-selected-text)' : undefined,
|
|
196
|
+
}}
|
|
197
|
+
onClick={(e) => {
|
|
198
|
+
if ((e.target as HTMLElement).closest('button') === null) {
|
|
199
|
+
onNodeClick(node, e);
|
|
200
|
+
}
|
|
201
|
+
}}
|
|
202
|
+
onMouseDown={(e) => {
|
|
203
|
+
if ((e.target as HTMLElement).closest('button') === null) {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
}
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
{/* Expand/Collapse */}
|
|
209
|
+
{node.hasChildren ? (
|
|
210
|
+
<button
|
|
211
|
+
onClick={(e) => {
|
|
212
|
+
e.stopPropagation();
|
|
213
|
+
onToggleExpand(node.id);
|
|
214
|
+
}}
|
|
215
|
+
className="p-0.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-none mr-1"
|
|
216
|
+
>
|
|
217
|
+
<ChevronRight
|
|
218
|
+
className={cn(
|
|
219
|
+
'h-3.5 w-3.5 transition-transform duration-200',
|
|
220
|
+
node.isExpanded && 'rotate-90'
|
|
221
|
+
)}
|
|
222
|
+
/>
|
|
223
|
+
</button>
|
|
224
|
+
) : (
|
|
225
|
+
<div className="w-5" />
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{/* Visibility Toggle - hide for spatial containers (Project/Site/Building) in multi-model mode */}
|
|
229
|
+
{!(isMultiModel && isSpatialContainer(node.type)) && (
|
|
230
|
+
<Tooltip>
|
|
231
|
+
<TooltipTrigger asChild>
|
|
232
|
+
<button
|
|
233
|
+
onClick={(e) => {
|
|
234
|
+
e.stopPropagation();
|
|
235
|
+
onVisibilityToggle(node);
|
|
236
|
+
}}
|
|
237
|
+
className={cn(
|
|
238
|
+
'p-0.5 opacity-0 group-hover:opacity-100 transition-opacity mr-1',
|
|
239
|
+
nodeHidden && 'opacity-100'
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{node.isVisible ? (
|
|
243
|
+
<Eye className="h-3 w-3 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
|
|
244
|
+
) : (
|
|
245
|
+
<EyeOff className="h-3 w-3 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
|
|
246
|
+
)}
|
|
247
|
+
</button>
|
|
248
|
+
</TooltipTrigger>
|
|
249
|
+
<TooltipContent>
|
|
250
|
+
<p className="text-xs">
|
|
251
|
+
{node.isVisible ? 'Hide' : 'Show'}
|
|
252
|
+
</p>
|
|
253
|
+
</TooltipContent>
|
|
254
|
+
</Tooltip>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Type Icon */}
|
|
258
|
+
<Tooltip>
|
|
259
|
+
<TooltipTrigger asChild>
|
|
260
|
+
<Icon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
|
|
261
|
+
</TooltipTrigger>
|
|
262
|
+
<TooltipContent>
|
|
263
|
+
<p className="text-xs">{node.type}</p>
|
|
264
|
+
</TooltipContent>
|
|
265
|
+
</Tooltip>
|
|
266
|
+
|
|
267
|
+
{/* Name */}
|
|
268
|
+
<span className={cn(
|
|
269
|
+
'flex-1 text-sm truncate ml-1.5',
|
|
270
|
+
isSpatialContainer(node.type)
|
|
271
|
+
? 'font-medium text-zinc-900 dark:text-zinc-100'
|
|
272
|
+
: 'text-zinc-700 dark:text-zinc-300',
|
|
273
|
+
nodeHidden && 'line-through decoration-zinc-400 dark:decoration-zinc-600'
|
|
274
|
+
)}>{node.name}</span>
|
|
275
|
+
|
|
276
|
+
{/* Storey Elevation */}
|
|
277
|
+
{node.storeyElevation !== undefined && (
|
|
278
|
+
<Tooltip>
|
|
279
|
+
<TooltipTrigger asChild>
|
|
280
|
+
<span className="text-[10px] font-mono bg-emerald-100 dark:bg-emerald-950 px-1.5 py-0.5 border border-emerald-200 dark:border-emerald-800 text-emerald-600 dark:text-emerald-400 rounded-none">
|
|
281
|
+
{node.storeyElevation >= 0 ? '+' : ''}{node.storeyElevation.toFixed(2)}m
|
|
282
|
+
</span>
|
|
283
|
+
</TooltipTrigger>
|
|
284
|
+
<TooltipContent>
|
|
285
|
+
<p className="text-xs">Elevation: {node.storeyElevation >= 0 ? '+' : ''}{node.storeyElevation.toFixed(2)}m</p>
|
|
286
|
+
</TooltipContent>
|
|
287
|
+
</Tooltip>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{/* Element Count */}
|
|
291
|
+
{node.elementCount !== undefined && (
|
|
292
|
+
<Tooltip>
|
|
293
|
+
<TooltipTrigger asChild>
|
|
294
|
+
<span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-950 px-1.5 py-0.5 border border-zinc-200 dark:border-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-none">
|
|
295
|
+
{node.elementCount}
|
|
296
|
+
</span>
|
|
297
|
+
</TooltipTrigger>
|
|
298
|
+
<TooltipContent>
|
|
299
|
+
<p className="text-xs">{node.elementCount} {node.elementCount === 1 ? 'element' : 'elements'}</p>
|
|
300
|
+
</TooltipContent>
|
|
301
|
+
</Tooltip>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface SectionHeaderProps {
|
|
309
|
+
icon: React.ElementType;
|
|
310
|
+
title: string;
|
|
311
|
+
count?: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function SectionHeader({ icon: IconComponent, title, count }: SectionHeaderProps) {
|
|
315
|
+
return (
|
|
316
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800">
|
|
317
|
+
<IconComponent className="h-3.5 w-3.5 text-zinc-500" />
|
|
318
|
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400">
|
|
319
|
+
{title}
|
|
320
|
+
</span>
|
|
321
|
+
{count !== undefined && (
|
|
322
|
+
<span className="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 ml-auto">
|
|
323
|
+
{count}
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|