@ifc-lite/viewer 1.19.1 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +59 -44
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +488 -0
- package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
- package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +8 -8
- package/index.html +1 -1
- package/package.json +10 -10
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +60 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +25 -11
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +6 -0
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-xbXqEDlO.js +0 -81590
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-2WdONLlu.js +0 -2033
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-BXeEKqJG.css +0 -1
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
* BIM ↔ scan deviation heatmap controls.
|
|
7
|
+
*
|
|
8
|
+
* Renders a "Compute Deviation" button when the scene has at least
|
|
9
|
+
* one mesh and one point cloud. Once compute completes, exposes a
|
|
10
|
+
* range slider + diverging-ramp legend; the splat shader's
|
|
11
|
+
* deviation colour mode then visualises signed distance to the
|
|
12
|
+
* nearest mesh surface.
|
|
13
|
+
*
|
|
14
|
+
* Lives inside the `PointCloudPanel`; rendered conditionally on
|
|
15
|
+
* `pointCloudAssetCount > 0`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useState } from 'react';
|
|
19
|
+
import { useViewerStore } from '@/store';
|
|
20
|
+
import { getGlobalRenderer } from '@/hooks/useBCF';
|
|
21
|
+
import { cn } from '@/lib/utils';
|
|
22
|
+
|
|
23
|
+
export interface DeviationPanelProps {
|
|
24
|
+
/** Total number of triangles currently in the scene — gates the
|
|
25
|
+
* compute button on the existence of a BIM model. */
|
|
26
|
+
triangleCount: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function DeviationPanel({ triangleCount }: DeviationPanelProps) {
|
|
30
|
+
const halfRange = useViewerStore((s) => s.pointCloudDeviationHalfRange);
|
|
31
|
+
const setHalfRange = useViewerStore((s) => s.setPointCloudDeviationHalfRange);
|
|
32
|
+
const computed = useViewerStore((s) => s.pointCloudDeviationComputed);
|
|
33
|
+
const setComputed = useViewerStore((s) => s.setPointCloudDeviationComputed);
|
|
34
|
+
const colorMode = useViewerStore((s) => s.pointCloudColorMode);
|
|
35
|
+
const setColorMode = useViewerStore((s) => s.setPointCloudColorMode);
|
|
36
|
+
|
|
37
|
+
const [running, setRunning] = useState(false);
|
|
38
|
+
const [stats, setStats] = useState<{
|
|
39
|
+
triangles: number;
|
|
40
|
+
points: number;
|
|
41
|
+
durationMs: number;
|
|
42
|
+
} | null>(null);
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
const handleCompute = useCallback(async () => {
|
|
46
|
+
const renderer = getGlobalRenderer();
|
|
47
|
+
if (!renderer) {
|
|
48
|
+
setError('Renderer not initialised yet.');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setError(null);
|
|
52
|
+
setRunning(true);
|
|
53
|
+
const t0 = performance.now();
|
|
54
|
+
try {
|
|
55
|
+
const result = await renderer.computeDeviations({ maxRange: 1.0 });
|
|
56
|
+
const dt = performance.now() - t0;
|
|
57
|
+
if (result.pointsProcessed === 0) {
|
|
58
|
+
setError('No points processed — load a point cloud first.');
|
|
59
|
+
setRunning(false);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (result.bvhTriangles === 0) {
|
|
63
|
+
setError('No mesh geometry in the scene — load an IFC first.');
|
|
64
|
+
setRunning(false);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setStats({
|
|
68
|
+
triangles: result.bvhTriangles,
|
|
69
|
+
points: result.pointsProcessed,
|
|
70
|
+
durationMs: dt,
|
|
71
|
+
});
|
|
72
|
+
setComputed(true);
|
|
73
|
+
// Default-pick a sensible half-range from the BVH's bbox if the
|
|
74
|
+
// user hasn't touched the slider yet (initial 5 cm is fine for
|
|
75
|
+
// small models but useless for a city-block scan).
|
|
76
|
+
if (halfRange === 0.05 && result.suggestedHalfRange !== 0.05) {
|
|
77
|
+
setHalfRange(result.suggestedHalfRange);
|
|
78
|
+
}
|
|
79
|
+
// Auto-switch the colour mode to deviation so the user sees
|
|
80
|
+
// the result immediately.
|
|
81
|
+
setColorMode('deviation');
|
|
82
|
+
} catch (err) {
|
|
83
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
84
|
+
} finally {
|
|
85
|
+
setRunning(false);
|
|
86
|
+
}
|
|
87
|
+
}, [halfRange, setHalfRange, setColorMode, setComputed]);
|
|
88
|
+
|
|
89
|
+
// Hide the panel entirely when there's no BIM to compare against.
|
|
90
|
+
// Point-cloud-only sessions (just a LAS / IFCx scan) have nothing
|
|
91
|
+
// to deviate from so the button would always fail.
|
|
92
|
+
if (triangleCount === 0) return null;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="flex flex-col gap-1 mt-1 pt-1 border-t border-border/40">
|
|
96
|
+
<span className="text-[9px] uppercase text-muted-foreground tracking-wider">
|
|
97
|
+
Deviation (BIM ↔ scan)
|
|
98
|
+
</span>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={handleCompute}
|
|
102
|
+
disabled={running}
|
|
103
|
+
className={cn(
|
|
104
|
+
'text-xs px-2 py-1 rounded transition-colors',
|
|
105
|
+
running
|
|
106
|
+
? 'bg-muted text-muted-foreground'
|
|
107
|
+
: 'bg-teal-600 text-white hover:bg-teal-500 disabled:opacity-50',
|
|
108
|
+
)}
|
|
109
|
+
title={`Build BVH from ${triangleCount.toLocaleString()} triangles, then signed-distance every loaded point against the nearest surface`}
|
|
110
|
+
>
|
|
111
|
+
{running ? 'Computing…' : computed ? 'Recompute' : 'Compute deviation'}
|
|
112
|
+
</button>
|
|
113
|
+
{error && (
|
|
114
|
+
<span className="text-[10px] text-destructive">{error}</span>
|
|
115
|
+
)}
|
|
116
|
+
{stats && (
|
|
117
|
+
<div className="text-[10px] text-muted-foreground">
|
|
118
|
+
{stats.points.toLocaleString()} pts vs.{' '}
|
|
119
|
+
{stats.triangles.toLocaleString()} tris in{' '}
|
|
120
|
+
{Math.round(stats.durationMs)} ms
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{computed && (
|
|
125
|
+
<>
|
|
126
|
+
{/* Range slider: half-width in mm. Range from 1 mm to 1 m
|
|
127
|
+
(logarithmic feel via the millimetre conversion). */}
|
|
128
|
+
<label className="flex items-center gap-2 mt-1">
|
|
129
|
+
<span className="text-[10px] text-muted-foreground w-12 shrink-0">
|
|
130
|
+
±{(halfRange * 1000).toFixed(halfRange < 0.01 ? 1 : 0)}mm
|
|
131
|
+
</span>
|
|
132
|
+
<input
|
|
133
|
+
type="range"
|
|
134
|
+
min={1}
|
|
135
|
+
max={1000}
|
|
136
|
+
step={1}
|
|
137
|
+
value={Math.round(halfRange * 1000)}
|
|
138
|
+
onChange={(e) => setHalfRange(Number(e.target.value) / 1000)}
|
|
139
|
+
className="flex-1 h-1 accent-teal-600 cursor-pointer"
|
|
140
|
+
title="Deviation half-range in millimetres — values past ±this map to the ramp endpoints"
|
|
141
|
+
aria-label="Deviation range half-width"
|
|
142
|
+
/>
|
|
143
|
+
</label>
|
|
144
|
+
|
|
145
|
+
{/* Legend: blue → white → red gradient with labelled endpoints. */}
|
|
146
|
+
<div
|
|
147
|
+
className="h-2 rounded-sm border border-foreground/10 mt-0.5"
|
|
148
|
+
style={{
|
|
149
|
+
background: 'linear-gradient(to right, rgb(26,77,217), rgb(242,242,242), rgb(217,51,26))',
|
|
150
|
+
}}
|
|
151
|
+
aria-label="Deviation ramp from negative (blue) to positive (red)"
|
|
152
|
+
/>
|
|
153
|
+
<div className="flex justify-between text-[9px] text-muted-foreground">
|
|
154
|
+
<span>−{(halfRange * 1000).toFixed(0)}mm (inside)</span>
|
|
155
|
+
<span>0</span>
|
|
156
|
+
<span>+{(halfRange * 1000).toFixed(0)}mm (outside)</span>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{colorMode !== 'deviation' && (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => setColorMode('deviation')}
|
|
163
|
+
className="text-[10px] text-teal-600 hover:text-teal-500 underline text-left mt-0.5"
|
|
164
|
+
>
|
|
165
|
+
Switch colour mode to Deviation
|
|
166
|
+
</button>
|
|
167
|
+
)}
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -126,13 +126,39 @@ export function HierarchyPanel() {
|
|
|
126
126
|
groupingMode,
|
|
127
127
|
setGroupingMode,
|
|
128
128
|
unifiedStoreys,
|
|
129
|
-
filteredNodes,
|
|
130
|
-
storeysNodes,
|
|
131
|
-
modelsNodes,
|
|
129
|
+
filteredNodes: rawFilteredNodes,
|
|
130
|
+
storeysNodes: rawStoreysNodes,
|
|
131
|
+
modelsNodes: rawModelsNodes,
|
|
132
132
|
toggleExpand,
|
|
133
133
|
getNodeElements,
|
|
134
134
|
} = useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryResult });
|
|
135
135
|
|
|
136
|
+
// Issue #540: when the user has the merge-layers load setting on,
|
|
137
|
+
// hide `IfcBuildingElementPart` rows from the tree — the Rust layer
|
|
138
|
+
// suppresses their meshes, so leaving the rows visible would lead
|
|
139
|
+
// to dead-clicks. Filter at the consumer (this panel) rather than
|
|
140
|
+
// in `spatialHierarchy.ts` per the agent coordination plan.
|
|
141
|
+
const mergeLayersHidesParts = useViewerStore((s) => s.mergeLayers);
|
|
142
|
+
const PART_TYPE_KEY = 'ifcbuildingelementpart';
|
|
143
|
+
const stripPartNodes = useCallback(
|
|
144
|
+
(nodes: TreeNode[]): TreeNode[] => {
|
|
145
|
+
if (!mergeLayersHidesParts) return nodes;
|
|
146
|
+
return nodes.filter((node) => {
|
|
147
|
+
// Only element rows carry an `ifcType` we can compare. Class
|
|
148
|
+
// grouping ("IfcBuildingElementPart (N)") and ifc-type nodes
|
|
149
|
+
// also expose an `ifcType`; we strip those too because they
|
|
150
|
+
// would expand to empty groups after merge.
|
|
151
|
+
const t = node.ifcType?.toLowerCase();
|
|
152
|
+
if (!t) return true;
|
|
153
|
+
return t !== PART_TYPE_KEY;
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
[mergeLayersHidesParts],
|
|
157
|
+
);
|
|
158
|
+
const filteredNodes = useMemo(() => stripPartNodes(rawFilteredNodes), [stripPartNodes, rawFilteredNodes]);
|
|
159
|
+
const storeysNodes = useMemo(() => stripPartNodes(rawStoreysNodes), [stripPartNodes, rawStoreysNodes]);
|
|
160
|
+
const modelsNodes = useMemo(() => stripPartNodes(rawModelsNodes), [stripPartNodes, rawModelsNodes]);
|
|
161
|
+
|
|
136
162
|
// Refs for both scroll areas
|
|
137
163
|
const storeysRef = useRef<HTMLDivElement>(null);
|
|
138
164
|
const modelsRef = useRef<HTMLDivElement>(null);
|
|
@@ -77,6 +77,11 @@ export function HoverTooltip() {
|
|
|
77
77
|
<div className="text-xs text-muted-foreground">
|
|
78
78
|
#{hoverState.entityId}
|
|
79
79
|
</div>
|
|
80
|
+
{hoverState.worldXYZ && (
|
|
81
|
+
<div className="text-[10px] font-mono text-muted-foreground/80 mt-0.5">
|
|
82
|
+
{hoverState.worldXYZ.x.toFixed(2)}, {hoverState.worldXYZ.y.toFixed(2)}, {hoverState.worldXYZ.z.toFixed(2)}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
80
85
|
</div>
|
|
81
86
|
);
|
|
82
87
|
}
|
|
@@ -0,0 +1,389 @@
|
|
|
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
|
+
* IDSAuditSummary — surfaces the auditor's verdict on a loaded IDS
|
|
7
|
+
* document.
|
|
8
|
+
*
|
|
9
|
+
* Visual language: refined-technical instrument. Restraint over
|
|
10
|
+
* decoration. The hierarchy is carried by:
|
|
11
|
+
* - **Severity rails** — 2px tinted left border on each issue row.
|
|
12
|
+
* - **Codes as machine output** — monospace uppercase chips with
|
|
13
|
+
* severity-tinted backgrounds; treat them like log lines.
|
|
14
|
+
* - **Counts strip** — compact `▪ 3 errors ▪ 2 warnings ▪ 0 info`
|
|
15
|
+
* bar with colored dots, similar to a developer-tool status line.
|
|
16
|
+
* - **Empty state** — single line with a check icon, no flair.
|
|
17
|
+
*
|
|
18
|
+
* Interactions:
|
|
19
|
+
* - Click counts strip to toggle the issue list.
|
|
20
|
+
* - Click an individual row to expose its `path` and `detail` payload.
|
|
21
|
+
* - Filter tabs (All / Errors / Warnings) when any issues exist.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import React, { useMemo, useState } from 'react';
|
|
25
|
+
import {
|
|
26
|
+
AlertCircle,
|
|
27
|
+
AlertTriangle,
|
|
28
|
+
CheckCircle2,
|
|
29
|
+
ChevronDown,
|
|
30
|
+
ChevronRight,
|
|
31
|
+
Info,
|
|
32
|
+
Loader2,
|
|
33
|
+
} from 'lucide-react';
|
|
34
|
+
import type { IDSAuditIssue, IDSAuditReport, IDSAuditSeverity } from '@ifc-lite/ids';
|
|
35
|
+
import { cn } from '@/lib/utils';
|
|
36
|
+
|
|
37
|
+
interface IDSAuditSummaryProps {
|
|
38
|
+
report: IDSAuditReport | null;
|
|
39
|
+
/** True while the auditor is running. */
|
|
40
|
+
auditing?: boolean;
|
|
41
|
+
/** Optional className passed to the outer container. */
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type SeverityFilter = 'all' | IDSAuditSeverity;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Severity tokens
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const SEVERITY_ORDER: Record<IDSAuditSeverity, number> = {
|
|
52
|
+
error: 0,
|
|
53
|
+
warning: 1,
|
|
54
|
+
info: 2,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const SEVERITY_TOKENS: Record<
|
|
58
|
+
IDSAuditSeverity,
|
|
59
|
+
{
|
|
60
|
+
label: string;
|
|
61
|
+
pluralLabel: string;
|
|
62
|
+
dot: string;
|
|
63
|
+
rail: string;
|
|
64
|
+
chipBg: string;
|
|
65
|
+
chipFg: string;
|
|
66
|
+
chipBorder: string;
|
|
67
|
+
icon: React.ReactNode;
|
|
68
|
+
iconClass: string;
|
|
69
|
+
}
|
|
70
|
+
> = {
|
|
71
|
+
error: {
|
|
72
|
+
label: 'error',
|
|
73
|
+
pluralLabel: 'errors',
|
|
74
|
+
dot: 'bg-red-500',
|
|
75
|
+
rail: 'border-l-red-500',
|
|
76
|
+
chipBg: 'bg-red-500/10',
|
|
77
|
+
chipFg: 'text-red-600 dark:text-red-400',
|
|
78
|
+
chipBorder: 'border-red-500/30',
|
|
79
|
+
icon: <AlertCircle className="h-4 w-4" aria-hidden="true" />,
|
|
80
|
+
iconClass: 'text-red-500',
|
|
81
|
+
},
|
|
82
|
+
warning: {
|
|
83
|
+
label: 'warning',
|
|
84
|
+
pluralLabel: 'warnings',
|
|
85
|
+
dot: 'bg-amber-500',
|
|
86
|
+
rail: 'border-l-amber-500',
|
|
87
|
+
chipBg: 'bg-amber-500/10',
|
|
88
|
+
chipFg: 'text-amber-700 dark:text-amber-400',
|
|
89
|
+
chipBorder: 'border-amber-500/30',
|
|
90
|
+
icon: <AlertTriangle className="h-4 w-4" aria-hidden="true" />,
|
|
91
|
+
iconClass: 'text-amber-500',
|
|
92
|
+
},
|
|
93
|
+
info: {
|
|
94
|
+
label: 'note',
|
|
95
|
+
pluralLabel: 'notes',
|
|
96
|
+
dot: 'bg-sky-400',
|
|
97
|
+
rail: 'border-l-sky-400',
|
|
98
|
+
chipBg: 'bg-sky-400/10',
|
|
99
|
+
chipFg: 'text-sky-600 dark:text-sky-400',
|
|
100
|
+
chipBorder: 'border-sky-400/30',
|
|
101
|
+
icon: <Info className="h-4 w-4" aria-hidden="true" />,
|
|
102
|
+
iconClass: 'text-sky-500',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Component
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export function IDSAuditSummary({
|
|
111
|
+
report,
|
|
112
|
+
auditing = false,
|
|
113
|
+
className,
|
|
114
|
+
}: IDSAuditSummaryProps): JSX.Element | null {
|
|
115
|
+
const [expanded, setExpanded] = useState(false);
|
|
116
|
+
const [filter, setFilter] = useState<SeverityFilter>('all');
|
|
117
|
+
|
|
118
|
+
// Stable per-severity counts.
|
|
119
|
+
const counts = useMemo(() => {
|
|
120
|
+
const base: Record<IDSAuditSeverity, number> = {
|
|
121
|
+
error: 0,
|
|
122
|
+
warning: 0,
|
|
123
|
+
info: 0,
|
|
124
|
+
};
|
|
125
|
+
if (!report) return base;
|
|
126
|
+
for (const issue of report.issues) {
|
|
127
|
+
base[issue.severity] += 1;
|
|
128
|
+
}
|
|
129
|
+
return base;
|
|
130
|
+
}, [report]);
|
|
131
|
+
|
|
132
|
+
// Sort issues by severity (errors first), preserving document order
|
|
133
|
+
// within each bucket. Rendering errors-first gives the user the most
|
|
134
|
+
// important information at the top of the expanded list.
|
|
135
|
+
const sortedIssues = useMemo(() => {
|
|
136
|
+
if (!report) return [];
|
|
137
|
+
return [...report.issues].sort(
|
|
138
|
+
(a, b) =>
|
|
139
|
+
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
|
|
140
|
+
);
|
|
141
|
+
}, [report]);
|
|
142
|
+
|
|
143
|
+
const visibleIssues = useMemo(() => {
|
|
144
|
+
if (filter === 'all') return sortedIssues;
|
|
145
|
+
return sortedIssues.filter((i) => i.severity === filter);
|
|
146
|
+
}, [sortedIssues, filter]);
|
|
147
|
+
|
|
148
|
+
// Auditing in flight — quietly mark the spot.
|
|
149
|
+
if (auditing && !report) {
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
className={cn(
|
|
153
|
+
'flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground',
|
|
154
|
+
'animate-fade-in-up',
|
|
155
|
+
className
|
|
156
|
+
)}
|
|
157
|
+
role="status"
|
|
158
|
+
aria-live="polite"
|
|
159
|
+
>
|
|
160
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
|
161
|
+
<span>Auditing IDS document…</span>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!report) return null;
|
|
167
|
+
|
|
168
|
+
const totalIssues = report.issues.length;
|
|
169
|
+
const isClean = report.status === 'valid' || totalIssues === 0;
|
|
170
|
+
|
|
171
|
+
// Clean state — single line, no flair.
|
|
172
|
+
if (isClean) {
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
className={cn(
|
|
176
|
+
'flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400',
|
|
177
|
+
'animate-fade-in-up',
|
|
178
|
+
className
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
|
182
|
+
<span>Document is valid — no audit issues</span>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Has issues — counts strip + collapsible list.
|
|
188
|
+
return (
|
|
189
|
+
<section
|
|
190
|
+
className={cn(
|
|
191
|
+
'overflow-hidden rounded-md border border-border/70 bg-card animate-fade-in-up',
|
|
192
|
+
className
|
|
193
|
+
)}
|
|
194
|
+
aria-label="IDS document audit summary"
|
|
195
|
+
>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={() => setExpanded((v) => !v)}
|
|
199
|
+
className={cn(
|
|
200
|
+
'flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors',
|
|
201
|
+
'hover:bg-muted/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60'
|
|
202
|
+
)}
|
|
203
|
+
aria-expanded={expanded}
|
|
204
|
+
>
|
|
205
|
+
<span className="flex items-center gap-3 text-xs">
|
|
206
|
+
{(['error', 'warning', 'info'] as IDSAuditSeverity[]).map((sev) => {
|
|
207
|
+
const n = counts[sev];
|
|
208
|
+
if (n === 0) return null;
|
|
209
|
+
const t = SEVERITY_TOKENS[sev];
|
|
210
|
+
return (
|
|
211
|
+
<span key={sev} className="inline-flex items-center gap-1.5">
|
|
212
|
+
<span
|
|
213
|
+
className={cn('h-1.5 w-1.5 rounded-full', t.dot)}
|
|
214
|
+
aria-hidden="true"
|
|
215
|
+
/>
|
|
216
|
+
<span className={cn('font-mono tabular-nums', t.chipFg)}>
|
|
217
|
+
{n}
|
|
218
|
+
</span>
|
|
219
|
+
<span className="text-muted-foreground">
|
|
220
|
+
{n === 1 ? t.label : t.pluralLabel}
|
|
221
|
+
</span>
|
|
222
|
+
</span>
|
|
223
|
+
);
|
|
224
|
+
})}
|
|
225
|
+
</span>
|
|
226
|
+
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
227
|
+
<span>{expanded ? 'Hide' : 'Details'}</span>
|
|
228
|
+
{expanded ? (
|
|
229
|
+
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
|
230
|
+
) : (
|
|
231
|
+
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
|
232
|
+
)}
|
|
233
|
+
</span>
|
|
234
|
+
</button>
|
|
235
|
+
|
|
236
|
+
{expanded && (
|
|
237
|
+
<div className="border-t border-border/60">
|
|
238
|
+
{/* Filter tabs */}
|
|
239
|
+
<div className="flex items-center gap-1 border-b border-border/60 bg-muted/20 px-2 py-1.5">
|
|
240
|
+
{(
|
|
241
|
+
[
|
|
242
|
+
{ key: 'all', label: `All (${totalIssues})` },
|
|
243
|
+
counts.error > 0 && {
|
|
244
|
+
key: 'error',
|
|
245
|
+
label: `Errors (${counts.error})`,
|
|
246
|
+
},
|
|
247
|
+
counts.warning > 0 && {
|
|
248
|
+
key: 'warning',
|
|
249
|
+
label: `Warnings (${counts.warning})`,
|
|
250
|
+
},
|
|
251
|
+
counts.info > 0 && {
|
|
252
|
+
key: 'info',
|
|
253
|
+
label: `Notes (${counts.info})`,
|
|
254
|
+
},
|
|
255
|
+
].filter(Boolean) as Array<{
|
|
256
|
+
key: SeverityFilter;
|
|
257
|
+
label: string;
|
|
258
|
+
}>
|
|
259
|
+
).map((tab) => (
|
|
260
|
+
<button
|
|
261
|
+
key={tab.key}
|
|
262
|
+
type="button"
|
|
263
|
+
onClick={() => setFilter(tab.key)}
|
|
264
|
+
className={cn(
|
|
265
|
+
'rounded px-2 py-0.5 text-[11px] transition-colors',
|
|
266
|
+
filter === tab.key
|
|
267
|
+
? 'bg-foreground text-background'
|
|
268
|
+
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
|
269
|
+
)}
|
|
270
|
+
>
|
|
271
|
+
{tab.label}
|
|
272
|
+
</button>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{/* Issue list */}
|
|
277
|
+
<ul className="max-h-72 overflow-y-auto py-1">
|
|
278
|
+
{visibleIssues.map((issue, i) => (
|
|
279
|
+
<IssueRow key={`${issue.code}-${issue.path}-${i}`} issue={issue} index={i} />
|
|
280
|
+
))}
|
|
281
|
+
{visibleIssues.length === 0 && (
|
|
282
|
+
<li className="px-3 py-3 text-xs text-muted-foreground">
|
|
283
|
+
No issues match the selected filter.
|
|
284
|
+
</li>
|
|
285
|
+
)}
|
|
286
|
+
</ul>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</section>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Issue row
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
interface IssueRowProps {
|
|
298
|
+
issue: IDSAuditIssue;
|
|
299
|
+
index: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function IssueRow({ issue, index }: IssueRowProps): JSX.Element {
|
|
303
|
+
const [open, setOpen] = useState(false);
|
|
304
|
+
const t = SEVERITY_TOKENS[issue.severity];
|
|
305
|
+
const hasDetail =
|
|
306
|
+
!!issue.path ||
|
|
307
|
+
(issue.detail !== undefined && Object.keys(issue.detail).length > 0);
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<li
|
|
311
|
+
className={cn(
|
|
312
|
+
'group border-l-2 px-3 py-1.5 text-xs transition-colors hover:bg-muted/30',
|
|
313
|
+
t.rail,
|
|
314
|
+
// Stagger reveal — capped so long lists don't take seconds.
|
|
315
|
+
'animate-fade-in-up'
|
|
316
|
+
)}
|
|
317
|
+
style={{ animationDelay: `${Math.min(index, 12) * 24}ms` }}
|
|
318
|
+
>
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
onClick={() => hasDetail && setOpen((v) => !v)}
|
|
322
|
+
className={cn(
|
|
323
|
+
'flex w-full items-start gap-2 text-left',
|
|
324
|
+
hasDetail && 'cursor-pointer',
|
|
325
|
+
!hasDetail && 'cursor-default'
|
|
326
|
+
)}
|
|
327
|
+
aria-expanded={hasDetail ? open : undefined}
|
|
328
|
+
>
|
|
329
|
+
<span className={cn('mt-0.5 shrink-0', t.iconClass)}>{t.icon}</span>
|
|
330
|
+
<span className="min-w-0 flex-1 space-y-1">
|
|
331
|
+
<span className="flex flex-wrap items-baseline gap-2">
|
|
332
|
+
<code
|
|
333
|
+
className={cn(
|
|
334
|
+
'shrink-0 rounded border px-1.5 py-0 font-mono text-[10px] uppercase tracking-tight leading-relaxed',
|
|
335
|
+
t.chipBg,
|
|
336
|
+
t.chipFg,
|
|
337
|
+
t.chipBorder
|
|
338
|
+
)}
|
|
339
|
+
>
|
|
340
|
+
{issue.code}
|
|
341
|
+
</code>
|
|
342
|
+
<span className="text-foreground">{issue.message}</span>
|
|
343
|
+
</span>
|
|
344
|
+
{hasDetail && open && (
|
|
345
|
+
<div className="ml-1 mt-1.5 space-y-1 border-l border-border/60 pl-2">
|
|
346
|
+
{issue.path && (
|
|
347
|
+
<div className="flex gap-2 font-mono text-[11px]">
|
|
348
|
+
<span className="text-muted-foreground/70">path</span>
|
|
349
|
+
<span className="break-all text-muted-foreground">
|
|
350
|
+
{issue.path}
|
|
351
|
+
</span>
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
{issue.facetType && (
|
|
355
|
+
<div className="flex gap-2 font-mono text-[11px]">
|
|
356
|
+
<span className="text-muted-foreground/70">facet</span>
|
|
357
|
+
<span className="text-muted-foreground">
|
|
358
|
+
{issue.facetType}
|
|
359
|
+
</span>
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
{issue.detail && Object.keys(issue.detail).length > 0 && (
|
|
363
|
+
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
|
364
|
+
{Object.entries(issue.detail).map(([k, v]) => (
|
|
365
|
+
<div key={k} className="flex gap-2">
|
|
366
|
+
<span className="text-muted-foreground/70">{k}</span>
|
|
367
|
+
<span className="break-all text-muted-foreground">
|
|
368
|
+
{String(v)}
|
|
369
|
+
</span>
|
|
370
|
+
</div>
|
|
371
|
+
))}
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</span>
|
|
377
|
+
{hasDetail && (
|
|
378
|
+
<ChevronDown
|
|
379
|
+
className={cn(
|
|
380
|
+
'mt-1 h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform',
|
|
381
|
+
open && 'rotate-180'
|
|
382
|
+
)}
|
|
383
|
+
aria-hidden="true"
|
|
384
|
+
/>
|
|
385
|
+
)}
|
|
386
|
+
</button>
|
|
387
|
+
</li>
|
|
388
|
+
);
|
|
389
|
+
}
|