@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,603 @@
|
|
|
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
|
+
* Lens panel — rule-based 3D filtering and coloring
|
|
7
|
+
*
|
|
8
|
+
* Shows saved lens presets and allows activating/deactivating them.
|
|
9
|
+
* Users can create, edit, and delete custom lenses with full rule editing.
|
|
10
|
+
* When a lens is active, a color legend displays the matched rules.
|
|
11
|
+
* Unmatched entities are ghosted (semi-transparent) for visual context.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { X, EyeOff, Palette, Check, Plus, Trash2, Pencil, Save, Download, Upload } from 'lucide-react';
|
|
16
|
+
import { Button } from '@/components/ui/button';
|
|
17
|
+
import { cn } from '@/lib/utils';
|
|
18
|
+
import { useViewerStore } from '@/store';
|
|
19
|
+
import { useLens } from '@/hooks/useLens';
|
|
20
|
+
import type { Lens, LensRule } from '@/store/slices/lensSlice';
|
|
21
|
+
import { COMMON_IFC_CLASSES, LENS_PALETTE } from '@/store/slices/lensSlice';
|
|
22
|
+
|
|
23
|
+
/** Format large counts compactly: 1234 → "1.2k" */
|
|
24
|
+
function formatCount(n: number): string {
|
|
25
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;
|
|
26
|
+
return String(n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface LensPanelProps {
|
|
30
|
+
onClose?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Rule display (read-only, clickable for isolation) ──────────────────────
|
|
34
|
+
|
|
35
|
+
const RuleRow = memo(function RuleRow({
|
|
36
|
+
rule,
|
|
37
|
+
count,
|
|
38
|
+
isIsolated,
|
|
39
|
+
onClick,
|
|
40
|
+
}: {
|
|
41
|
+
rule: LensRule;
|
|
42
|
+
count: number;
|
|
43
|
+
isIsolated?: boolean;
|
|
44
|
+
onClick?: () => void;
|
|
45
|
+
}) {
|
|
46
|
+
const isEmpty = count === 0;
|
|
47
|
+
const isClickable = !!onClick && !isEmpty;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
'group/row relative flex items-center gap-2 pl-3 pr-3 py-1.5 text-xs',
|
|
53
|
+
'border-l-2 transition-[border-color,background-color] duration-100',
|
|
54
|
+
!rule.enabled && 'opacity-40',
|
|
55
|
+
// Default: transparent left border
|
|
56
|
+
!isIsolated && !isEmpty && 'border-l-transparent',
|
|
57
|
+
// Hover: accent left border + subtle bg (only for clickable rows)
|
|
58
|
+
isClickable && 'cursor-pointer hover:border-l-primary/70 hover:bg-zinc-100/80 dark:hover:bg-zinc-700/40',
|
|
59
|
+
// Isolated: solid accent border + bg
|
|
60
|
+
isIsolated && 'border-l-primary bg-primary/8 dark:bg-primary/15',
|
|
61
|
+
// Empty: muted, non-interactive
|
|
62
|
+
isEmpty && 'border-l-transparent opacity-50 cursor-default',
|
|
63
|
+
)}
|
|
64
|
+
role={isClickable ? 'button' : undefined}
|
|
65
|
+
tabIndex={isClickable ? 0 : undefined}
|
|
66
|
+
onClick={(e) => { if (isClickable) { e.stopPropagation(); onClick(); } }}
|
|
67
|
+
onKeyDown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onClick(); } }}
|
|
68
|
+
title={isClickable ? 'Click to isolate / show only this class' : isEmpty ? 'No matching entities' : undefined}
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
className={cn(
|
|
72
|
+
'w-3 h-3 rounded-sm flex-shrink-0 ring-1 ring-black/10 dark:ring-white/20',
|
|
73
|
+
isEmpty && 'grayscale',
|
|
74
|
+
)}
|
|
75
|
+
style={{ backgroundColor: rule.color }}
|
|
76
|
+
/>
|
|
77
|
+
<span className={cn(
|
|
78
|
+
'flex-1 truncate font-medium',
|
|
79
|
+
isIsolated
|
|
80
|
+
? 'text-zinc-900 dark:text-zinc-50'
|
|
81
|
+
: isEmpty
|
|
82
|
+
? 'text-zinc-400 dark:text-zinc-600'
|
|
83
|
+
: 'text-zinc-900 dark:text-zinc-50',
|
|
84
|
+
)}>
|
|
85
|
+
{rule.name}
|
|
86
|
+
</span>
|
|
87
|
+
{isIsolated && (
|
|
88
|
+
<span className="text-[10px] uppercase tracking-wider font-bold text-primary">
|
|
89
|
+
isolated
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
<span className={cn(
|
|
93
|
+
'text-[10px] tabular-nums font-mono min-w-[2ch] text-right',
|
|
94
|
+
isEmpty
|
|
95
|
+
? 'text-zinc-300 dark:text-zinc-700'
|
|
96
|
+
: 'text-zinc-400 dark:text-zinc-500',
|
|
97
|
+
)}>
|
|
98
|
+
{isEmpty ? '—' : formatCount(count)}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ─── Rule editor (inline editing) ───────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function RuleEditor({
|
|
107
|
+
rule,
|
|
108
|
+
onChange,
|
|
109
|
+
onRemove,
|
|
110
|
+
}: {
|
|
111
|
+
rule: LensRule;
|
|
112
|
+
onChange: (patch: Partial<LensRule>) => void;
|
|
113
|
+
onRemove: () => void;
|
|
114
|
+
}) {
|
|
115
|
+
return (
|
|
116
|
+
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
|
117
|
+
<input
|
|
118
|
+
type="color"
|
|
119
|
+
value={rule.color}
|
|
120
|
+
onChange={(e) => onChange({ color: e.target.value })}
|
|
121
|
+
className="w-6 h-6 cursor-pointer border-0 p-0 bg-transparent flex-shrink-0 rounded"
|
|
122
|
+
/>
|
|
123
|
+
<select
|
|
124
|
+
value={rule.criteria.ifcType ?? ''}
|
|
125
|
+
onChange={(e) => {
|
|
126
|
+
const ifcType = e.target.value;
|
|
127
|
+
onChange({
|
|
128
|
+
criteria: { type: 'ifcType', ifcType },
|
|
129
|
+
name: ifcType ? ifcType.replace('Ifc', '') : rule.name,
|
|
130
|
+
});
|
|
131
|
+
}}
|
|
132
|
+
className="flex-1 min-w-0 text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm"
|
|
133
|
+
>
|
|
134
|
+
<option value="">Class...</option>
|
|
135
|
+
{COMMON_IFC_CLASSES.map(t => (
|
|
136
|
+
<option key={t} value={t}>{t.replace('Ifc', '')}</option>
|
|
137
|
+
))}
|
|
138
|
+
</select>
|
|
139
|
+
<select
|
|
140
|
+
value={rule.action}
|
|
141
|
+
onChange={(e) => onChange({ action: e.target.value as LensRule['action'] })}
|
|
142
|
+
className="text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm w-[72px]"
|
|
143
|
+
>
|
|
144
|
+
<option value="colorize">Color</option>
|
|
145
|
+
<option value="transparent">Transp</option>
|
|
146
|
+
<option value="hide">Hide</option>
|
|
147
|
+
</select>
|
|
148
|
+
<button
|
|
149
|
+
onClick={onRemove}
|
|
150
|
+
className="text-zinc-400 hover:text-red-500 dark:text-zinc-500 dark:hover:text-red-400 p-0.5 flex-shrink-0"
|
|
151
|
+
title="Remove rule"
|
|
152
|
+
>
|
|
153
|
+
<X className="h-3.5 w-3.5" />
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Lens editor (create/edit mode) ─────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function LensEditor({
|
|
162
|
+
initial,
|
|
163
|
+
onSave,
|
|
164
|
+
onCancel,
|
|
165
|
+
}: {
|
|
166
|
+
initial: Lens;
|
|
167
|
+
onSave: (lens: Lens) => void;
|
|
168
|
+
onCancel: () => void;
|
|
169
|
+
}) {
|
|
170
|
+
const [name, setName] = useState(initial.name);
|
|
171
|
+
const [rules, setRules] = useState<LensRule[]>(() =>
|
|
172
|
+
initial.rules.map(r => ({ ...r })),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const addRule = () => {
|
|
176
|
+
const colorIndex = rules.length % LENS_PALETTE.length;
|
|
177
|
+
setRules([...rules, {
|
|
178
|
+
id: `rule-${Date.now()}-${rules.length}`,
|
|
179
|
+
name: 'New Rule',
|
|
180
|
+
enabled: true,
|
|
181
|
+
criteria: { type: 'ifcType', ifcType: '' },
|
|
182
|
+
action: 'colorize',
|
|
183
|
+
color: LENS_PALETTE[colorIndex],
|
|
184
|
+
}]);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const updateRule = (index: number, patch: Partial<LensRule>) => {
|
|
188
|
+
setRules(rules.map((r, i) => i === index ? { ...r, ...patch } : r));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const removeRule = (index: number) => {
|
|
192
|
+
setRules(rules.filter((_, i) => i !== index));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const handleSave = () => {
|
|
196
|
+
const validRules = rules.filter(r => r.criteria.ifcType);
|
|
197
|
+
if (!name.trim() || validRules.length === 0) return;
|
|
198
|
+
onSave({ ...initial, name: name.trim(), rules: validRules });
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const canSave = name.trim().length > 0 && rules.some(r => r.criteria.ifcType);
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className="border-2 border-primary bg-white dark:bg-zinc-900 rounded-sm">
|
|
205
|
+
{/* Name input */}
|
|
206
|
+
<div className="px-3 pt-3 pb-2">
|
|
207
|
+
<input
|
|
208
|
+
type="text"
|
|
209
|
+
value={name}
|
|
210
|
+
onChange={(e) => setName(e.target.value)}
|
|
211
|
+
placeholder="Lens name..."
|
|
212
|
+
className="w-full px-2 py-1.5 text-xs font-bold uppercase tracking-wider bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm placeholder:normal-case placeholder:font-normal placeholder:text-zinc-400 dark:placeholder:text-zinc-500"
|
|
213
|
+
autoFocus
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Rules */}
|
|
218
|
+
<div className="border-t border-zinc-200 dark:border-zinc-700 py-1 bg-zinc-50/50 dark:bg-zinc-800/50">
|
|
219
|
+
{rules.map((rule, i) => (
|
|
220
|
+
<RuleEditor
|
|
221
|
+
key={rule.id}
|
|
222
|
+
rule={rule}
|
|
223
|
+
onChange={(patch) => updateRule(i, patch)}
|
|
224
|
+
onRemove={() => removeRule(i)}
|
|
225
|
+
/>
|
|
226
|
+
))}
|
|
227
|
+
|
|
228
|
+
<button
|
|
229
|
+
onClick={addRule}
|
|
230
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary hover:text-primary/80 w-full"
|
|
231
|
+
>
|
|
232
|
+
<Plus className="h-3.5 w-3.5" />
|
|
233
|
+
Add Rule
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Actions */}
|
|
238
|
+
<div className="flex gap-1.5 p-2 border-t border-zinc-200 dark:border-zinc-700">
|
|
239
|
+
<Button
|
|
240
|
+
variant="default"
|
|
241
|
+
size="sm"
|
|
242
|
+
className="flex-1 h-7 text-[10px] uppercase tracking-wider rounded-sm"
|
|
243
|
+
onClick={handleSave}
|
|
244
|
+
disabled={!canSave}
|
|
245
|
+
>
|
|
246
|
+
<Save className="h-3 w-3 mr-1" />
|
|
247
|
+
Save
|
|
248
|
+
</Button>
|
|
249
|
+
<Button
|
|
250
|
+
variant="ghost"
|
|
251
|
+
size="sm"
|
|
252
|
+
className="h-7 text-[10px] uppercase tracking-wider rounded-sm"
|
|
253
|
+
onClick={onCancel}
|
|
254
|
+
>
|
|
255
|
+
Cancel
|
|
256
|
+
</Button>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Lens card (read-only display) ──────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
function LensCard({
|
|
265
|
+
lens,
|
|
266
|
+
isActive,
|
|
267
|
+
onToggle,
|
|
268
|
+
onEdit,
|
|
269
|
+
onDelete,
|
|
270
|
+
isolatedRuleId,
|
|
271
|
+
onIsolateRule,
|
|
272
|
+
ruleCounts,
|
|
273
|
+
}: {
|
|
274
|
+
lens: Lens;
|
|
275
|
+
isActive: boolean;
|
|
276
|
+
onToggle: (id: string) => void;
|
|
277
|
+
onEdit?: (lens: Lens) => void;
|
|
278
|
+
onDelete?: (id: string) => void;
|
|
279
|
+
isolatedRuleId?: string | null;
|
|
280
|
+
onIsolateRule?: (ruleId: string) => void;
|
|
281
|
+
ruleCounts?: Map<string, number>;
|
|
282
|
+
}) {
|
|
283
|
+
return (
|
|
284
|
+
<div
|
|
285
|
+
className={cn(
|
|
286
|
+
'border-2 transition-colors cursor-pointer group rounded-sm',
|
|
287
|
+
isActive
|
|
288
|
+
? 'border-primary bg-white dark:bg-zinc-900'
|
|
289
|
+
: 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:border-zinc-400 dark:hover:border-zinc-500',
|
|
290
|
+
)}
|
|
291
|
+
onClick={() => onToggle(lens.id)}
|
|
292
|
+
>
|
|
293
|
+
{/* Header */}
|
|
294
|
+
<div className="flex items-center justify-between px-3 py-2">
|
|
295
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
296
|
+
{isActive ? (
|
|
297
|
+
<Check className="h-3.5 w-3.5 text-primary flex-shrink-0" />
|
|
298
|
+
) : (
|
|
299
|
+
<Palette className="h-3.5 w-3.5 text-zinc-400 dark:text-zinc-500 flex-shrink-0" />
|
|
300
|
+
)}
|
|
301
|
+
<span className="text-xs font-bold uppercase tracking-wider text-zinc-900 dark:text-zinc-100 truncate">
|
|
302
|
+
{lens.name}
|
|
303
|
+
</span>
|
|
304
|
+
</div>
|
|
305
|
+
<div className="flex items-center gap-1">
|
|
306
|
+
{/* Edit for all lenses, delete for custom only */}
|
|
307
|
+
{onEdit && (
|
|
308
|
+
<button
|
|
309
|
+
onClick={(e) => { e.stopPropagation(); onEdit(lens); }}
|
|
310
|
+
className="opacity-0 group-hover:opacity-100 text-zinc-400 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200 p-0.5"
|
|
311
|
+
title="Edit lens"
|
|
312
|
+
>
|
|
313
|
+
<Pencil className="h-3 w-3" />
|
|
314
|
+
</button>
|
|
315
|
+
)}
|
|
316
|
+
{!lens.builtin && onDelete && (
|
|
317
|
+
<button
|
|
318
|
+
onClick={(e) => { e.stopPropagation(); onDelete(lens.id); }}
|
|
319
|
+
className="opacity-0 group-hover:opacity-100 text-zinc-400 hover:text-red-500 dark:text-zinc-500 dark:hover:text-red-400 p-0.5"
|
|
320
|
+
title="Delete lens"
|
|
321
|
+
>
|
|
322
|
+
<Trash2 className="h-3 w-3" />
|
|
323
|
+
</button>
|
|
324
|
+
)}
|
|
325
|
+
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-mono ml-1">
|
|
326
|
+
{lens.rules.filter(r => r.enabled).length} rules
|
|
327
|
+
</span>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{/* Color legend (shown when active) — click rules to isolate */}
|
|
332
|
+
{isActive && (
|
|
333
|
+
<div className="border-t border-zinc-200 dark:border-zinc-700 py-0.5 bg-zinc-50 dark:bg-zinc-800/60">
|
|
334
|
+
{lens.rules.map(rule => {
|
|
335
|
+
const count = ruleCounts?.get(rule.id) ?? 0;
|
|
336
|
+
return (
|
|
337
|
+
<RuleRow
|
|
338
|
+
key={rule.id}
|
|
339
|
+
rule={rule}
|
|
340
|
+
count={count}
|
|
341
|
+
isIsolated={isolatedRuleId === rule.id}
|
|
342
|
+
onClick={onIsolateRule ? () => onIsolateRule(rule.id) : undefined}
|
|
343
|
+
/>
|
|
344
|
+
);
|
|
345
|
+
})}
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── Main panel ─────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
export function LensPanel({ onClose }: LensPanelProps) {
|
|
355
|
+
const { activeLensId, savedLenses } = useLens();
|
|
356
|
+
const setActiveLens = useViewerStore((s) => s.setActiveLens);
|
|
357
|
+
const createLens = useViewerStore((s) => s.createLens);
|
|
358
|
+
const updateLens = useViewerStore((s) => s.updateLens);
|
|
359
|
+
const deleteLens = useViewerStore((s) => s.deleteLens);
|
|
360
|
+
const importLenses = useViewerStore((s) => s.importLenses);
|
|
361
|
+
const exportLenses = useViewerStore((s) => s.exportLenses);
|
|
362
|
+
const hideEntities = useViewerStore((s) => s.hideEntities);
|
|
363
|
+
const showAll = useViewerStore((s) => s.showAll);
|
|
364
|
+
const isolateEntities = useViewerStore((s) => s.isolateEntities);
|
|
365
|
+
const clearIsolation = useViewerStore((s) => s.clearIsolation);
|
|
366
|
+
// For footer stats — cheap primitive subscriptions
|
|
367
|
+
const lensColorMapSize = useViewerStore((s) => s.lensColorMap.size);
|
|
368
|
+
const lensHiddenIdsSize = useViewerStore((s) => s.lensHiddenIds.size);
|
|
369
|
+
const lensRuleCounts = useViewerStore((s) => s.lensRuleCounts);
|
|
370
|
+
|
|
371
|
+
// Editor state: null = not editing, Lens object = editing/creating
|
|
372
|
+
const [editingLens, setEditingLens] = useState<Lens | null>(null);
|
|
373
|
+
const [isolatedRuleId, setIsolatedRuleId] = useState<string | null>(null);
|
|
374
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
375
|
+
|
|
376
|
+
const handleToggle = useCallback((id: string) => {
|
|
377
|
+
setIsolatedRuleId(null);
|
|
378
|
+
if (activeLensId === id) {
|
|
379
|
+
setActiveLens(null);
|
|
380
|
+
showAll();
|
|
381
|
+
} else {
|
|
382
|
+
setActiveLens(id);
|
|
383
|
+
}
|
|
384
|
+
}, [activeLensId, setActiveLens, showAll]);
|
|
385
|
+
|
|
386
|
+
/** Click a rule row in the active lens to isolate matching entities */
|
|
387
|
+
const handleIsolateRule = useCallback((ruleId: string) => {
|
|
388
|
+
const lens = savedLenses.find(l => l.id === activeLensId);
|
|
389
|
+
if (!lens) return;
|
|
390
|
+
|
|
391
|
+
// Toggle off if clicking the already-isolated rule
|
|
392
|
+
if (isolatedRuleId === ruleId) {
|
|
393
|
+
setIsolatedRuleId(null);
|
|
394
|
+
clearIsolation();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const rule = lens.rules.find(r => r.id === ruleId);
|
|
399
|
+
if (!rule || !rule.enabled) return;
|
|
400
|
+
|
|
401
|
+
// Look up entities matched by this specific rule (not by color)
|
|
402
|
+
const matchingIds = useViewerStore.getState().lensRuleEntityIds.get(ruleId);
|
|
403
|
+
if (!matchingIds || matchingIds.length === 0) return;
|
|
404
|
+
|
|
405
|
+
if (matchingIds.length > 0) {
|
|
406
|
+
setIsolatedRuleId(ruleId);
|
|
407
|
+
isolateEntities(matchingIds);
|
|
408
|
+
}
|
|
409
|
+
}, [activeLensId, savedLenses, isolatedRuleId, isolateEntities, clearIsolation]);
|
|
410
|
+
|
|
411
|
+
const handleNewLens = useCallback(() => {
|
|
412
|
+
setEditingLens({
|
|
413
|
+
id: `lens-${Date.now()}`,
|
|
414
|
+
name: '',
|
|
415
|
+
rules: [],
|
|
416
|
+
});
|
|
417
|
+
}, []);
|
|
418
|
+
|
|
419
|
+
const handleEditLens = useCallback((lens: Lens) => {
|
|
420
|
+
setEditingLens({ ...lens, rules: lens.rules.map(r => ({ ...r })) });
|
|
421
|
+
}, []);
|
|
422
|
+
|
|
423
|
+
const handleSaveLens = useCallback((lens: Lens) => {
|
|
424
|
+
// Check if this is an existing lens or new
|
|
425
|
+
const exists = savedLenses.some(l => l.id === lens.id);
|
|
426
|
+
if (exists) {
|
|
427
|
+
updateLens(lens.id, { name: lens.name, rules: lens.rules });
|
|
428
|
+
} else {
|
|
429
|
+
createLens(lens);
|
|
430
|
+
}
|
|
431
|
+
setEditingLens(null);
|
|
432
|
+
}, [savedLenses, createLens, updateLens]);
|
|
433
|
+
|
|
434
|
+
const handleDeleteLens = useCallback((id: string) => {
|
|
435
|
+
if (activeLensId === id) {
|
|
436
|
+
setActiveLens(null);
|
|
437
|
+
showAll();
|
|
438
|
+
}
|
|
439
|
+
deleteLens(id);
|
|
440
|
+
}, [activeLensId, setActiveLens, showAll, deleteLens]);
|
|
441
|
+
|
|
442
|
+
// Apply hidden entities when lens hidden IDs change (proper effect, not render side-effect)
|
|
443
|
+
useEffect(() => {
|
|
444
|
+
if (lensHiddenIdsSize > 0 && activeLensId) {
|
|
445
|
+
const ids = useViewerStore.getState().lensHiddenIds;
|
|
446
|
+
hideEntities(Array.from(ids));
|
|
447
|
+
}
|
|
448
|
+
}, [activeLensId, lensHiddenIdsSize, hideEntities]);
|
|
449
|
+
|
|
450
|
+
const handleExport = useCallback(() => {
|
|
451
|
+
const data = exportLenses();
|
|
452
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
453
|
+
const url = URL.createObjectURL(blob);
|
|
454
|
+
const a = document.createElement('a');
|
|
455
|
+
a.href = url;
|
|
456
|
+
a.download = 'lenses.json';
|
|
457
|
+
a.click();
|
|
458
|
+
URL.revokeObjectURL(url);
|
|
459
|
+
}, [exportLenses]);
|
|
460
|
+
|
|
461
|
+
const handleImport = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
462
|
+
const file = e.target.files?.[0];
|
|
463
|
+
if (!file) return;
|
|
464
|
+
const reader = new FileReader();
|
|
465
|
+
reader.onload = () => {
|
|
466
|
+
try {
|
|
467
|
+
const parsed = JSON.parse(reader.result as string);
|
|
468
|
+
const arr: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
|
|
469
|
+
// Validate each entry has required Lens structure
|
|
470
|
+
const valid = arr.filter((item): item is Lens => {
|
|
471
|
+
if (item === null || typeof item !== 'object') return false;
|
|
472
|
+
const obj = item as Record<string, unknown>;
|
|
473
|
+
return typeof obj.id === 'string' && obj.id.length > 0
|
|
474
|
+
&& typeof obj.name === 'string' && obj.name.length > 0
|
|
475
|
+
&& Array.isArray(obj.rules);
|
|
476
|
+
});
|
|
477
|
+
if (valid.length > 0) {
|
|
478
|
+
importLenses(valid);
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
// invalid JSON — silently ignore
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
reader.readAsText(file);
|
|
485
|
+
// reset so same file can be re-imported
|
|
486
|
+
e.target.value = '';
|
|
487
|
+
}, [importLenses]);
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<div className="h-full flex flex-col bg-white dark:bg-zinc-950">
|
|
491
|
+
{/* Header */}
|
|
492
|
+
<div className="flex items-center justify-between p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900">
|
|
493
|
+
<div className="flex items-center gap-2">
|
|
494
|
+
<Palette className="h-4 w-4 text-primary" />
|
|
495
|
+
<h2 className="font-bold uppercase tracking-wider text-xs text-zinc-900 dark:text-zinc-100">
|
|
496
|
+
Lens
|
|
497
|
+
</h2>
|
|
498
|
+
</div>
|
|
499
|
+
<div className="flex items-center gap-1">
|
|
500
|
+
<Button
|
|
501
|
+
variant="ghost"
|
|
502
|
+
size="sm"
|
|
503
|
+
className="h-7 w-7 p-0 rounded-sm"
|
|
504
|
+
onClick={handleExport}
|
|
505
|
+
title="Export lenses as JSON"
|
|
506
|
+
>
|
|
507
|
+
<Download className="h-3.5 w-3.5" />
|
|
508
|
+
</Button>
|
|
509
|
+
<Button
|
|
510
|
+
variant="ghost"
|
|
511
|
+
size="sm"
|
|
512
|
+
className="h-7 w-7 p-0 rounded-sm"
|
|
513
|
+
onClick={() => fileInputRef.current?.click()}
|
|
514
|
+
title="Import lenses from JSON"
|
|
515
|
+
>
|
|
516
|
+
<Upload className="h-3.5 w-3.5" />
|
|
517
|
+
</Button>
|
|
518
|
+
<input
|
|
519
|
+
ref={fileInputRef}
|
|
520
|
+
type="file"
|
|
521
|
+
accept=".json"
|
|
522
|
+
className="hidden"
|
|
523
|
+
onChange={handleImport}
|
|
524
|
+
/>
|
|
525
|
+
{activeLensId && (
|
|
526
|
+
<Button
|
|
527
|
+
variant="ghost"
|
|
528
|
+
size="sm"
|
|
529
|
+
className="h-7 text-[10px] uppercase tracking-wider rounded-sm"
|
|
530
|
+
onClick={() => { setActiveLens(null); showAll(); }}
|
|
531
|
+
>
|
|
532
|
+
<EyeOff className="h-3 w-3 mr-1" />
|
|
533
|
+
Clear
|
|
534
|
+
</Button>
|
|
535
|
+
)}
|
|
536
|
+
{onClose && (
|
|
537
|
+
<Button
|
|
538
|
+
variant="ghost"
|
|
539
|
+
size="sm"
|
|
540
|
+
className="h-7 w-7 p-0 rounded-sm"
|
|
541
|
+
onClick={onClose}
|
|
542
|
+
>
|
|
543
|
+
<X className="h-4 w-4" />
|
|
544
|
+
</Button>
|
|
545
|
+
)}
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
{/* Lens list + editor */}
|
|
550
|
+
<div className="flex-1 overflow-auto p-3 space-y-2">
|
|
551
|
+
{savedLenses.map(lens => (
|
|
552
|
+
editingLens?.id === lens.id ? (
|
|
553
|
+
<LensEditor
|
|
554
|
+
key={lens.id}
|
|
555
|
+
initial={editingLens}
|
|
556
|
+
onSave={handleSaveLens}
|
|
557
|
+
onCancel={() => setEditingLens(null)}
|
|
558
|
+
/>
|
|
559
|
+
) : (
|
|
560
|
+
<LensCard
|
|
561
|
+
key={lens.id}
|
|
562
|
+
lens={lens}
|
|
563
|
+
isActive={activeLensId === lens.id}
|
|
564
|
+
onToggle={handleToggle}
|
|
565
|
+
onEdit={handleEditLens}
|
|
566
|
+
onDelete={handleDeleteLens}
|
|
567
|
+
isolatedRuleId={activeLensId === lens.id ? isolatedRuleId : null}
|
|
568
|
+
onIsolateRule={activeLensId === lens.id ? handleIsolateRule : undefined}
|
|
569
|
+
ruleCounts={activeLensId === lens.id ? lensRuleCounts : undefined}
|
|
570
|
+
/>
|
|
571
|
+
)
|
|
572
|
+
))}
|
|
573
|
+
|
|
574
|
+
{/* New lens editor (when creating) */}
|
|
575
|
+
{editingLens && !savedLenses.some(l => l.id === editingLens.id) && (
|
|
576
|
+
<LensEditor
|
|
577
|
+
initial={editingLens}
|
|
578
|
+
onSave={handleSaveLens}
|
|
579
|
+
onCancel={() => setEditingLens(null)}
|
|
580
|
+
/>
|
|
581
|
+
)}
|
|
582
|
+
|
|
583
|
+
{/* New lens button */}
|
|
584
|
+
{!editingLens && (
|
|
585
|
+
<button
|
|
586
|
+
onClick={handleNewLens}
|
|
587
|
+
className="w-full border-2 border-dashed border-zinc-300 dark:border-zinc-600 hover:border-primary dark:hover:border-primary py-2.5 flex items-center justify-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-primary transition-colors rounded-sm"
|
|
588
|
+
>
|
|
589
|
+
<Plus className="h-3.5 w-3.5" />
|
|
590
|
+
New Lens
|
|
591
|
+
</button>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
{/* Status footer */}
|
|
596
|
+
<div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 text-[10px] uppercase tracking-wide text-zinc-600 dark:text-zinc-400 text-center bg-zinc-50 dark:bg-zinc-900 font-mono">
|
|
597
|
+
{activeLensId
|
|
598
|
+
? `Active · ${lensColorMapSize} colored · ${lensHiddenIdsSize > 0 ? `${lensHiddenIdsSize} hidden` : 'ghosted'}`
|
|
599
|
+
: 'Click a lens to activate'}
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
);
|
|
603
|
+
}
|