@the-syllabus/analysis-renderers 0.2.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/dist/cells/RelationshipCardCell.d.ts +10 -0
- package/dist/cells/RelationshipCardCell.d.ts.map +1 -0
- package/dist/cells/RelationshipCardCell.js +91 -0
- package/dist/cells/RelationshipCardCell.js.map +1 -0
- package/dist/cells/TacticCardCell.d.ts +12 -0
- package/dist/cells/TacticCardCell.d.ts.map +1 -0
- package/dist/cells/TacticCardCell.js +77 -0
- package/dist/cells/TacticCardCell.js.map +1 -0
- package/dist/cells/TemplateCardCell.d.ts +29 -0
- package/dist/cells/TemplateCardCell.d.ts.map +1 -0
- package/dist/cells/TemplateCardCell.js +202 -0
- package/dist/cells/TemplateCardCell.js.map +1 -0
- package/dist/cells/index.d.ts +15 -0
- package/dist/cells/index.d.ts.map +1 -0
- package/dist/cells/index.js +85 -0
- package/dist/cells/index.js.map +1 -0
- package/dist/components/ConditionCards.d.ts +18 -0
- package/dist/components/ConditionCards.d.ts.map +1 -0
- package/dist/components/ConditionCards.js +28 -0
- package/dist/components/ConditionCards.js.map +1 -0
- package/dist/components/EvidenceTrail.d.ts +54 -0
- package/dist/components/EvidenceTrail.d.ts.map +1 -0
- package/dist/components/EvidenceTrail.js +98 -0
- package/dist/components/EvidenceTrail.js.map +1 -0
- package/dist/dispatch/SubRendererDispatch.d.ts +39 -0
- package/dist/dispatch/SubRendererDispatch.d.ts.map +1 -0
- package/dist/dispatch/SubRendererDispatch.js +153 -0
- package/dist/dispatch/SubRendererDispatch.js.map +1 -0
- package/dist/hooks/useProseExtraction.d.ts +38 -0
- package/dist/hooks/useProseExtraction.d.ts.map +1 -0
- package/dist/hooks/useProseExtraction.js +93 -0
- package/dist/hooks/useProseExtraction.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/provenance/ProvenanceSectionIcon.d.ts +15 -0
- package/dist/provenance/ProvenanceSectionIcon.d.ts.map +1 -0
- package/dist/provenance/ProvenanceSectionIcon.js +11 -0
- package/dist/provenance/ProvenanceSectionIcon.js.map +1 -0
- package/dist/renderers/AccordionRenderer.d.ts +29 -0
- package/dist/renderers/AccordionRenderer.d.ts.map +1 -0
- package/dist/renderers/AccordionRenderer.js +315 -0
- package/dist/renderers/AccordionRenderer.js.map +1 -0
- package/dist/renderers/CardGridRenderer.d.ts +24 -0
- package/dist/renderers/CardGridRenderer.d.ts.map +1 -0
- package/dist/renderers/CardGridRenderer.js +321 -0
- package/dist/renderers/CardGridRenderer.js.map +1 -0
- package/dist/renderers/CardRenderer.d.ts +27 -0
- package/dist/renderers/CardRenderer.d.ts.map +1 -0
- package/dist/renderers/CardRenderer.js +337 -0
- package/dist/renderers/CardRenderer.js.map +1 -0
- package/dist/renderers/IdeaEvolutionRenderer.d.ts +16 -0
- package/dist/renderers/IdeaEvolutionRenderer.d.ts.map +1 -0
- package/dist/renderers/IdeaEvolutionRenderer.js +187 -0
- package/dist/renderers/IdeaEvolutionRenderer.js.map +1 -0
- package/dist/renderers/ProseRenderer.d.ts +10 -0
- package/dist/renderers/ProseRenderer.d.ts.map +1 -0
- package/dist/renderers/ProseRenderer.js +42 -0
- package/dist/renderers/ProseRenderer.js.map +1 -0
- package/dist/renderers/RawJsonRenderer.d.ts +8 -0
- package/dist/renderers/RawJsonRenderer.d.ts.map +1 -0
- package/dist/renderers/RawJsonRenderer.js +17 -0
- package/dist/renderers/RawJsonRenderer.js.map +1 -0
- package/dist/renderers/StatSummaryRenderer.d.ts +12 -0
- package/dist/renderers/StatSummaryRenderer.d.ts.map +1 -0
- package/dist/renderers/StatSummaryRenderer.js +93 -0
- package/dist/renderers/StatSummaryRenderer.js.map +1 -0
- package/dist/renderers/SynthesisRenderer.d.ts +15 -0
- package/dist/renderers/SynthesisRenderer.d.ts.map +1 -0
- package/dist/renderers/SynthesisRenderer.js +60 -0
- package/dist/renderers/SynthesisRenderer.js.map +1 -0
- package/dist/renderers/TableRenderer.d.ts +19 -0
- package/dist/renderers/TableRenderer.d.ts.map +1 -0
- package/dist/renderers/TableRenderer.js +273 -0
- package/dist/renderers/TableRenderer.js.map +1 -0
- package/dist/styles/accordion.css +376 -0
- package/dist/styles/index.css +5 -0
- package/dist/styles/renderers.css +1049 -0
- package/dist/sub-renderers/SubRenderers.d.ts +73 -0
- package/dist/sub-renderers/SubRenderers.d.ts.map +1 -0
- package/dist/sub-renderers/SubRenderers.js +2462 -0
- package/dist/sub-renderers/SubRenderers.js.map +1 -0
- package/dist/tokens/DesignTokenContext.d.ts +40 -0
- package/dist/tokens/DesignTokenContext.d.ts.map +1 -0
- package/dist/tokens/DesignTokenContext.js +408 -0
- package/dist/tokens/DesignTokenContext.js.map +1 -0
- package/dist/types/designTokens.d.ts +220 -0
- package/dist/types/designTokens.d.ts.map +1 -0
- package/dist/types/designTokens.js +8 -0
- package/dist/types/designTokens.js.map +1 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/styles.d.ts +38 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/dist/types/styles.js +14 -0
- package/dist/types/styles.js.map +1 -0
- package/dist/utils/tokenFlattener.d.ts +14 -0
- package/dist/utils/tokenFlattener.d.ts.map +1 -0
- package/dist/utils/tokenFlattener.js +56 -0
- package/dist/utils/tokenFlattener.js.map +1 -0
- package/package.json +31 -0
- package/src/cells/TemplateCardCell.tsx +439 -0
- package/src/cells/index.ts +98 -0
- package/src/components/ConditionCards.tsx +109 -0
- package/src/components/EvidenceTrail.tsx +203 -0
- package/src/dispatch/SubRendererDispatch.tsx +282 -0
- package/src/hooks/useProseExtraction.ts +125 -0
- package/src/index.ts +82 -0
- package/src/provenance/ProvenanceSectionIcon.tsx +19 -0
- package/src/renderers/AccordionRenderer.tsx +609 -0
- package/src/renderers/CardGridRenderer.tsx +608 -0
- package/src/renderers/CardRenderer.tsx +517 -0
- package/src/renderers/ProseRenderer.tsx +85 -0
- package/src/renderers/RawJsonRenderer.tsx +37 -0
- package/src/renderers/StatSummaryRenderer.tsx +182 -0
- package/src/renderers/TableRenderer.tsx +470 -0
- package/src/styles/accordion.css +376 -0
- package/src/styles/index.css +5 -0
- package/src/styles/renderers.css +1049 -0
- package/src/sub-renderers/SubRenderers.tsx +3487 -0
- package/src/tokens/DesignTokenContext.tsx +502 -0
- package/src/types/designTokens.ts +236 -0
- package/src/types/index.ts +53 -0
- package/src/types/styles.ts +44 -0
- package/src/utils/tokenFlattener.ts +64 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CardGridRenderer — Generic card grid with pluggable cell renderers.
|
|
3
|
+
*
|
|
4
|
+
* Layout shell that handles:
|
|
5
|
+
* - Data normalization (arrays stay as-is, Records are flattened)
|
|
6
|
+
* - Grouping by a configurable field
|
|
7
|
+
* - Summary bar with distribution counts
|
|
8
|
+
* - Variable card sizing with hero card pattern
|
|
9
|
+
* - Hover elevation and card type indicators
|
|
10
|
+
* - Style override consumption for polish integration
|
|
11
|
+
* - Expandable cards (optional)
|
|
12
|
+
*
|
|
13
|
+
* renderer_config keys:
|
|
14
|
+
* cell_renderer: string — key into cellRenderers registry
|
|
15
|
+
* group_by: string — field to group items by (optional)
|
|
16
|
+
* group_style_map: string — category name for getCategoryColor lookups
|
|
17
|
+
* group_descriptions: Record — optional per-group description text
|
|
18
|
+
* columns: number — grid columns (default: 2)
|
|
19
|
+
* expandable: boolean — cards expand on click (default: false)
|
|
20
|
+
* summary_fields: string[] — fields for summary bar above grid
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
|
24
|
+
import { RendererProps } from '../types';
|
|
25
|
+
import { cellRenderers, DefaultCardCell } from '../cells';
|
|
26
|
+
import { useDesignTokens } from '../tokens/DesignTokenContext';
|
|
27
|
+
import { useProseExtraction } from '../hooks/useProseExtraction';
|
|
28
|
+
import { StyleOverrides, getSO } from '../types/styles';
|
|
29
|
+
type CaptureSelection = Record<string, unknown>;
|
|
30
|
+
import { DistributionSummary } from '../sub-renderers/SubRenderers';
|
|
31
|
+
|
|
32
|
+
// ── Content length estimation ─────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Estimate the text content length of an item for hero detection */
|
|
35
|
+
function estimateContentLength(item: Record<string, unknown>): number {
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (const value of Object.values(item)) {
|
|
38
|
+
if (typeof value === 'string') total += value.length;
|
|
39
|
+
}
|
|
40
|
+
return total;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Prose mode rendering helpers ─────────────────────────
|
|
44
|
+
|
|
45
|
+
function ProseLoadingState() {
|
|
46
|
+
return (
|
|
47
|
+
<div style={{ padding: '2rem', textAlign: 'center' as const }}>
|
|
48
|
+
<div className="gen-extracting-spinner" />
|
|
49
|
+
<p>Preparing structured view from analytical prose...</p>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ProseErrorState({ error }: { error: string }) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="gen-extraction-error" style={{ padding: '1rem' }}>
|
|
57
|
+
<p>Could not extract structured data: {error}</p>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Data normalization ───────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function normalizeToArray(data: unknown): Array<Record<string, unknown>> {
|
|
65
|
+
if (Array.isArray(data)) return data;
|
|
66
|
+
|
|
67
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
68
|
+
const obj = data as Record<string, unknown>;
|
|
69
|
+
const entries = Object.entries(obj);
|
|
70
|
+
|
|
71
|
+
// Detect pattern: object whose values are arrays of objects
|
|
72
|
+
// e.g. { enabling_conditions: [{...}], constraining_conditions: [{...}] }
|
|
73
|
+
// Flatten into a single array with _category tag for grouping
|
|
74
|
+
const arrayOfObjectEntries = entries.filter(
|
|
75
|
+
([, v]) => Array.isArray(v) && v.length > 0 && v[0] != null && typeof v[0] === 'object'
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (arrayOfObjectEntries.length > 0) {
|
|
79
|
+
return arrayOfObjectEntries.flatMap(([key, arr]) =>
|
|
80
|
+
(arr as Record<string, unknown>[]).map(item => ({
|
|
81
|
+
_category: key,
|
|
82
|
+
...item,
|
|
83
|
+
}))
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Fallback: Record<string, T> → flatten to array with docKey field
|
|
88
|
+
return entries.map(([key, value]) => {
|
|
89
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
90
|
+
return { docKey: key, ...value as Record<string, unknown> };
|
|
91
|
+
}
|
|
92
|
+
return { docKey: key, value };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Grouping ─────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
interface Group {
|
|
102
|
+
key: string;
|
|
103
|
+
label: string;
|
|
104
|
+
style: { bg: string; text: string; border: string; label?: string };
|
|
105
|
+
items: Array<Record<string, unknown>>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function groupItems(
|
|
109
|
+
items: Array<Record<string, unknown>>,
|
|
110
|
+
groupBy: string | undefined,
|
|
111
|
+
styleMapKey: string | undefined,
|
|
112
|
+
getCategoryColor?: (category: string, key: string) => { bg: string; text: string; border: string; label?: string } | null,
|
|
113
|
+
): Group[] {
|
|
114
|
+
if (!groupBy) {
|
|
115
|
+
return [{
|
|
116
|
+
key: '__all__',
|
|
117
|
+
label: '',
|
|
118
|
+
style: { bg: 'transparent', text: 'inherit', border: 'transparent' },
|
|
119
|
+
items,
|
|
120
|
+
}];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const category = styleMapKey || undefined;
|
|
124
|
+
const defaultStyle = { bg: 'var(--dt-surface-alt)', text: 'var(--dt-text-muted)', border: 'var(--dt-border-light)', label: undefined as string | undefined };
|
|
125
|
+
|
|
126
|
+
const grouped: Record<string, Array<Record<string, unknown>>> = {};
|
|
127
|
+
for (const item of items) {
|
|
128
|
+
const groupValue = String(item[groupBy] || 'unknown');
|
|
129
|
+
if (!grouped[groupValue]) grouped[groupValue] = [];
|
|
130
|
+
grouped[groupValue].push(item);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Sort groups by count (most frequent first)
|
|
134
|
+
return Object.entries(grouped)
|
|
135
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
136
|
+
.map(([key, groupItems]) => {
|
|
137
|
+
const mapStyle = category && getCategoryColor ? getCategoryColor(category, key) : null;
|
|
138
|
+
// Format label: "enabling_conditions" → "Enabling Conditions"
|
|
139
|
+
const rawLabel = key.replace(/_/g, ' ');
|
|
140
|
+
const titleLabel = rawLabel.replace(/\b\w/g, c => c.toUpperCase());
|
|
141
|
+
return {
|
|
142
|
+
key,
|
|
143
|
+
label: mapStyle?.label || titleLabel,
|
|
144
|
+
style: mapStyle || defaultStyle,
|
|
145
|
+
items: groupItems,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Main Component ───────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Navigate a dotted path into an object.
|
|
154
|
+
*/
|
|
155
|
+
function getPath(obj: unknown, path: string): unknown {
|
|
156
|
+
if (!path) return obj;
|
|
157
|
+
const parts = path.split('.');
|
|
158
|
+
let current: unknown = obj;
|
|
159
|
+
for (const part of parts) {
|
|
160
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
161
|
+
current = (current as Record<string, unknown>)[part];
|
|
162
|
+
}
|
|
163
|
+
return current;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function CardGridRenderer({ data, config }: RendererProps) {
|
|
167
|
+
const { getCategoryColor, getSemanticColor, getLabel } = useDesignTokens();
|
|
168
|
+
|
|
169
|
+
const cellRendererKey = config.cell_renderer as string | undefined;
|
|
170
|
+
const groupBy = config.group_by as string | undefined;
|
|
171
|
+
const groupStyleMap = config.group_style_map as string | undefined;
|
|
172
|
+
const expandable = config.expandable as boolean | undefined;
|
|
173
|
+
const itemsPath = config.items_path as string | undefined;
|
|
174
|
+
const proseEndpoint = config.prose_endpoint as string | undefined;
|
|
175
|
+
|
|
176
|
+
// Group scroll-to targeting (from URL ?group= param)
|
|
177
|
+
const targetGroup = config._targetGroup as string | undefined;
|
|
178
|
+
const onGroupConsumed = config._onGroupConsumed as (() => void) | undefined;
|
|
179
|
+
|
|
180
|
+
// Capture mode
|
|
181
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
182
|
+
const onCapture = config._onCapture as
|
|
183
|
+
| ((sel: Record<string, unknown>) => void)
|
|
184
|
+
| undefined;
|
|
185
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
186
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
187
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
188
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
189
|
+
|
|
190
|
+
const so = getSO(config);
|
|
191
|
+
|
|
192
|
+
const [activeFilter, setActiveFilter] = useState<string | null>(null);
|
|
193
|
+
|
|
194
|
+
// Prose extraction — detect if the data has _prose_output marker
|
|
195
|
+
const { data: extractedData, loading, error, isProseMode } = useProseExtraction<unknown>(
|
|
196
|
+
data as unknown,
|
|
197
|
+
config._jobId as string | undefined,
|
|
198
|
+
proseEndpoint || 'data',
|
|
199
|
+
{ apiPathPrefix: config._apiPathPrefix as string | undefined }
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const workingData = isProseMode ? extractedData : data;
|
|
203
|
+
const workingObj = workingData as Record<string, unknown> | undefined;
|
|
204
|
+
|
|
205
|
+
// Resolve cell renderer
|
|
206
|
+
const CellRenderer = cellRendererKey
|
|
207
|
+
? cellRenderers[cellRendererKey] || DefaultCardCell
|
|
208
|
+
: DefaultCardCell;
|
|
209
|
+
|
|
210
|
+
// Extract items array from the data using items_path
|
|
211
|
+
const rawItems = useMemo(() => {
|
|
212
|
+
if (!workingData) return [];
|
|
213
|
+
const extracted = itemsPath ? getPath(workingData, itemsPath) : workingData;
|
|
214
|
+
return normalizeToArray(extracted);
|
|
215
|
+
}, [workingData, itemsPath]);
|
|
216
|
+
|
|
217
|
+
// Auto-detect group_by: if items have _category (from flattening), group by it
|
|
218
|
+
const effectiveGroupBy = groupBy || (
|
|
219
|
+
rawItems.length > 0 && rawItems[0]._category ? '_category' : undefined
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Group items
|
|
223
|
+
const groups = useMemo(
|
|
224
|
+
() => groupItems(rawItems, effectiveGroupBy, groupStyleMap, getCategoryColor),
|
|
225
|
+
[rawItems, effectiveGroupBy, groupStyleMap, getCategoryColor]
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Apply active filter
|
|
229
|
+
const visibleGroups = activeFilter
|
|
230
|
+
? groups.filter(g => g.key === activeFilter)
|
|
231
|
+
: groups;
|
|
232
|
+
|
|
233
|
+
if (loading) return <ProseLoadingState />;
|
|
234
|
+
if (error) return <ProseErrorState error={error} />;
|
|
235
|
+
|
|
236
|
+
if (!workingData) {
|
|
237
|
+
return <p className="gen-empty">No data available yet.</p>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (rawItems.length === 0) {
|
|
241
|
+
return <p className="gen-empty">No items found.</p>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const hasGroups = groups.length > 1 || groups[0]?.key !== '__all__';
|
|
245
|
+
|
|
246
|
+
// Summary panel config — either from view definition or legacy tactic_patterns fallback
|
|
247
|
+
const summaryConfig = config.summary as {
|
|
248
|
+
data_path?: string;
|
|
249
|
+
renderer_config?: Record<string, unknown>;
|
|
250
|
+
} | undefined;
|
|
251
|
+
const summaryDataPath = summaryConfig?.data_path || 'distribution';
|
|
252
|
+
const summaryData = workingObj?.[summaryDataPath] as Record<string, unknown> | undefined;
|
|
253
|
+
const hasSummary = Boolean(summaryData);
|
|
254
|
+
|
|
255
|
+
const handleFilterClick = (groupKey: string) => {
|
|
256
|
+
if (!groupKey) { setActiveFilter(null); return; }
|
|
257
|
+
setActiveFilter(prev => prev === groupKey ? null : groupKey);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className="ar-card-grid" style={so?.view_wrapper || undefined}>
|
|
262
|
+
{/* Prose mode badge */}
|
|
263
|
+
{isProseMode && (
|
|
264
|
+
<div className="gen-prose-badge">
|
|
265
|
+
<span className="gen-prose-indicator">Extracted from analytical prose</span>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{/* Distribution summary with integrated bar chart */}
|
|
270
|
+
{hasSummary && (
|
|
271
|
+
<DistributionSummary
|
|
272
|
+
data={summaryData}
|
|
273
|
+
config={{
|
|
274
|
+
...summaryConfig?.renderer_config,
|
|
275
|
+
_onFilterClick: handleFilterClick,
|
|
276
|
+
_activeFilter: activeFilter,
|
|
277
|
+
_groups: hasGroups ? groups : undefined,
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{/* Flat filter chips — only when no distribution summary (non-tactic views) */}
|
|
283
|
+
{hasGroups && !hasSummary && (
|
|
284
|
+
<div className="ar-grid-summary">
|
|
285
|
+
<span className="ar-grid-total">
|
|
286
|
+
{rawItems.length} item{rawItems.length !== 1 ? 's' : ''}
|
|
287
|
+
</span>
|
|
288
|
+
<div className="ar-grid-dist">
|
|
289
|
+
{groups.map(group => (
|
|
290
|
+
<button
|
|
291
|
+
key={group.key}
|
|
292
|
+
type="button"
|
|
293
|
+
className={`ar-grid-dist-tag ${activeFilter === group.key ? 'ar-grid-dist-tag--active' : ''}`}
|
|
294
|
+
style={{
|
|
295
|
+
background: activeFilter === group.key ? group.style.text : group.style.bg,
|
|
296
|
+
color: activeFilter === group.key ? 'var(--dt-text-inverse)' : group.style.text,
|
|
297
|
+
borderColor: group.style.border,
|
|
298
|
+
}}
|
|
299
|
+
onClick={() => handleFilterClick(group.key)}
|
|
300
|
+
>
|
|
301
|
+
{group.label}: {group.items.length}
|
|
302
|
+
</button>
|
|
303
|
+
))}
|
|
304
|
+
{activeFilter && (
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
className="ar-grid-dist-clear"
|
|
308
|
+
onClick={() => setActiveFilter(null)}
|
|
309
|
+
>
|
|
310
|
+
Show all
|
|
311
|
+
</button>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
{/* Grouped card grid */}
|
|
318
|
+
{visibleGroups.map(group => {
|
|
319
|
+
// Sort items within group: major first, then moderate, then minor
|
|
320
|
+
const severityWeight: Record<string, number> = { major: 3, moderate: 2, minor: 1 };
|
|
321
|
+
const sortedItems = [...group.items].sort((a, b) => {
|
|
322
|
+
const wa = severityWeight[String(a.severity || 'minor').toLowerCase()] || 0;
|
|
323
|
+
const wb = severityWeight[String(b.severity || 'minor').toLowerCase()] || 0;
|
|
324
|
+
return wb - wa;
|
|
325
|
+
});
|
|
326
|
+
const majorCount = sortedItems.filter(i => String(i.severity || '').toLowerCase() === 'major').length;
|
|
327
|
+
const description = (config.group_descriptions as Record<string, string> | undefined)?.[group.key];
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<GroupSection
|
|
331
|
+
key={group.key}
|
|
332
|
+
group={group}
|
|
333
|
+
sortedItems={sortedItems}
|
|
334
|
+
majorCount={majorCount}
|
|
335
|
+
description={description}
|
|
336
|
+
hasGroups={hasGroups}
|
|
337
|
+
config={config}
|
|
338
|
+
expandable={expandable}
|
|
339
|
+
CellRenderer={CellRenderer}
|
|
340
|
+
so={so}
|
|
341
|
+
isTarget={targetGroup === group.key}
|
|
342
|
+
onTargetConsumed={targetGroup === group.key ? onGroupConsumed : undefined}
|
|
343
|
+
/>
|
|
344
|
+
);
|
|
345
|
+
})}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Group Section ─────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
function GroupSection({
|
|
353
|
+
group,
|
|
354
|
+
sortedItems,
|
|
355
|
+
majorCount,
|
|
356
|
+
description,
|
|
357
|
+
hasGroups,
|
|
358
|
+
config,
|
|
359
|
+
expandable,
|
|
360
|
+
CellRenderer,
|
|
361
|
+
so,
|
|
362
|
+
isTarget,
|
|
363
|
+
onTargetConsumed,
|
|
364
|
+
}: {
|
|
365
|
+
group: Group;
|
|
366
|
+
sortedItems: Array<Record<string, unknown>>;
|
|
367
|
+
majorCount: number;
|
|
368
|
+
description: string | undefined;
|
|
369
|
+
hasGroups: boolean;
|
|
370
|
+
config: Record<string, unknown>;
|
|
371
|
+
expandable?: boolean;
|
|
372
|
+
CellRenderer: React.ComponentType<{ item: Record<string, unknown>; config: Record<string, unknown> }>;
|
|
373
|
+
so: StyleOverrides | undefined;
|
|
374
|
+
isTarget?: boolean;
|
|
375
|
+
onTargetConsumed?: () => void;
|
|
376
|
+
}) {
|
|
377
|
+
const groupRef = useRef<HTMLDivElement>(null);
|
|
378
|
+
const [highlight, setHighlight] = useState(false);
|
|
379
|
+
|
|
380
|
+
// Scroll-to + highlight when this is the target group
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!isTarget || !groupRef.current) return;
|
|
383
|
+
const el = groupRef.current;
|
|
384
|
+
// Small delay to ensure layout is settled
|
|
385
|
+
const timer = setTimeout(() => {
|
|
386
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
387
|
+
setHighlight(true);
|
|
388
|
+
// Remove highlight after animation
|
|
389
|
+
const fadeTimer = setTimeout(() => {
|
|
390
|
+
setHighlight(false);
|
|
391
|
+
onTargetConsumed?.();
|
|
392
|
+
}, 2000);
|
|
393
|
+
return () => clearTimeout(fadeTimer);
|
|
394
|
+
}, 300);
|
|
395
|
+
return () => clearTimeout(timer);
|
|
396
|
+
}, [isTarget, onTargetConsumed]);
|
|
397
|
+
|
|
398
|
+
// Derive subtle background tint from group color
|
|
399
|
+
const groupTintBg = hasGroups && group.style.text !== 'inherit'
|
|
400
|
+
? `${group.style.text}06`
|
|
401
|
+
: undefined;
|
|
402
|
+
|
|
403
|
+
const groupStyle: React.CSSProperties | undefined = groupTintBg
|
|
404
|
+
? {
|
|
405
|
+
background: groupTintBg,
|
|
406
|
+
borderRadius: 'var(--radius-lg, 12px)',
|
|
407
|
+
padding: 'var(--space-lg, 1.5rem)',
|
|
408
|
+
...(highlight ? { outline: '2px solid var(--dt-accent, #6366f1)', outlineOffset: '2px', transition: 'outline-color 0.5s' } : {}),
|
|
409
|
+
}
|
|
410
|
+
: (highlight ? { outline: '2px solid var(--dt-accent, #6366f1)', outlineOffset: '2px', transition: 'outline-color 0.5s' } : undefined);
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<div
|
|
414
|
+
ref={groupRef}
|
|
415
|
+
id={`group-${group.key}`}
|
|
416
|
+
className={hasGroups ? 'ar-grid-group ar-grid-group--enhanced' : ''}
|
|
417
|
+
style={groupStyle}
|
|
418
|
+
>
|
|
419
|
+
{hasGroups && (
|
|
420
|
+
<div
|
|
421
|
+
className="ar-grid-group-header ar-grid-group-header--enhanced"
|
|
422
|
+
style={{ '--group-accent': group.style.text, '--group-border': group.style.border } as React.CSSProperties}
|
|
423
|
+
>
|
|
424
|
+
<div className="ar-grid-group-rule" style={{ background: `linear-gradient(to right, ${group.style.text}, ${group.style.border}40)` }} />
|
|
425
|
+
<h3 style={so?.section_title || undefined}>
|
|
426
|
+
<span
|
|
427
|
+
className="ar-grid-group-badge"
|
|
428
|
+
style={{ background: group.style.bg, color: group.style.text, borderColor: group.style.border }}
|
|
429
|
+
>
|
|
430
|
+
{group.label}
|
|
431
|
+
</span>
|
|
432
|
+
<span className="ar-grid-group-count">
|
|
433
|
+
{group.items.length} item{group.items.length !== 1 ? 's' : ''}
|
|
434
|
+
</span>
|
|
435
|
+
{majorCount > 0 && (
|
|
436
|
+
<span className="ar-grid-major-pill">{majorCount} major</span>
|
|
437
|
+
)}
|
|
438
|
+
</h3>
|
|
439
|
+
{description && (
|
|
440
|
+
<p className="ar-grid-group-desc" style={so?.section_description || undefined}>{description}</p>
|
|
441
|
+
)}
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
<div className="ar-grid-cards ar-grid-cards--variable" style={so?.items_container || undefined}>
|
|
445
|
+
{sortedItems.map((item, idx) => {
|
|
446
|
+
const isHero = idx === 0 && sortedItems.length > 1;
|
|
447
|
+
const isLongContent = estimateContentLength(item) > 200;
|
|
448
|
+
const spanFull = isHero || isLongContent;
|
|
449
|
+
const isSingleCard = sortedItems.length === 1;
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<CardWrapper
|
|
453
|
+
key={String(item.id || item.docKey || idx)}
|
|
454
|
+
item={item}
|
|
455
|
+
config={config}
|
|
456
|
+
expandable={expandable}
|
|
457
|
+
CellRenderer={CellRenderer}
|
|
458
|
+
groupStyle={group.style}
|
|
459
|
+
isHero={spanFull}
|
|
460
|
+
isSingleCard={isSingleCard}
|
|
461
|
+
so={so}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
464
|
+
})}
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Card Wrapper (handles expansion, hero sizing, type indicators) ──
|
|
471
|
+
|
|
472
|
+
function CardWrapper({
|
|
473
|
+
item,
|
|
474
|
+
config,
|
|
475
|
+
expandable,
|
|
476
|
+
CellRenderer,
|
|
477
|
+
groupStyle,
|
|
478
|
+
isHero,
|
|
479
|
+
isSingleCard,
|
|
480
|
+
so,
|
|
481
|
+
}: {
|
|
482
|
+
item: Record<string, unknown>;
|
|
483
|
+
config: Record<string, unknown>;
|
|
484
|
+
expandable?: boolean;
|
|
485
|
+
CellRenderer: React.ComponentType<{ item: Record<string, unknown>; config: Record<string, unknown> }>;
|
|
486
|
+
groupStyle: { text: string; bg?: string; border?: string };
|
|
487
|
+
isHero: boolean;
|
|
488
|
+
isSingleCard: boolean;
|
|
489
|
+
so: StyleOverrides | undefined;
|
|
490
|
+
}) {
|
|
491
|
+
const { getSemanticColor, getLabel } = useDesignTokens();
|
|
492
|
+
const [expanded, setExpanded] = useState(false);
|
|
493
|
+
|
|
494
|
+
// Default cell renderer always gets expand/collapse (text is clamped by CSS)
|
|
495
|
+
const isDefaultCell = CellRenderer === DefaultCardCell;
|
|
496
|
+
const canExpand = expandable || isDefaultCell;
|
|
497
|
+
|
|
498
|
+
// Determine severity for type indicator dot
|
|
499
|
+
const severityKey = String(item.severity || '').toLowerCase();
|
|
500
|
+
const severityStyle = getSemanticColor('severity', severityKey);
|
|
501
|
+
|
|
502
|
+
// Card type from _category or type
|
|
503
|
+
const cardType = String(item._category || item.type || '');
|
|
504
|
+
|
|
505
|
+
// Build card class names
|
|
506
|
+
const cardClasses = [
|
|
507
|
+
'ar-grid-card',
|
|
508
|
+
'ar-grid-card--enhanced',
|
|
509
|
+
expanded ? 'expanded' : '',
|
|
510
|
+
isHero ? 'ar-grid-card--hero' : '',
|
|
511
|
+
isSingleCard ? 'ar-grid-card--single' : '',
|
|
512
|
+
].filter(Boolean).join(' ');
|
|
513
|
+
|
|
514
|
+
// Build inline styles: merge base with style overrides
|
|
515
|
+
const baseCardStyle: Record<string, string> = {
|
|
516
|
+
borderLeftColor: groupStyle.text,
|
|
517
|
+
cursor: canExpand ? 'pointer' : 'default',
|
|
518
|
+
};
|
|
519
|
+
const cardStyle = isHero
|
|
520
|
+
? { ...baseCardStyle, ...so?.hero_card, ...so?.card }
|
|
521
|
+
: { ...baseCardStyle, ...so?.card };
|
|
522
|
+
|
|
523
|
+
// Capture button (rendered outside JSX to avoid TS2746)
|
|
524
|
+
const captureBtn = config._captureMode && config._onCapture ? (
|
|
525
|
+
<button
|
|
526
|
+
key="capture-btn"
|
|
527
|
+
title="Capture this card"
|
|
528
|
+
onClick={e => {
|
|
529
|
+
e.stopPropagation();
|
|
530
|
+
const onCap = config._onCapture as (sel: CaptureSelection) => void;
|
|
531
|
+
const title = String(item.title || item.name || item.id || '');
|
|
532
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
533
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
534
|
+
onCap({
|
|
535
|
+
source_view_key: String(config._captureViewKey || ''),
|
|
536
|
+
source_item_index: undefined,
|
|
537
|
+
source_renderer_type: 'card_grid',
|
|
538
|
+
content_type: 'card',
|
|
539
|
+
selected_text: (item.summary || item.analysis || item.description || JSON.stringify(item)).toString().slice(0, 500),
|
|
540
|
+
structured_data: item,
|
|
541
|
+
context_title: parentSectionKey
|
|
542
|
+
? `${config._captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${title}`
|
|
543
|
+
: `${config._captureViewKey || 'Analysis'} > ${title}`,
|
|
544
|
+
source_type: ((config._captureSourceType as string) || 'analysis') as string,
|
|
545
|
+
entity_id: String(config._captureEntityId || config._captureJobId || ''),
|
|
546
|
+
depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
|
|
547
|
+
parent_context: parentSectionKey ? {
|
|
548
|
+
section_key: parentSectionKey,
|
|
549
|
+
section_title: parentSectionTitle || '',
|
|
550
|
+
} : undefined,
|
|
551
|
+
});
|
|
552
|
+
}}
|
|
553
|
+
style={{
|
|
554
|
+
position: 'absolute' as const,
|
|
555
|
+
top: '6px',
|
|
556
|
+
right: '6px',
|
|
557
|
+
background: 'rgba(255,255,255,0.9)',
|
|
558
|
+
border: '1px solid #ccc',
|
|
559
|
+
borderRadius: '4px',
|
|
560
|
+
cursor: 'pointer',
|
|
561
|
+
padding: '2px 5px',
|
|
562
|
+
fontSize: '0.7rem',
|
|
563
|
+
lineHeight: 1,
|
|
564
|
+
zIndex: 2,
|
|
565
|
+
}}
|
|
566
|
+
>
|
|
567
|
+
📌
|
|
568
|
+
</button>
|
|
569
|
+
) : null;
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
<div
|
|
573
|
+
className={cardClasses}
|
|
574
|
+
style={cardStyle}
|
|
575
|
+
onClick={canExpand ? () => setExpanded(!expanded) : undefined}
|
|
576
|
+
>
|
|
577
|
+
{captureBtn}
|
|
578
|
+
{/* Type indicator dot */}
|
|
579
|
+
{(severityStyle || cardType) && (
|
|
580
|
+
<div className="ar-card-type-indicator">
|
|
581
|
+
{severityStyle && (
|
|
582
|
+
<span
|
|
583
|
+
className="ar-card-type-dot"
|
|
584
|
+
style={{ background: severityStyle.text }}
|
|
585
|
+
title={`Severity: ${severityKey}`}
|
|
586
|
+
/>
|
|
587
|
+
)}
|
|
588
|
+
{cardType && (
|
|
589
|
+
<span className="ar-card-type-label" style={so?.badge || undefined}>
|
|
590
|
+
{getLabel('tactic', cardType) || cardType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
|
|
591
|
+
</span>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
)}
|
|
595
|
+
|
|
596
|
+
{/* Card content from cell renderer */}
|
|
597
|
+
<div className="ar-card-content" style={so?.card_body || undefined}>
|
|
598
|
+
<CellRenderer item={item} config={config} />
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
{canExpand && (
|
|
602
|
+
<div className="card-cell-expand-hint">
|
|
603
|
+
{expanded ? 'click to collapse' : 'click to expand'}
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
);
|
|
608
|
+
}
|