@ifc-lite/viewer 1.7.0 → 1.8.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 +35 -0
- package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CwcRxist.js} +1 -1
- package/dist/assets/index-7WoQ-qVC.css +1 -0
- package/dist/assets/{index-dgdgiQ9p.js → index-BSANf7-H.js} +20926 -17587
- package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-5LbrYh3R.js} +1 -1
- package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-CgpLtj1h.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -18
- package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +3 -1
- package/src/components/viewer/LensPanel.tsx +848 -85
- package/src/components/viewer/MainToolbar.tsx +114 -81
- package/src/components/viewer/Section2DPanel.tsx +269 -29
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/Viewport.tsx +57 -23
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
- package/src/components/viewer/hierarchy/types.ts +1 -1
- package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/useGeometryStreaming.ts +12 -4
- package/src/hooks/ids/idsExportService.ts +1 -1
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useDrawingExport.ts +83 -1
- package/src/hooks/useKeyboardShortcuts.ts +113 -14
- package/src/hooks/useLens.ts +39 -55
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/index.css +7 -1
- package/src/lib/lens/adapter.ts +127 -1
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/store/index.ts +14 -1
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +46 -4
- package/src/store/slices/pinboardSlice.ts +171 -38
- package/src/store.ts +3 -0
- package/dist/assets/index-yTqs8kgX.css +0 -1
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
* Inline text editor overlay for text annotations on the 2D drawing.
|
|
7
|
+
* Positioned absolutely over the canvas at the annotation's screen coordinates.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
|
+
import type { TextAnnotation2D } from '@/store/slices/drawing2DSlice';
|
|
12
|
+
|
|
13
|
+
interface TextAnnotationEditorProps {
|
|
14
|
+
/** The text annotation being edited */
|
|
15
|
+
annotation: TextAnnotation2D;
|
|
16
|
+
/** Screen position (top-left of the editor) */
|
|
17
|
+
screenX: number;
|
|
18
|
+
screenY: number;
|
|
19
|
+
/** Called with the new text when user confirms (Enter) */
|
|
20
|
+
onConfirm: (id: string, text: string) => void;
|
|
21
|
+
/** Called when user cancels (Escape) or submits empty text */
|
|
22
|
+
onCancel: (id: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TextAnnotationEditor({
|
|
26
|
+
annotation,
|
|
27
|
+
screenX,
|
|
28
|
+
screenY,
|
|
29
|
+
onConfirm,
|
|
30
|
+
onCancel,
|
|
31
|
+
}: TextAnnotationEditorProps): React.ReactElement {
|
|
32
|
+
const [text, setText] = useState(annotation.text);
|
|
33
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
34
|
+
// Guard against blur firing during the initial click that created this editor.
|
|
35
|
+
// Without this, the mouseup from the placement click can steal focus from the
|
|
36
|
+
// textarea before the user has a chance to type, causing an immediate cancel.
|
|
37
|
+
const readyRef = useRef(false);
|
|
38
|
+
|
|
39
|
+
// Auto-focus on mount, but defer slightly so the originating mouseup
|
|
40
|
+
// from the placement click doesn't immediately steal focus / trigger blur.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const timer = requestAnimationFrame(() => {
|
|
43
|
+
textareaRef.current?.focus();
|
|
44
|
+
// Mark ready after focus is established so blur handler is enabled
|
|
45
|
+
readyRef.current = true;
|
|
46
|
+
});
|
|
47
|
+
return () => cancelAnimationFrame(timer);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
51
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
const trimmed = text.trim();
|
|
54
|
+
if (trimmed) {
|
|
55
|
+
onConfirm(annotation.id, trimmed);
|
|
56
|
+
} else {
|
|
57
|
+
onCancel(annotation.id);
|
|
58
|
+
}
|
|
59
|
+
} else if (e.key === 'Escape') {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
onCancel(annotation.id);
|
|
62
|
+
}
|
|
63
|
+
// Stop propagation so the canvas doesn't receive these events
|
|
64
|
+
e.stopPropagation();
|
|
65
|
+
}, [text, annotation.id, onConfirm, onCancel]);
|
|
66
|
+
|
|
67
|
+
const handleBlur = useCallback(() => {
|
|
68
|
+
// Ignore blur events that fire before the editor is fully ready
|
|
69
|
+
// (e.g. from the originating click's mouseup stealing focus)
|
|
70
|
+
if (!readyRef.current) return;
|
|
71
|
+
|
|
72
|
+
const trimmed = text.trim();
|
|
73
|
+
if (trimmed) {
|
|
74
|
+
onConfirm(annotation.id, trimmed);
|
|
75
|
+
} else {
|
|
76
|
+
onCancel(annotation.id);
|
|
77
|
+
}
|
|
78
|
+
}, [text, annotation.id, onConfirm, onCancel]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
className="absolute z-20 pointer-events-auto"
|
|
83
|
+
style={{
|
|
84
|
+
left: screenX,
|
|
85
|
+
top: screenY,
|
|
86
|
+
}}
|
|
87
|
+
// Prevent mousedown from propagating to canvas (which would place another annotation)
|
|
88
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
89
|
+
onClick={(e) => e.stopPropagation()}
|
|
90
|
+
>
|
|
91
|
+
<textarea
|
|
92
|
+
ref={textareaRef}
|
|
93
|
+
value={text}
|
|
94
|
+
onChange={(e) => setText(e.target.value)}
|
|
95
|
+
onKeyDown={handleKeyDown}
|
|
96
|
+
onBlur={handleBlur}
|
|
97
|
+
placeholder="Type annotation text..."
|
|
98
|
+
className="min-w-[120px] max-w-[300px] min-h-[32px] px-2 py-1 text-sm border-2 border-blue-500 rounded resize shadow-lg outline-none"
|
|
99
|
+
rows={2}
|
|
100
|
+
style={{
|
|
101
|
+
fontSize: annotation.fontSize,
|
|
102
|
+
backgroundColor: '#ffffff',
|
|
103
|
+
color: '#000000',
|
|
104
|
+
caretColor: '#000000',
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
<div className="text-[10px] text-muted-foreground mt-0.5 bg-white/80 px-1 rounded">
|
|
108
|
+
Enter to confirm · Shift+Enter for newline · Esc to cancel
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
|
10
10
|
import { Renderer } from '@ifc-lite/renderer';
|
|
11
11
|
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
12
|
-
import { useViewerStore, type MeasurePoint, type SnapVisualization } from '@/store';
|
|
12
|
+
import { useViewerStore, resolveEntityRef, type MeasurePoint, type SnapVisualization } from '@/store';
|
|
13
13
|
import {
|
|
14
14
|
useSelectionState,
|
|
15
15
|
useVisibilityState,
|
|
@@ -53,8 +53,8 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
53
53
|
// Selection state
|
|
54
54
|
const { selectedEntityId, selectedEntityIds, setSelectedEntityId, setSelectedEntity, toggleSelection, models } = useSelectionState();
|
|
55
55
|
const selectedEntity = useViewerStore((s) => s.selectedEntity);
|
|
56
|
-
|
|
57
|
-
const
|
|
56
|
+
const addEntityToSelection = useViewerStore((s) => s.addEntityToSelection);
|
|
57
|
+
const toggleEntitySelection = useViewerStore((s) => s.toggleEntitySelection);
|
|
58
58
|
|
|
59
59
|
// Sync selectedEntityId with model-aware selectedEntity for PropertiesPanel
|
|
60
60
|
useModelSelection();
|
|
@@ -76,9 +76,14 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
76
76
|
|
|
77
77
|
// Helper to handle pick result and set selection properly
|
|
78
78
|
// IMPORTANT: pickResult.expressId is now a globalId (transformed at load time)
|
|
79
|
-
//
|
|
80
|
-
// This is more reliable than the singleton registry which can have bundling issues
|
|
79
|
+
// resolveEntityRef is the single source of truth for globalId → EntityRef
|
|
81
80
|
const handlePickForSelection = useCallback((pickResult: import('@ifc-lite/renderer').PickResult | null) => {
|
|
81
|
+
// Normal click clears multi-select set (fresh single-selection)
|
|
82
|
+
const currentState = useViewerStore.getState();
|
|
83
|
+
if (currentState.selectedEntitiesSet.size > 0) {
|
|
84
|
+
useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
|
|
85
|
+
}
|
|
86
|
+
|
|
82
87
|
if (!pickResult) {
|
|
83
88
|
setSelectedEntityId(null);
|
|
84
89
|
return;
|
|
@@ -89,29 +94,58 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
89
94
|
// Set globalId for renderer (highlighting uses globalIds directly)
|
|
90
95
|
setSelectedEntityId(globalId);
|
|
91
96
|
|
|
92
|
-
// Resolve globalId
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (resolved) {
|
|
96
|
-
// Set the EntityRef with ORIGINAL expressId (for property lookup in IfcDataStore)
|
|
97
|
-
setSelectedEntity({ modelId: resolved.modelId, expressId: resolved.expressId });
|
|
98
|
-
} else {
|
|
99
|
-
// Fallback for single-model mode (offset = 0, globalId = expressId)
|
|
100
|
-
// Try to find model from the old modelIndex if available
|
|
101
|
-
if (pickResult.modelIndex !== undefined && modelIndexToId) {
|
|
102
|
-
const modelId = modelIndexToId.get(pickResult.modelIndex);
|
|
103
|
-
if (modelId) {
|
|
104
|
-
setSelectedEntity({ modelId, expressId: globalId });
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}, [setSelectedEntityId, setSelectedEntity, resolveGlobalIdFromModels, modelIndexToId]);
|
|
97
|
+
// Resolve globalId → EntityRef for property panel (single source of truth, never null)
|
|
98
|
+
setSelectedEntity(resolveEntityRef(globalId));
|
|
99
|
+
}, [setSelectedEntityId, setSelectedEntity]);
|
|
109
100
|
|
|
110
101
|
// Ref to always access latest handlePickForSelection from event handlers
|
|
111
102
|
// (useMouseControls/useTouchControls capture this at effect setup time)
|
|
112
103
|
const handlePickForSelectionRef = useRef(handlePickForSelection);
|
|
113
104
|
useEffect(() => { handlePickForSelectionRef.current = handlePickForSelection; }, [handlePickForSelection]);
|
|
114
105
|
|
|
106
|
+
// Multi-select handler: Ctrl+Click adds/removes from multi-selection
|
|
107
|
+
// Properly populates both selectedEntitiesSet (multi-model) and selectedEntityIds (legacy)
|
|
108
|
+
const handleMultiSelect = useCallback((globalId: number) => {
|
|
109
|
+
// Resolve globalId → EntityRef (single source of truth, never null)
|
|
110
|
+
const entityRef = resolveEntityRef(globalId);
|
|
111
|
+
|
|
112
|
+
// If this is the first Ctrl+click and there's already a single-selected entity,
|
|
113
|
+
// add it to the multi-select set first (so it's not lost)
|
|
114
|
+
const state = useViewerStore.getState();
|
|
115
|
+
if (state.selectedEntitiesSet.size === 0 && state.selectedEntity) {
|
|
116
|
+
addEntityToSelection(state.selectedEntity);
|
|
117
|
+
// Also seed legacy selectedEntityIds with previous entity's globalId
|
|
118
|
+
// so the renderer highlights both the old and new entity
|
|
119
|
+
if (state.selectedEntityId !== null) {
|
|
120
|
+
toggleSelection(state.selectedEntityId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Toggle the clicked entity in multi-select
|
|
125
|
+
toggleEntitySelection(entityRef);
|
|
126
|
+
|
|
127
|
+
// Also sync legacy selectedEntityIds and selectedEntityId
|
|
128
|
+
toggleSelection(globalId);
|
|
129
|
+
|
|
130
|
+
// Read post-toggle state to keep renderer highlighting in sync:
|
|
131
|
+
// If the entity was toggled OFF, don't force-highlight it.
|
|
132
|
+
const updated = useViewerStore.getState();
|
|
133
|
+
if (updated.selectedEntityIds.has(globalId)) {
|
|
134
|
+
// Entity was toggled ON — highlight it
|
|
135
|
+
setSelectedEntityId(globalId);
|
|
136
|
+
} else if (updated.selectedEntityIds.size > 0) {
|
|
137
|
+
// Entity was toggled OFF but others remain — highlight the last remaining
|
|
138
|
+
const remaining = Array.from(updated.selectedEntityIds);
|
|
139
|
+
setSelectedEntityId(remaining[remaining.length - 1]);
|
|
140
|
+
} else {
|
|
141
|
+
// Nothing left selected
|
|
142
|
+
setSelectedEntityId(null);
|
|
143
|
+
}
|
|
144
|
+
}, [addEntityToSelection, toggleEntitySelection, toggleSelection, setSelectedEntityId]);
|
|
145
|
+
|
|
146
|
+
const handleMultiSelectRef = useRef(handleMultiSelect);
|
|
147
|
+
useEffect(() => { handleMultiSelectRef.current = handleMultiSelect; }, [handleMultiSelect]);
|
|
148
|
+
|
|
115
149
|
// Visibility state - use computedIsolatedIds from parent (includes storey selection)
|
|
116
150
|
// Fall back to store isolation if computedIsolatedIds is not provided
|
|
117
151
|
const { hiddenEntities, isolatedEntities: storeIsolatedEntities } = useVisibilityState();
|
|
@@ -668,7 +702,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
668
702
|
updateConstraintActiveAxis,
|
|
669
703
|
updateMeasurementScreenCoords,
|
|
670
704
|
updateCameraRotationRealtime,
|
|
671
|
-
toggleSelection,
|
|
705
|
+
toggleSelection: (entityId: number) => handleMultiSelectRef.current(entityId),
|
|
672
706
|
calculateScale,
|
|
673
707
|
getPickOptions,
|
|
674
708
|
hasPendingMeasurements,
|
|
@@ -317,6 +317,7 @@ export function ViewportContainer() {
|
|
|
317
317
|
return (
|
|
318
318
|
<div
|
|
319
319
|
className="relative h-full w-full bg-white dark:bg-black text-zinc-900 dark:text-zinc-50 overflow-hidden"
|
|
320
|
+
data-viewport
|
|
320
321
|
onDragOver={handleDragOver}
|
|
321
322
|
onDragLeave={handleDragLeave}
|
|
322
323
|
onDrop={handleDrop}
|
|
@@ -556,6 +557,7 @@ export function ViewportContainer() {
|
|
|
556
557
|
return (
|
|
557
558
|
<div
|
|
558
559
|
className="relative h-full w-full bg-zinc-50 dark:bg-black overflow-hidden"
|
|
560
|
+
data-viewport
|
|
559
561
|
onDragOver={handleDragOver}
|
|
560
562
|
onDragLeave={handleDragLeave}
|
|
561
563
|
onDrop={handleDrop}
|
|
@@ -267,7 +267,7 @@ export function HierarchyNode({
|
|
|
267
267
|
{/* Name */}
|
|
268
268
|
<span className={cn(
|
|
269
269
|
'flex-1 text-sm truncate ml-1.5',
|
|
270
|
-
isSpatialContainer(node.type)
|
|
270
|
+
isSpatialContainer(node.type) || node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' || node.type === 'type-group'
|
|
271
271
|
? 'font-medium text-zinc-900 dark:text-zinc-100'
|
|
272
272
|
: 'text-zinc-700 dark:text-zinc-300',
|
|
273
273
|
nodeHidden && 'line-through decoration-zinc-400 dark:decoration-zinc-600'
|
|
@@ -10,7 +10,7 @@ export type NodeType =
|
|
|
10
10
|
| 'IfcSite' // Site node
|
|
11
11
|
| 'IfcBuilding' // Building node
|
|
12
12
|
| 'IfcBuildingStorey' // Storey node
|
|
13
|
-
| 'type-group' // IFC
|
|
13
|
+
| 'type-group' // IFC class grouping header (e.g., "IfcWall (47)")
|
|
14
14
|
| 'element'; // Individual element
|
|
15
15
|
|
|
16
16
|
export interface TreeNode {
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
14
14
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
15
|
-
import { ArrowUp, ArrowDown, Search } from 'lucide-react';
|
|
15
|
+
import { ArrowUp, ArrowDown, Search, Palette } from 'lucide-react';
|
|
16
16
|
import { Input } from '@/components/ui/input';
|
|
17
17
|
import { useViewerStore } from '@/store';
|
|
18
|
-
import type { ListResult, ListRow, CellValue } from '@ifc-lite/lists';
|
|
18
|
+
import type { ListResult, ListRow, CellValue, ColumnDefinition } from '@ifc-lite/lists';
|
|
19
19
|
import { cn } from '@/lib/utils';
|
|
20
|
+
import { columnToAutoColor } from '@/lib/lists/columnToAutoColor';
|
|
21
|
+
import { AUTO_COLOR_FROM_LIST_ID } from '@/store/slices/lensSlice';
|
|
20
22
|
|
|
21
23
|
interface ListResultsTableProps {
|
|
22
24
|
result: ListResult;
|
|
@@ -31,6 +33,9 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
31
33
|
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
32
34
|
const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
|
|
33
35
|
const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
|
|
36
|
+
const activateAutoColorFromColumn = useViewerStore((s) => s.activateAutoColorFromColumn);
|
|
37
|
+
const activeLensId = useViewerStore((s) => s.activeLensId);
|
|
38
|
+
const [colorByColIdx, setColorByColIdx] = useState<number | null>(null);
|
|
34
39
|
|
|
35
40
|
// Filter rows by search query
|
|
36
41
|
const filteredRows = useMemo(() => {
|
|
@@ -62,6 +67,13 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
62
67
|
}
|
|
63
68
|
}, [sortCol]);
|
|
64
69
|
|
|
70
|
+
const handleColorByColumn = useCallback((col: ColumnDefinition, colIdx: number) => {
|
|
71
|
+
const spec = columnToAutoColor(col);
|
|
72
|
+
const label = col.label ?? col.propertyName;
|
|
73
|
+
activateAutoColorFromColumn(spec, label);
|
|
74
|
+
setColorByColIdx(colIdx);
|
|
75
|
+
}, [activateAutoColorFromColumn]);
|
|
76
|
+
|
|
65
77
|
const handleRowClick = useCallback((row: ListRow) => {
|
|
66
78
|
setSelectedEntity({ modelId: row.modelId, expressId: row.entityId });
|
|
67
79
|
// For single-model, selectedEntityId is the expressId
|
|
@@ -108,23 +120,45 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
108
120
|
<div style={{ minWidth: totalWidth }}>
|
|
109
121
|
{/* Header */}
|
|
110
122
|
<div className="flex sticky top-0 bg-muted/80 backdrop-blur-sm border-b z-10">
|
|
111
|
-
{result.columns.map((col, colIdx) =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
{result.columns.map((col, colIdx) => {
|
|
124
|
+
const isColoredCol = activeLensId === AUTO_COLOR_FROM_LIST_ID && colorByColIdx === colIdx;
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
key={col.id}
|
|
128
|
+
className={cn(
|
|
129
|
+
'flex items-center gap-0.5 px-2 py-1.5 text-xs font-medium text-muted-foreground border-r border-border/50 shrink-0 group/col',
|
|
130
|
+
isColoredCol && 'bg-primary/10',
|
|
131
|
+
)}
|
|
132
|
+
style={{ width: columnWidths[colIdx] }}
|
|
133
|
+
>
|
|
134
|
+
<button
|
|
135
|
+
className="flex items-center gap-1 flex-1 min-w-0 hover:text-foreground"
|
|
136
|
+
onClick={() => handleHeaderClick(colIdx)}
|
|
137
|
+
>
|
|
138
|
+
<span className="truncate">
|
|
139
|
+
{col.label ?? col.propertyName}
|
|
140
|
+
</span>
|
|
141
|
+
{sortCol === colIdx && (
|
|
142
|
+
sortDir === 'asc'
|
|
143
|
+
? <ArrowUp className="h-3 w-3 shrink-0" />
|
|
144
|
+
: <ArrowDown className="h-3 w-3 shrink-0" />
|
|
145
|
+
)}
|
|
146
|
+
</button>
|
|
147
|
+
<button
|
|
148
|
+
className={cn(
|
|
149
|
+
'shrink-0 p-0.5 rounded-sm transition-opacity',
|
|
150
|
+
isColoredCol
|
|
151
|
+
? 'text-primary opacity-100'
|
|
152
|
+
: 'opacity-0 group-hover/col:opacity-100 text-muted-foreground hover:text-primary',
|
|
153
|
+
)}
|
|
154
|
+
onClick={(e) => { e.stopPropagation(); handleColorByColumn(col, colIdx); }}
|
|
155
|
+
title={`Color by ${col.label ?? col.propertyName}`}
|
|
156
|
+
>
|
|
157
|
+
<Palette className="h-3 w-3" />
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
128
162
|
</div>
|
|
129
163
|
|
|
130
164
|
{/* Virtualized rows */}
|
|
@@ -0,0 +1,118 @@
|
|
|
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 { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { generateCloudArcs, generateCloudSVGPath } from './cloudPathGenerator.js';
|
|
8
|
+
|
|
9
|
+
describe('generateCloudArcs', () => {
|
|
10
|
+
it('generates arcs for a simple rectangle', () => {
|
|
11
|
+
const arcs = generateCloudArcs(
|
|
12
|
+
{ x: 0, y: 0 },
|
|
13
|
+
{ x: 4, y: 2 },
|
|
14
|
+
0.5 // arcRadius
|
|
15
|
+
);
|
|
16
|
+
// Should produce arcs along all 4 edges
|
|
17
|
+
assert.ok(arcs.length > 0, 'Should generate at least some arcs');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('produces arcs proportional to edge length', () => {
|
|
21
|
+
// A 4x2 rectangle with arcRadius=1 (diameter=2)
|
|
22
|
+
// Top edge (4 units): ~2 arcs
|
|
23
|
+
// Right edge (2 units): ~1 arc
|
|
24
|
+
// Bottom edge (4 units): ~2 arcs
|
|
25
|
+
// Left edge (2 units): ~1 arc
|
|
26
|
+
// Total: ~6 arcs
|
|
27
|
+
const arcs = generateCloudArcs(
|
|
28
|
+
{ x: 0, y: 0 },
|
|
29
|
+
{ x: 4, y: 2 },
|
|
30
|
+
1.0
|
|
31
|
+
);
|
|
32
|
+
assert.strictEqual(arcs.length, 6);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles equal corners (zero-size rectangle) gracefully', () => {
|
|
36
|
+
const arcs = generateCloudArcs(
|
|
37
|
+
{ x: 1, y: 1 },
|
|
38
|
+
{ x: 1, y: 1 },
|
|
39
|
+
0.5
|
|
40
|
+
);
|
|
41
|
+
// All edges are zero-length, should skip them
|
|
42
|
+
assert.strictEqual(arcs.length, 0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('handles reversed corner order', () => {
|
|
46
|
+
const arcs1 = generateCloudArcs({ x: 0, y: 0 }, { x: 4, y: 2 }, 1.0);
|
|
47
|
+
const arcs2 = generateCloudArcs({ x: 4, y: 2 }, { x: 0, y: 0 }, 1.0);
|
|
48
|
+
// Should produce same number of arcs regardless of corner order
|
|
49
|
+
assert.strictEqual(arcs1.length, arcs2.length);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('each arc has valid start, end, center, and radius', () => {
|
|
53
|
+
const arcs = generateCloudArcs({ x: 0, y: 0 }, { x: 2, y: 2 }, 0.5);
|
|
54
|
+
for (const arc of arcs) {
|
|
55
|
+
assert.ok(isFinite(arc.start.x) && isFinite(arc.start.y), 'Start should be finite');
|
|
56
|
+
assert.ok(isFinite(arc.end.x) && isFinite(arc.end.y), 'End should be finite');
|
|
57
|
+
assert.ok(isFinite(arc.center.x) && isFinite(arc.center.y), 'Center should be finite');
|
|
58
|
+
assert.ok(arc.radius > 0, 'Radius should be positive');
|
|
59
|
+
assert.ok(isFinite(arc.startAngle), 'Start angle should be finite');
|
|
60
|
+
assert.ok(isFinite(arc.endAngle), 'End angle should be finite');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('generateCloudSVGPath', () => {
|
|
66
|
+
it('generates a valid SVG path string', () => {
|
|
67
|
+
const path = generateCloudSVGPath(
|
|
68
|
+
{ x: 0, y: 0 },
|
|
69
|
+
{ x: 4, y: 2 },
|
|
70
|
+
1.0,
|
|
71
|
+
(x) => x,
|
|
72
|
+
(y) => y,
|
|
73
|
+
);
|
|
74
|
+
assert.ok(path.startsWith('M'), 'Path should start with M command');
|
|
75
|
+
assert.ok(path.includes('A'), 'Path should contain arc commands');
|
|
76
|
+
assert.ok(path.endsWith('Z'), 'Path should end with Z (close)');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('uses clockwise sweep flag (1) for outward-bulging arcs', () => {
|
|
80
|
+
const path = generateCloudSVGPath(
|
|
81
|
+
{ x: 0, y: 0 },
|
|
82
|
+
{ x: 4, y: 2 },
|
|
83
|
+
1.0,
|
|
84
|
+
(x) => x,
|
|
85
|
+
(y) => y,
|
|
86
|
+
);
|
|
87
|
+
// All arcs should use sweep-flag=1 (clockwise = outward bulge)
|
|
88
|
+
const arcMatches = path.match(/A\s+[\d.]+\s+[\d.]+\s+0\s+0\s+(\d)/g);
|
|
89
|
+
assert.ok(arcMatches && arcMatches.length > 0, 'Should have arc commands');
|
|
90
|
+
for (const match of arcMatches!) {
|
|
91
|
+
assert.ok(match.endsWith('1'), `Arc sweep flag should be 1 (clockwise/outward), got: ${match}`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('applies coordinate transforms', () => {
|
|
96
|
+
// Flip X
|
|
97
|
+
const path = generateCloudSVGPath(
|
|
98
|
+
{ x: 1, y: 0 },
|
|
99
|
+
{ x: 3, y: 2 },
|
|
100
|
+
0.5,
|
|
101
|
+
(x) => -x,
|
|
102
|
+
(y) => y,
|
|
103
|
+
);
|
|
104
|
+
assert.ok(path.includes('-'), 'Flipped X should produce negative coordinates');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('handles small rectangles', () => {
|
|
108
|
+
const path = generateCloudSVGPath(
|
|
109
|
+
{ x: 0, y: 0 },
|
|
110
|
+
{ x: 0.1, y: 0.1 },
|
|
111
|
+
0.02,
|
|
112
|
+
(x) => x,
|
|
113
|
+
(y) => y,
|
|
114
|
+
);
|
|
115
|
+
assert.ok(path.length > 0, 'Should produce a non-empty path');
|
|
116
|
+
assert.ok(path.endsWith('Z'), 'Path should be closed');
|
|
117
|
+
});
|
|
118
|
+
});
|