@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,3487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubRenderers — Section-level rendering components for accordion sections.
|
|
3
|
+
*
|
|
4
|
+
* These are small, focused renderers that handle one section's data within
|
|
5
|
+
* an accordion. The AccordionRenderer dispatches to these based on
|
|
6
|
+
* config.section_renderers[sectionKey].renderer_type.
|
|
7
|
+
*
|
|
8
|
+
* Available sub-renderers:
|
|
9
|
+
* chip_grid — Array of strings/objects → weighted chip cloud
|
|
10
|
+
* definition_list — Array of "Term: Definition" strings or objects → glossary layout
|
|
11
|
+
* mini_card_list — Array of objects → hero + grid insight cards
|
|
12
|
+
* key_value_table — Object or [{key, value}] → styled two-column table
|
|
13
|
+
* prose_block — String → formatted analysis with lede, blockquotes
|
|
14
|
+
* stat_row — Object → monospace stat cards
|
|
15
|
+
* comparison_panel — Array of objects → side-by-side comparison with headers
|
|
16
|
+
* timeline_strip — Array of objects with stages → evolution arc with progression
|
|
17
|
+
* evidence_trail — Vertical chain of evidence steps with dot markers and connectors
|
|
18
|
+
* ordered_flow — Ordered sequence of content units with connecting line and category badges
|
|
19
|
+
* intensity_matrix — Dashboard rows with horizontal intensity bars for quantitative dimensions
|
|
20
|
+
* move_repertoire — Grouped card list with collapsible category headers and count badges
|
|
21
|
+
* dialectical_pair — Two-panel tension visualization for thesis/antithesis contrasts
|
|
22
|
+
* rich_description_list — Stacked items with colored borders for paragraph-length descriptions
|
|
23
|
+
* phase_timeline — Connected timeline with prominent phase nodes for temporal data
|
|
24
|
+
* distribution_summary — Visual bar chart with dominant highlight, counts, and optional narrative
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import React, { useState } from 'react';
|
|
28
|
+
import { EvidenceTrailSubRenderer } from '../components/EvidenceTrail';
|
|
29
|
+
import { EnableConditionsSubRenderer, ConstrainConditionsSubRenderer } from '../components/ConditionCards';
|
|
30
|
+
import { StyleOverrides, getSO } from '../types/styles';
|
|
31
|
+
import { useDesignTokens } from '../tokens/DesignTokenContext';
|
|
32
|
+
import type { SubRendererProps } from '../types';
|
|
33
|
+
// CaptureSelection is passed via config._onCapture — no direct type import needed
|
|
34
|
+
type CaptureSelection = Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
// Re-export SubRendererProps for backward compat
|
|
37
|
+
export type { SubRendererProps } from '../types';
|
|
38
|
+
|
|
39
|
+
// ── Registry ─────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const SUB_RENDERER_MAP: Record<string, React.FC<SubRendererProps>> = {
|
|
42
|
+
chip_grid: ChipGrid,
|
|
43
|
+
definition_list: DefinitionList,
|
|
44
|
+
mini_card_list: MiniCardList,
|
|
45
|
+
key_value_table: KeyValueTable,
|
|
46
|
+
prose_block: ProseBlock,
|
|
47
|
+
stat_row: StatRow,
|
|
48
|
+
comparison_panel: ComparisonPanel,
|
|
49
|
+
timeline_strip: TimelineStrip,
|
|
50
|
+
evidence_trail: EvidenceTrailSubRenderer,
|
|
51
|
+
enabling_conditions: EnableConditionsSubRenderer,
|
|
52
|
+
constraining_conditions: ConstrainConditionsSubRenderer,
|
|
53
|
+
ordered_flow: OrderedFlow,
|
|
54
|
+
intensity_matrix: IntensityMatrix,
|
|
55
|
+
move_repertoire: MoveRepertoire,
|
|
56
|
+
grouped_card_list: MoveRepertoire, // alias for generic usage
|
|
57
|
+
dialectical_pair: DialecticalPair,
|
|
58
|
+
rich_description_list: RichDescriptionList,
|
|
59
|
+
phase_timeline: PhaseTimeline,
|
|
60
|
+
distribution_summary: DistributionSummary,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function resolveSubRenderer(rendererType: string): React.FC<SubRendererProps> | null {
|
|
64
|
+
return SUB_RENDERER_MAP[rendererType] || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Auto-detect the best sub-renderer for a data shape.
|
|
69
|
+
* Used by AccordionRenderer when no section_renderer is explicitly configured.
|
|
70
|
+
*
|
|
71
|
+
* Decision tree:
|
|
72
|
+
* string → prose_block
|
|
73
|
+
* string[] → chip_grid
|
|
74
|
+
* object[] with nested array fields → timeline_strip (concept evolution, stage progressions)
|
|
75
|
+
* object[] with title+description → mini_card_list
|
|
76
|
+
* object[] short items (≤3 fields, all short) → chip_grid
|
|
77
|
+
* flat object (no nested arrays) → key_value_table
|
|
78
|
+
* object with only numeric values → stat_row
|
|
79
|
+
*/
|
|
80
|
+
export function autoDetectSubRenderer(data: unknown): string | null {
|
|
81
|
+
if (data === null || data === undefined) return null;
|
|
82
|
+
|
|
83
|
+
if (typeof data === 'string') return 'prose_block';
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
86
|
+
if (data.every(d => typeof d === 'string')) {
|
|
87
|
+
// Check if strings look like "Term: Definition" → use definition_list
|
|
88
|
+
const strs = data as string[];
|
|
89
|
+
const defCount = strs.filter(s => {
|
|
90
|
+
const colonIdx = s.indexOf(':');
|
|
91
|
+
// Term before colon (1-60 chars), definition after colon (10+ chars)
|
|
92
|
+
return colonIdx > 1 && colonIdx < 60 && s.length > colonIdx + 10;
|
|
93
|
+
}).length;
|
|
94
|
+
if (defCount >= strs.length * 0.5) return 'definition_list';
|
|
95
|
+
return 'chip_grid';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
|
|
99
|
+
if (firstObj) {
|
|
100
|
+
const entries = Object.entries(firstObj);
|
|
101
|
+
const hasArrayField = entries.some(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
|
|
102
|
+
const fieldCount = entries.length;
|
|
103
|
+
const shortStringCount = entries.filter(([, v]) => typeof v === 'string' && (v as string).length < 60).length;
|
|
104
|
+
const longStringCount = entries.filter(([, v]) => typeof v === 'string' && (v as string).length >= 60).length;
|
|
105
|
+
|
|
106
|
+
if (hasArrayField) return 'timeline_strip';
|
|
107
|
+
if (fieldCount <= 3 && shortStringCount >= fieldCount - 1 && longStringCount === 0) return 'chip_grid';
|
|
108
|
+
if (longStringCount > 0) return 'mini_card_list';
|
|
109
|
+
return 'mini_card_list';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof data === 'object' && !Array.isArray(data)) {
|
|
114
|
+
const obj = data as Record<string, unknown>;
|
|
115
|
+
const entries = Object.entries(obj).filter(([, v]) => v !== null && v !== undefined);
|
|
116
|
+
if (entries.length > 0 && entries.every(([, v]) => typeof v === 'number')) return 'stat_row';
|
|
117
|
+
const allScalar = entries.every(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean');
|
|
118
|
+
if (allScalar) return 'key_value_table';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function getField(obj: Record<string, unknown>, field: string | undefined): string {
|
|
127
|
+
if (!field) return '';
|
|
128
|
+
const val = obj[field];
|
|
129
|
+
if (val === null || val === undefined) return '';
|
|
130
|
+
return String(val);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getNumField(obj: Record<string, unknown>, field: string | undefined): number | null {
|
|
134
|
+
if (!field) return null;
|
|
135
|
+
const val = obj[field];
|
|
136
|
+
if (typeof val === 'number') return val;
|
|
137
|
+
if (typeof val === 'string') {
|
|
138
|
+
const n = parseFloat(val);
|
|
139
|
+
return isNaN(n) ? null : n;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface AutoFields {
|
|
145
|
+
title?: string;
|
|
146
|
+
subtitle?: string;
|
|
147
|
+
description?: string;
|
|
148
|
+
badge?: string;
|
|
149
|
+
label?: string;
|
|
150
|
+
count?: string;
|
|
151
|
+
key?: string;
|
|
152
|
+
value?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const TITLE_HINTS = ['name', 'title', 'label', 'term', 'concept', 'framework_name', 'heading'];
|
|
156
|
+
const SUBTITLE_HINTS = ['type', 'category', 'kind', 'centrality', 'status', 'role', 'level'];
|
|
157
|
+
const DESC_HINTS = ['description', 'summary', 'definition', 'explanation', 'text', 'content', 'methodological_signature'];
|
|
158
|
+
const KEY_HINTS = ['key', 'term', 'concept', 'name', 'label'];
|
|
159
|
+
const VALUE_HINTS = ['value', 'definition', 'meaning', 'description', 'explanation'];
|
|
160
|
+
|
|
161
|
+
function autoDetectFields(sample: Record<string, unknown>): AutoFields {
|
|
162
|
+
const result: AutoFields = {};
|
|
163
|
+
const entries = Object.entries(sample);
|
|
164
|
+
|
|
165
|
+
const shortStrings: string[] = [];
|
|
166
|
+
const longStrings: string[] = [];
|
|
167
|
+
const numericFields: string[] = [];
|
|
168
|
+
|
|
169
|
+
for (const [k, v] of entries) {
|
|
170
|
+
if (typeof v === 'string') {
|
|
171
|
+
if (v.length > 80) longStrings.push(k);
|
|
172
|
+
else shortStrings.push(k);
|
|
173
|
+
} else if (typeof v === 'number') {
|
|
174
|
+
numericFields.push(k);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
result.title = shortStrings.find(k => TITLE_HINTS.includes(k)) || shortStrings[0];
|
|
179
|
+
result.label = result.title;
|
|
180
|
+
|
|
181
|
+
const remaining = shortStrings.filter(k => k !== result.title);
|
|
182
|
+
result.subtitle = remaining.find(k => SUBTITLE_HINTS.includes(k)) || remaining[0];
|
|
183
|
+
|
|
184
|
+
result.description = longStrings.find(k => DESC_HINTS.includes(k)) || longStrings[0];
|
|
185
|
+
|
|
186
|
+
result.badge = numericFields[0];
|
|
187
|
+
result.count = numericFields[0];
|
|
188
|
+
|
|
189
|
+
result.key = shortStrings.find(k => KEY_HINTS.includes(k)) || shortStrings[0];
|
|
190
|
+
const valCandidates = [...longStrings, ...shortStrings.filter(k => k !== result.key)];
|
|
191
|
+
result.value = valCandidates.find(k => VALUE_HINTS.includes(k)) || valCandidates[0];
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveField(config: Record<string, unknown>, configKey: string, auto: AutoFields, autoKey: keyof AutoFields): string | undefined {
|
|
197
|
+
return (config[configKey] as string | undefined) || auto[autoKey];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Color Utilities ──────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
function parseAccentHSL(hex: string | undefined): { h: number; s: number; l: number } {
|
|
203
|
+
if (!hex || !hex.startsWith('#') || hex.length < 7) return { h: 220, s: 55, l: 45 };
|
|
204
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
205
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
206
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
207
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
208
|
+
let h = 0, s = 0;
|
|
209
|
+
const l = (max + min) / 2;
|
|
210
|
+
if (max !== min) {
|
|
211
|
+
const d = max - min;
|
|
212
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
213
|
+
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
214
|
+
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
215
|
+
else h = ((r - g) / d + 4) / 6;
|
|
216
|
+
}
|
|
217
|
+
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Render inline markdown: **bold** → accent-underlined bold, *italic* → em */
|
|
221
|
+
function renderInlineMarkdown(text: string, accentColor: string): React.ReactNode[] {
|
|
222
|
+
const parts: React.ReactNode[] = [];
|
|
223
|
+
let lastIndex = 0;
|
|
224
|
+
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*)/g;
|
|
225
|
+
let match;
|
|
226
|
+
while ((match = regex.exec(text)) !== null) {
|
|
227
|
+
if (match.index > lastIndex) {
|
|
228
|
+
parts.push(text.slice(lastIndex, match.index));
|
|
229
|
+
}
|
|
230
|
+
if (match[2]) {
|
|
231
|
+
parts.push(
|
|
232
|
+
<strong key={match.index} style={{
|
|
233
|
+
fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
|
|
234
|
+
textDecoration: 'underline',
|
|
235
|
+
textDecorationColor: accentColor,
|
|
236
|
+
textUnderlineOffset: '3px',
|
|
237
|
+
textDecorationThickness: '2px',
|
|
238
|
+
}}>
|
|
239
|
+
{match[2]}
|
|
240
|
+
</strong>
|
|
241
|
+
);
|
|
242
|
+
} else if (match[3]) {
|
|
243
|
+
parts.push(<em key={match.index}>{match[3]}</em>);
|
|
244
|
+
}
|
|
245
|
+
lastIndex = match.index + match[0].length;
|
|
246
|
+
}
|
|
247
|
+
if (lastIndex < text.length) {
|
|
248
|
+
parts.push(text.slice(lastIndex));
|
|
249
|
+
}
|
|
250
|
+
return parts.length > 0 ? parts : [text];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── ChipGrid ─────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function ChipGrid({ data, config }: SubRendererProps) {
|
|
256
|
+
const [expandedIdx, setExpandedIdx] = React.useState<number | null>(null);
|
|
257
|
+
const { getChipWeight, tokens } = useDesignTokens();
|
|
258
|
+
const so = getSO(config);
|
|
259
|
+
|
|
260
|
+
if (!data || !Array.isArray(data)) return null;
|
|
261
|
+
|
|
262
|
+
const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
|
|
263
|
+
const auto = firstObj ? autoDetectFields(firstObj) : {};
|
|
264
|
+
const labelField = resolveField(config, 'label_field', auto, 'label');
|
|
265
|
+
const countField = resolveField(config, 'count_field', auto, 'count');
|
|
266
|
+
const subtitleField = resolveField(config, 'subtitle_field', auto, 'subtitle');
|
|
267
|
+
const descField = resolveField(config, 'description_field', auto, 'description');
|
|
268
|
+
|
|
269
|
+
const hasDetails = firstObj && (
|
|
270
|
+
(descField && getField(firstObj, descField).length > 0) ||
|
|
271
|
+
Object.values(firstObj).some(v => Array.isArray(v))
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Collect numeric values for size variation
|
|
275
|
+
const numericValues: number[] = [];
|
|
276
|
+
if (countField) {
|
|
277
|
+
data.forEach(item => {
|
|
278
|
+
if (typeof item === 'object' && item !== null) {
|
|
279
|
+
const n = getNumField(item as Record<string, unknown>, countField);
|
|
280
|
+
if (n !== null) numericValues.push(n);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const hasNumeric = numericValues.length > 0;
|
|
285
|
+
const minVal = hasNumeric ? Math.min(...numericValues) : 0;
|
|
286
|
+
const maxVal = hasNumeric ? Math.max(...numericValues) : 1;
|
|
287
|
+
const valRange = maxVal - minVal || 1;
|
|
288
|
+
|
|
289
|
+
// Build chip items with weight for sorting
|
|
290
|
+
const chipItems = data.map((item, i) => {
|
|
291
|
+
const label = typeof item === 'string'
|
|
292
|
+
? item
|
|
293
|
+
: typeof item === 'object' && item !== null
|
|
294
|
+
? getField(item as Record<string, unknown>, labelField)
|
|
295
|
+
|| String(Object.values(item as Record<string, unknown>).find(v => typeof v === 'string' && (v as string).length < 80) || `Item ${i + 1}`)
|
|
296
|
+
: String(item);
|
|
297
|
+
|
|
298
|
+
const subtitle = typeof item === 'object' && item !== null && subtitleField
|
|
299
|
+
? getField(item as Record<string, unknown>, subtitleField)
|
|
300
|
+
: '';
|
|
301
|
+
|
|
302
|
+
const count = typeof item === 'object' && item !== null && countField
|
|
303
|
+
? getNumField(item as Record<string, unknown>, countField)
|
|
304
|
+
: null;
|
|
305
|
+
|
|
306
|
+
const weight = hasNumeric && count !== null
|
|
307
|
+
? (count - minVal) / valRange
|
|
308
|
+
: 0.5;
|
|
309
|
+
|
|
310
|
+
return { item, label, subtitle, count, weight, originalIndex: i };
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Sort by weight descending for weighted cloud layout
|
|
314
|
+
const sortedChips = [...chipItems].sort((a, b) => b.weight - a.weight);
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<div>
|
|
318
|
+
<div style={{
|
|
319
|
+
display: 'flex', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap',
|
|
320
|
+
padding: 'var(--space-md, 0.75rem)',
|
|
321
|
+
backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
|
|
322
|
+
borderRadius: 'var(--radius-lg, 12px)',
|
|
323
|
+
border: '1px solid var(--color-border-light, #eef0f2)',
|
|
324
|
+
...so?.items_container,
|
|
325
|
+
}}>
|
|
326
|
+
{sortedChips.map(({ item, label, subtitle, count, weight, originalIndex }) => {
|
|
327
|
+
const chipColors = getChipWeight(weight);
|
|
328
|
+
const colors = { ...chipColors, headerBg: tokens.components.chip_header_bg, headerText: tokens.components.chip_header_text };
|
|
329
|
+
const isExpanded = expandedIdx === originalIndex;
|
|
330
|
+
const isClickable = hasDetails && typeof item === 'object' && item !== null;
|
|
331
|
+
|
|
332
|
+
// Size variation: large chips (weight > 0.7) get bigger padding/font
|
|
333
|
+
const sizeClass = weight > 0.7 ? 'large' : weight > 0.3 ? 'medium' : 'small';
|
|
334
|
+
const padH = sizeClass === 'large' ? '18px' : sizeClass === 'medium' ? '14px' : '10px';
|
|
335
|
+
const padV = sizeClass === 'large' ? '8px' : sizeClass === 'medium' ? '6px' : '5px';
|
|
336
|
+
const fontSize = sizeClass === 'large'
|
|
337
|
+
? 'var(--type-body, 0.9375rem)'
|
|
338
|
+
: sizeClass === 'medium'
|
|
339
|
+
? 'var(--type-caption, 0.8125rem)'
|
|
340
|
+
: 'var(--type-label, 0.6875rem)';
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<span
|
|
344
|
+
key={originalIndex}
|
|
345
|
+
onClick={isClickable ? () => setExpandedIdx(isExpanded ? null : originalIndex) : undefined}
|
|
346
|
+
title={isClickable ? 'Click to expand details' : undefined}
|
|
347
|
+
style={{
|
|
348
|
+
display: 'inline-flex', alignItems: 'center',
|
|
349
|
+
gap: 'var(--space-xs, 0.25rem)',
|
|
350
|
+
padding: `${padV} ${padH}`,
|
|
351
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
352
|
+
fontSize,
|
|
353
|
+
fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
|
|
354
|
+
backgroundColor: isExpanded ? colors.headerBg : colors.bg,
|
|
355
|
+
color: isExpanded ? colors.headerText : colors.text,
|
|
356
|
+
border: `1.5px solid ${isExpanded ? colors.headerBg : colors.border}`,
|
|
357
|
+
boxShadow: isExpanded
|
|
358
|
+
? 'var(--shadow-md, 0 4px 6px rgba(0,0,0,0.05))'
|
|
359
|
+
: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
|
|
360
|
+
cursor: isClickable ? 'pointer' : 'default',
|
|
361
|
+
transition: `all var(--duration-fast, 150ms) var(--ease-out, ease)`,
|
|
362
|
+
...so?.chip,
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
<span style={so?.chip_label}>{label}</span>
|
|
366
|
+
{subtitle && (
|
|
367
|
+
<span style={{
|
|
368
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
369
|
+
fontWeight: 'var(--weight-medium, 500)' as unknown as number,
|
|
370
|
+
padding: '1px 7px', borderRadius: 'var(--radius-pill, 9999px)',
|
|
371
|
+
backgroundColor: isExpanded ? 'rgba(255,255,255,0.25)' : colors.headerBg,
|
|
372
|
+
color: colors.headerText,
|
|
373
|
+
letterSpacing: '0.02em',
|
|
374
|
+
...so?.badge,
|
|
375
|
+
}}>{subtitle}</span>
|
|
376
|
+
)}
|
|
377
|
+
{count !== null && (
|
|
378
|
+
<span style={{
|
|
379
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
380
|
+
fontWeight: 'var(--weight-bold, 700)' as unknown as number,
|
|
381
|
+
backgroundColor: isExpanded ? 'rgba(255,255,255,0.25)' : colors.headerBg,
|
|
382
|
+
color: colors.headerText,
|
|
383
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
384
|
+
padding: '1px 6px',
|
|
385
|
+
...so?.badge,
|
|
386
|
+
}}>
|
|
387
|
+
{count}
|
|
388
|
+
</span>
|
|
389
|
+
)}
|
|
390
|
+
</span>
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
{/* Expanded detail — inline card */}
|
|
396
|
+
{expandedIdx !== null && typeof data[expandedIdx] === 'object' && data[expandedIdx] !== null && (() => {
|
|
397
|
+
const obj = data[expandedIdx] as Record<string, unknown>;
|
|
398
|
+
const label = getField(obj, labelField);
|
|
399
|
+
const subtitle = getField(obj, subtitleField);
|
|
400
|
+
const desc = descField ? getField(obj, descField) : '';
|
|
401
|
+
const expandChipColors = getChipWeight(0.6);
|
|
402
|
+
const colors = { ...expandChipColors, headerBg: tokens.components.chip_header_bg, headerText: tokens.components.chip_header_text };
|
|
403
|
+
|
|
404
|
+
const skipKeys = new Set([labelField, subtitleField, descField].filter(Boolean) as string[]);
|
|
405
|
+
const remaining = Object.entries(obj).filter(([k, v]) => !skipKeys.has(k) && v !== null && v !== undefined && v !== '');
|
|
406
|
+
const arrayFields = remaining.filter(([, v]) => Array.isArray(v));
|
|
407
|
+
const scalarFields = remaining.filter(([, v]) => !Array.isArray(v) && typeof v === 'string' && (v as string).length > 40);
|
|
408
|
+
const shortFields = remaining.filter(([, v]) => !Array.isArray(v) && (typeof v !== 'string' || (v as string).length <= 40));
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<div style={{
|
|
412
|
+
margin: 'var(--space-sm, 0.5rem) 0',
|
|
413
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
414
|
+
overflow: 'hidden',
|
|
415
|
+
border: `1.5px solid ${colors.border}`,
|
|
416
|
+
boxShadow: 'var(--shadow-md, 0 4px 6px rgba(0,0,0,0.05))',
|
|
417
|
+
...so?.chip_expanded,
|
|
418
|
+
}}>
|
|
419
|
+
{/* Card header */}
|
|
420
|
+
<div style={{
|
|
421
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
422
|
+
backgroundColor: colors.headerBg,
|
|
423
|
+
display: 'flex', alignItems: 'center', gap: 'var(--space-sm, 0.5rem)',
|
|
424
|
+
}}>
|
|
425
|
+
<strong style={{
|
|
426
|
+
fontSize: 'var(--type-subheading, 1.125rem)',
|
|
427
|
+
color: colors.headerText,
|
|
428
|
+
}}>{label}</strong>
|
|
429
|
+
{subtitle && (
|
|
430
|
+
<span style={{
|
|
431
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
432
|
+
fontWeight: 'var(--weight-medium, 500)' as unknown as number,
|
|
433
|
+
padding: '2px 8px', borderRadius: 'var(--radius-pill, 9999px)',
|
|
434
|
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
435
|
+
color: colors.headerText,
|
|
436
|
+
letterSpacing: '0.02em',
|
|
437
|
+
}}>{subtitle}</span>
|
|
438
|
+
)}
|
|
439
|
+
<span
|
|
440
|
+
onClick={() => setExpandedIdx(null)}
|
|
441
|
+
style={{
|
|
442
|
+
marginLeft: 'auto', cursor: 'pointer',
|
|
443
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
444
|
+
color: 'rgba(255,255,255,0.7)',
|
|
445
|
+
padding: '2px 8px', borderRadius: 'var(--radius-sm, 4px)',
|
|
446
|
+
transition: `opacity var(--duration-fast, 150ms)`,
|
|
447
|
+
}}
|
|
448
|
+
>close</span>
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
{/* Card body */}
|
|
452
|
+
<div style={{
|
|
453
|
+
padding: 'var(--space-md, 1rem)',
|
|
454
|
+
backgroundColor: colors.bg,
|
|
455
|
+
}}>
|
|
456
|
+
{desc && (
|
|
457
|
+
<p style={{
|
|
458
|
+
margin: '0 0 var(--space-sm, 0.5rem) 0',
|
|
459
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
460
|
+
color: 'var(--color-text, #1a1d23)',
|
|
461
|
+
lineHeight: 'var(--leading-relaxed, 1.65)',
|
|
462
|
+
}}>{desc}</p>
|
|
463
|
+
)}
|
|
464
|
+
|
|
465
|
+
{shortFields.length > 0 && (
|
|
466
|
+
<div style={{
|
|
467
|
+
display: 'flex', gap: 'var(--space-md, 0.75rem)', flexWrap: 'wrap',
|
|
468
|
+
marginBottom: arrayFields.length > 0 || scalarFields.length > 0 ? 'var(--space-sm, 0.5rem)' : 0,
|
|
469
|
+
}}>
|
|
470
|
+
{shortFields.map(([k, v]) => (
|
|
471
|
+
<span key={k} style={{ fontSize: 'var(--type-caption, 0.8125rem)', color: 'var(--color-text-muted, #6b7280)' }}>
|
|
472
|
+
<span className="gen-inline-label">
|
|
473
|
+
{k.replace(/_/g, ' ')}:
|
|
474
|
+
</span>{' '}
|
|
475
|
+
{String(v)}
|
|
476
|
+
</span>
|
|
477
|
+
))}
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
{scalarFields.map(([k, v]) => (
|
|
482
|
+
<div key={k} style={{ marginBottom: 'var(--space-xs, 0.375rem)' }}>
|
|
483
|
+
<span className="gen-inline-label" style={{ display: 'block', marginBottom: 'var(--space-2xs, 0.125rem)' }}>
|
|
484
|
+
{k.replace(/_/g, ' ')}
|
|
485
|
+
</span>
|
|
486
|
+
<span style={{
|
|
487
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
488
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
489
|
+
lineHeight: 'var(--leading-normal, 1.5)',
|
|
490
|
+
}}>{String(v)}</span>
|
|
491
|
+
</div>
|
|
492
|
+
))}
|
|
493
|
+
|
|
494
|
+
{arrayFields.map(([k, v]) => (
|
|
495
|
+
<div key={k} style={{ marginTop: 'var(--space-xs, 0.375rem)' }}>
|
|
496
|
+
<span className="gen-inline-label" style={{ marginRight: 'var(--space-xs, 0.375rem)' }}>
|
|
497
|
+
{k.replace(/_/g, ' ')}:
|
|
498
|
+
</span>
|
|
499
|
+
<span style={{ display: 'inline-flex', gap: 'var(--space-xs, 0.25rem)', flexWrap: 'wrap' }}>
|
|
500
|
+
{(v as unknown[]).map((chip, ci) => (
|
|
501
|
+
<span key={ci} className="gen-keyword-tag">{String(chip)}</span>
|
|
502
|
+
))}
|
|
503
|
+
</span>
|
|
504
|
+
</div>
|
|
505
|
+
))}
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
);
|
|
509
|
+
})()}
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── DefinitionList → Glossary/Vocabulary ─────────────────
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Purpose-built renderer for glossary/vocabulary data.
|
|
518
|
+
* Handles:
|
|
519
|
+
* - Array of "Term: Definition" strings (splits on first colon)
|
|
520
|
+
* - Array of objects with term/definition fields
|
|
521
|
+
* Renders as a visually rich definition list with:
|
|
522
|
+
* - Prominent term styling with accent color
|
|
523
|
+
* - Clear definition text
|
|
524
|
+
* - Alternating subtle backgrounds
|
|
525
|
+
* - Compact, scannable layout
|
|
526
|
+
*/
|
|
527
|
+
function DefinitionList({ data, config }: SubRendererProps) {
|
|
528
|
+
const [expandedIdx, setExpandedIdx] = React.useState<number | null>(null);
|
|
529
|
+
const { tokens } = useDesignTokens();
|
|
530
|
+
const so = getSO(config);
|
|
531
|
+
|
|
532
|
+
// Capture mode support (threaded from AccordionRenderer)
|
|
533
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
534
|
+
const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
|
|
535
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
536
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
537
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
538
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
539
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
540
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
541
|
+
|
|
542
|
+
if (!data || !Array.isArray(data) || data.length === 0) return null;
|
|
543
|
+
|
|
544
|
+
// Parse items into {term, definition} pairs
|
|
545
|
+
const termField = config.term_field as string | undefined;
|
|
546
|
+
const defField = config.definition_field as string | undefined;
|
|
547
|
+
|
|
548
|
+
const items = data.map((item, i) => {
|
|
549
|
+
if (typeof item === 'string') {
|
|
550
|
+
// Split "Term: Definition" on first colon
|
|
551
|
+
const colonIdx = item.indexOf(':');
|
|
552
|
+
if (colonIdx > 0 && colonIdx < 80) {
|
|
553
|
+
return {
|
|
554
|
+
term: item.slice(0, colonIdx).trim(),
|
|
555
|
+
definition: item.slice(colonIdx + 1).trim(),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
return { term: `Entry ${i + 1}`, definition: item };
|
|
559
|
+
}
|
|
560
|
+
if (typeof item === 'object' && item !== null) {
|
|
561
|
+
const obj = item as Record<string, unknown>;
|
|
562
|
+
const t = getField(obj, termField) || getField(obj, 'term') || getField(obj, 'name') || getField(obj, 'concept') || getField(obj, 'label') || '';
|
|
563
|
+
const d = getField(obj, defField) || getField(obj, 'definition') || getField(obj, 'description') || getField(obj, 'meaning') || '';
|
|
564
|
+
return { term: t || `Entry ${i + 1}`, definition: d || JSON.stringify(obj) };
|
|
565
|
+
}
|
|
566
|
+
return { term: `Entry ${i + 1}`, definition: String(item) };
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Color rotation for visual variety using series palette
|
|
570
|
+
const palette = tokens.primitives.series_palette;
|
|
571
|
+
const termColor = (idx: number) => {
|
|
572
|
+
const color = palette[idx % palette.length];
|
|
573
|
+
return {
|
|
574
|
+
termBg: color,
|
|
575
|
+
termText: 'var(--dt-text-inverse)',
|
|
576
|
+
dotColor: color,
|
|
577
|
+
hoverBg: tokens.surfaces.surface_alt,
|
|
578
|
+
};
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<div style={{
|
|
583
|
+
display: 'flex',
|
|
584
|
+
flexDirection: 'column',
|
|
585
|
+
gap: '2px',
|
|
586
|
+
borderRadius: 'var(--radius-lg, 12px)',
|
|
587
|
+
overflow: 'hidden',
|
|
588
|
+
border: '1px solid var(--color-border-light, #eef0f2)',
|
|
589
|
+
boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
|
|
590
|
+
...so?.items_container,
|
|
591
|
+
}}>
|
|
592
|
+
{items.map((item, i) => {
|
|
593
|
+
const colors = termColor(i);
|
|
594
|
+
const isExpanded = expandedIdx === i;
|
|
595
|
+
const isLong = item.definition.length > 180;
|
|
596
|
+
const displayDef = !isLong || isExpanded
|
|
597
|
+
? item.definition
|
|
598
|
+
: item.definition.slice(0, 180) + '...';
|
|
599
|
+
|
|
600
|
+
return (
|
|
601
|
+
<div
|
|
602
|
+
key={i}
|
|
603
|
+
onClick={isLong ? () => setExpandedIdx(isExpanded ? null : i) : undefined}
|
|
604
|
+
style={{
|
|
605
|
+
display: 'grid',
|
|
606
|
+
gridTemplateColumns: 'auto 1fr',
|
|
607
|
+
gap: 0,
|
|
608
|
+
backgroundColor: i % 2 === 0
|
|
609
|
+
? 'var(--color-surface, #ffffff)'
|
|
610
|
+
: 'var(--color-surface-alt, #f8f9fa)',
|
|
611
|
+
cursor: isLong ? 'pointer' : 'default',
|
|
612
|
+
transition: 'background-color 150ms ease',
|
|
613
|
+
}}
|
|
614
|
+
>
|
|
615
|
+
{/* Term column */}
|
|
616
|
+
<div style={{
|
|
617
|
+
padding: '10px 14px',
|
|
618
|
+
display: 'flex',
|
|
619
|
+
alignItems: 'flex-start',
|
|
620
|
+
gap: '8px',
|
|
621
|
+
minWidth: '200px',
|
|
622
|
+
maxWidth: '280px',
|
|
623
|
+
borderRight: `3px solid ${colors.dotColor}`,
|
|
624
|
+
backgroundColor: i % 2 === 0 ? tokens.surfaces.surface_alt : tokens.surfaces.surface_inset,
|
|
625
|
+
}}>
|
|
626
|
+
<span style={{
|
|
627
|
+
display: 'inline-block',
|
|
628
|
+
width: '7px',
|
|
629
|
+
height: '7px',
|
|
630
|
+
borderRadius: '50%',
|
|
631
|
+
backgroundColor: colors.dotColor,
|
|
632
|
+
marginTop: '6px',
|
|
633
|
+
flexShrink: 0,
|
|
634
|
+
}} />
|
|
635
|
+
<span style={{
|
|
636
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
637
|
+
fontWeight: 'var(--weight-bold, 700)' as unknown as number,
|
|
638
|
+
color: tokens.surfaces.text_default,
|
|
639
|
+
lineHeight: '1.3',
|
|
640
|
+
letterSpacing: '0.01em',
|
|
641
|
+
...so?.stat_label,
|
|
642
|
+
}}>
|
|
643
|
+
{item.term}
|
|
644
|
+
</span>
|
|
645
|
+
</div>
|
|
646
|
+
|
|
647
|
+
{/* Definition column */}
|
|
648
|
+
<div style={{
|
|
649
|
+
padding: '10px 16px',
|
|
650
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
651
|
+
fontWeight: 'var(--weight-normal, 400)' as unknown as number,
|
|
652
|
+
color: 'var(--color-text, #1a1d23)',
|
|
653
|
+
lineHeight: 'var(--leading-relaxed, 1.6)',
|
|
654
|
+
display: 'flex',
|
|
655
|
+
alignItems: 'flex-start',
|
|
656
|
+
gap: '8px',
|
|
657
|
+
}}>
|
|
658
|
+
<div style={{ flex: 1 }}>
|
|
659
|
+
{renderInlineMarkdown(displayDef, colors.dotColor)}
|
|
660
|
+
{isLong && (
|
|
661
|
+
<span className="gen-show-more-link" style={{ marginLeft: '6px' }}>
|
|
662
|
+
{isExpanded ? 'show less' : 'show more'}
|
|
663
|
+
</span>
|
|
664
|
+
)}
|
|
665
|
+
</div>
|
|
666
|
+
{captureMode && onCapture && (
|
|
667
|
+
<button
|
|
668
|
+
title="Capture this item"
|
|
669
|
+
onClick={e => {
|
|
670
|
+
e.stopPropagation();
|
|
671
|
+
onCapture({
|
|
672
|
+
source_view_key: captureViewKey || '',
|
|
673
|
+
source_section_key: parentSectionKey,
|
|
674
|
+
source_item_index: i,
|
|
675
|
+
source_renderer_type: 'definition_list',
|
|
676
|
+
content_type: 'item',
|
|
677
|
+
selected_text: `${item.term}: ${item.definition}`.slice(0, 500),
|
|
678
|
+
structured_data: data[i],
|
|
679
|
+
context_title: parentSectionKey
|
|
680
|
+
? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${item.term}`
|
|
681
|
+
: `${captureViewKey || 'Analysis'} > ${item.term}`,
|
|
682
|
+
source_type: (captureSourceType || 'analysis') as string,
|
|
683
|
+
entity_id: captureEntityId || captureJobId || '',
|
|
684
|
+
depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
|
|
685
|
+
parent_context: parentSectionKey ? {
|
|
686
|
+
section_key: parentSectionKey,
|
|
687
|
+
section_title: parentSectionTitle || '',
|
|
688
|
+
} : undefined,
|
|
689
|
+
});
|
|
690
|
+
}}
|
|
691
|
+
style={{
|
|
692
|
+
flexShrink: 0,
|
|
693
|
+
background: 'none',
|
|
694
|
+
border: '1px solid var(--color-border, #ccc)',
|
|
695
|
+
borderRadius: '4px',
|
|
696
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
697
|
+
cursor: 'pointer',
|
|
698
|
+
padding: '2px 6px',
|
|
699
|
+
fontSize: '0.7rem',
|
|
700
|
+
lineHeight: 1,
|
|
701
|
+
marginTop: '2px',
|
|
702
|
+
}}
|
|
703
|
+
>
|
|
704
|
+
📌
|
|
705
|
+
</button>
|
|
706
|
+
)}
|
|
707
|
+
</div>
|
|
708
|
+
</div>
|
|
709
|
+
);
|
|
710
|
+
})}
|
|
711
|
+
</div>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ── MiniCardList → Insight Cards ─────────────────────────
|
|
716
|
+
|
|
717
|
+
function MiniCardList({ data, config }: SubRendererProps) {
|
|
718
|
+
const [expandedCards, setExpandedCards] = React.useState<Set<number>>(new Set());
|
|
719
|
+
const [hoveredIdx, setHoveredIdx] = React.useState<number | null>(null);
|
|
720
|
+
const { tokens } = useDesignTokens();
|
|
721
|
+
|
|
722
|
+
// Capture mode support (threaded from AccordionRenderer)
|
|
723
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
724
|
+
const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
|
|
725
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
726
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
727
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
728
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
729
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
730
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
731
|
+
|
|
732
|
+
if (!data || !Array.isArray(data)) return null;
|
|
733
|
+
const so = getSO(config);
|
|
734
|
+
|
|
735
|
+
const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
|
|
736
|
+
const auto = firstObj ? autoDetectFields(firstObj) : {};
|
|
737
|
+
const titleField = resolveField(config, 'title_field', auto, 'title');
|
|
738
|
+
const subtitleField = resolveField(config, 'subtitle_field', auto, 'subtitle');
|
|
739
|
+
const badgeField = resolveField(config, 'badge_field', auto, 'badge');
|
|
740
|
+
const descriptionField = resolveField(config, 'description_field', auto, 'description');
|
|
741
|
+
|
|
742
|
+
// Hero card is opt-in: set hero:true in config to enable the hero pattern
|
|
743
|
+
const useHero = config.hero === true;
|
|
744
|
+
|
|
745
|
+
// Determine hero card: highest significance/importance/priority, or first
|
|
746
|
+
const SIGNIFICANCE_KEYS = ['significance', 'importance', 'priority', 'weight', 'relevance'];
|
|
747
|
+
let heroIdx = useHero ? 0 : -1;
|
|
748
|
+
if (useHero && data.length > 1 && firstObj) {
|
|
749
|
+
const sigField = Object.keys(firstObj).find(k => SIGNIFICANCE_KEYS.includes(k));
|
|
750
|
+
if (sigField) {
|
|
751
|
+
let maxVal = -Infinity;
|
|
752
|
+
data.forEach((item, i) => {
|
|
753
|
+
if (typeof item === 'object' && item !== null) {
|
|
754
|
+
const val = getNumField(item as Record<string, unknown>, sigField);
|
|
755
|
+
if (val !== null && val > maxVal) { maxVal = val; heroIdx = i; }
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const toggleExpand = (idx: number) => {
|
|
762
|
+
setExpandedCards(prev => {
|
|
763
|
+
const next = new Set(prev);
|
|
764
|
+
if (next.has(idx)) next.delete(idx);
|
|
765
|
+
else next.add(idx);
|
|
766
|
+
return next;
|
|
767
|
+
});
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const DESC_TRUNCATE_LEN = Infinity; // Show full text — no truncation
|
|
771
|
+
|
|
772
|
+
const renderCard = (item: unknown, idx: number, isHero: boolean) => {
|
|
773
|
+
if (typeof item !== 'object' || item === null) return null;
|
|
774
|
+
const obj = item as Record<string, unknown>;
|
|
775
|
+
|
|
776
|
+
const title = getField(obj, titleField);
|
|
777
|
+
const subtitle = getField(obj, subtitleField);
|
|
778
|
+
const badge = getField(obj, badgeField);
|
|
779
|
+
const description = getField(obj, descriptionField);
|
|
780
|
+
|
|
781
|
+
const shownFields = new Set([titleField, subtitleField, badgeField, descriptionField]);
|
|
782
|
+
const remaining = Object.entries(obj).filter(
|
|
783
|
+
([k, v]) => !shownFields.has(k) && v !== null && v !== undefined && v !== ''
|
|
784
|
+
);
|
|
785
|
+
const chipFields = remaining.filter(([, v]) => Array.isArray(v) && (v as unknown[]).every(x => typeof x === 'string'));
|
|
786
|
+
const scalarFields = remaining.filter(([, v]) => !Array.isArray(v) || !(v as unknown[]).every(x => typeof x === 'string'));
|
|
787
|
+
|
|
788
|
+
const seriesColor = tokens.primitives.series_palette[idx % tokens.primitives.series_palette.length];
|
|
789
|
+
const colors = {
|
|
790
|
+
headerBg: seriesColor,
|
|
791
|
+
headerText: tokens.surfaces.text_on_accent,
|
|
792
|
+
accent: seriesColor,
|
|
793
|
+
lightBg: tokens.surfaces.surface_alt,
|
|
794
|
+
darkText: tokens.surfaces.text_default,
|
|
795
|
+
border: tokens.surfaces.border_default,
|
|
796
|
+
};
|
|
797
|
+
const isHovered = hoveredIdx === idx;
|
|
798
|
+
const isContentExpanded = expandedCards.has(idx);
|
|
799
|
+
const needsTruncation = !isHero && description.length > DESC_TRUNCATE_LEN;
|
|
800
|
+
|
|
801
|
+
return (
|
|
802
|
+
<div
|
|
803
|
+
key={idx}
|
|
804
|
+
onMouseEnter={() => setHoveredIdx(idx)}
|
|
805
|
+
onMouseLeave={() => setHoveredIdx(null)}
|
|
806
|
+
style={{
|
|
807
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
808
|
+
overflow: 'hidden',
|
|
809
|
+
borderLeft: `4px solid ${colors.accent}`,
|
|
810
|
+
border: `1px solid var(--color-border, #e2e5e9)`,
|
|
811
|
+
borderLeftWidth: '4px',
|
|
812
|
+
borderLeftColor: colors.accent,
|
|
813
|
+
boxShadow: isHovered
|
|
814
|
+
? 'var(--shadow-md, 0 4px 6px rgba(0,0,0,0.05))'
|
|
815
|
+
: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.06))',
|
|
816
|
+
transform: isHovered ? 'translateY(-2px)' : 'none',
|
|
817
|
+
transition: `box-shadow var(--duration-fast, 150ms) var(--ease-out, ease), transform var(--duration-fast, 150ms) var(--ease-out, ease)`,
|
|
818
|
+
backgroundColor: 'var(--color-surface, #ffffff)',
|
|
819
|
+
...(isHero ? so?.hero_card : {}),
|
|
820
|
+
...so?.card,
|
|
821
|
+
}}
|
|
822
|
+
>
|
|
823
|
+
{/* Header bar */}
|
|
824
|
+
<div style={{
|
|
825
|
+
padding: isHero
|
|
826
|
+
? 'var(--space-md, 1rem) var(--space-lg, 1.5rem)'
|
|
827
|
+
: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
828
|
+
display: 'flex', alignItems: 'center', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap',
|
|
829
|
+
...so?.card_header,
|
|
830
|
+
}}>
|
|
831
|
+
{title && (
|
|
832
|
+
<strong style={{
|
|
833
|
+
fontSize: isHero
|
|
834
|
+
? 'var(--type-heading, 1.375rem)'
|
|
835
|
+
: 'var(--type-body, 0.9375rem)',
|
|
836
|
+
fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
|
|
837
|
+
color: 'var(--color-text, #1a1d23)',
|
|
838
|
+
lineHeight: 'var(--leading-tight, 1.2)',
|
|
839
|
+
}}>
|
|
840
|
+
{title}
|
|
841
|
+
</strong>
|
|
842
|
+
)}
|
|
843
|
+
{subtitle && (
|
|
844
|
+
<span style={{
|
|
845
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
846
|
+
fontWeight: 'var(--weight-medium, 500)' as unknown as number,
|
|
847
|
+
color: colors.headerBg,
|
|
848
|
+
padding: '2px 10px',
|
|
849
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
850
|
+
backgroundColor: colors.lightBg,
|
|
851
|
+
letterSpacing: '0.02em',
|
|
852
|
+
...so?.badge,
|
|
853
|
+
}}>{subtitle}</span>
|
|
854
|
+
)}
|
|
855
|
+
{badge && (
|
|
856
|
+
<span style={{
|
|
857
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
858
|
+
fontWeight: 'var(--weight-bold, 700)' as unknown as number,
|
|
859
|
+
padding: '2px 8px',
|
|
860
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
861
|
+
backgroundColor: colors.lightBg,
|
|
862
|
+
color: colors.darkText,
|
|
863
|
+
marginLeft: 'auto',
|
|
864
|
+
...so?.badge,
|
|
865
|
+
}}>
|
|
866
|
+
{badge}
|
|
867
|
+
</span>
|
|
868
|
+
)}
|
|
869
|
+
{captureMode && onCapture && (
|
|
870
|
+
<button
|
|
871
|
+
title="Capture this card"
|
|
872
|
+
onClick={e => {
|
|
873
|
+
e.stopPropagation();
|
|
874
|
+
onCapture({
|
|
875
|
+
source_view_key: captureViewKey || '',
|
|
876
|
+
source_item_index: idx,
|
|
877
|
+
source_renderer_type: 'mini_card_list',
|
|
878
|
+
content_type: 'card',
|
|
879
|
+
selected_text: `${title}: ${description}`.slice(0, 500),
|
|
880
|
+
structured_data: obj,
|
|
881
|
+
context_title: parentSectionKey
|
|
882
|
+
? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${title || `Card ${idx + 1}`}`
|
|
883
|
+
: `${captureViewKey || 'Analysis'} > ${title || `Card ${idx + 1}`}`,
|
|
884
|
+
source_type: (captureSourceType || 'analysis') as string,
|
|
885
|
+
entity_id: captureEntityId || captureJobId || '',
|
|
886
|
+
depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
|
|
887
|
+
parent_context: parentSectionKey ? {
|
|
888
|
+
section_key: parentSectionKey,
|
|
889
|
+
section_title: parentSectionTitle || '',
|
|
890
|
+
} : undefined,
|
|
891
|
+
});
|
|
892
|
+
}}
|
|
893
|
+
style={{
|
|
894
|
+
background: 'none',
|
|
895
|
+
border: '1px solid var(--color-border, #ccc)',
|
|
896
|
+
borderRadius: '4px',
|
|
897
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
898
|
+
cursor: 'pointer',
|
|
899
|
+
padding: '2px 6px',
|
|
900
|
+
fontSize: '0.7rem',
|
|
901
|
+
lineHeight: 1,
|
|
902
|
+
marginLeft: badge ? '0' : 'auto',
|
|
903
|
+
}}
|
|
904
|
+
>
|
|
905
|
+
📌
|
|
906
|
+
</button>
|
|
907
|
+
)}
|
|
908
|
+
</div>
|
|
909
|
+
|
|
910
|
+
{/* Description body */}
|
|
911
|
+
{description && (
|
|
912
|
+
<div style={{
|
|
913
|
+
padding: isHero
|
|
914
|
+
? '0 var(--space-lg, 1.5rem) var(--space-md, 1rem)'
|
|
915
|
+
: '0 var(--space-md, 1rem) var(--space-sm, 0.5rem)',
|
|
916
|
+
...so?.card_body,
|
|
917
|
+
}}>
|
|
918
|
+
<p style={{
|
|
919
|
+
fontSize: isHero
|
|
920
|
+
? 'var(--type-body, 0.9375rem)'
|
|
921
|
+
: 'var(--type-caption, 0.8125rem)',
|
|
922
|
+
color: 'var(--color-text, #1a1d23)',
|
|
923
|
+
lineHeight: 'var(--leading-relaxed, 1.65)',
|
|
924
|
+
margin: 0,
|
|
925
|
+
...so?.prose,
|
|
926
|
+
}}>
|
|
927
|
+
{needsTruncation && !isContentExpanded
|
|
928
|
+
? description.slice(0, DESC_TRUNCATE_LEN) + '...'
|
|
929
|
+
: description}
|
|
930
|
+
</p>
|
|
931
|
+
{needsTruncation && (
|
|
932
|
+
<button
|
|
933
|
+
className="gen-show-more-link"
|
|
934
|
+
onClick={(e) => { e.stopPropagation(); toggleExpand(idx); }}
|
|
935
|
+
style={{
|
|
936
|
+
marginTop: 'var(--space-xs, 0.25rem)',
|
|
937
|
+
}}
|
|
938
|
+
>
|
|
939
|
+
{isContentExpanded ? 'show less' : 'show more'}
|
|
940
|
+
</button>
|
|
941
|
+
)}
|
|
942
|
+
</div>
|
|
943
|
+
)}
|
|
944
|
+
|
|
945
|
+
{/* Scalar fields */}
|
|
946
|
+
{scalarFields.length > 0 && (
|
|
947
|
+
<div style={{
|
|
948
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
949
|
+
backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
|
|
950
|
+
borderTop: '1px solid var(--color-border-light, #eef0f2)',
|
|
951
|
+
}}>
|
|
952
|
+
{scalarFields.map(([key, value]) => (
|
|
953
|
+
<div key={key} style={{ marginBottom: 'var(--space-2xs, 0.25rem)' }}>
|
|
954
|
+
<span className="gen-inline-label">
|
|
955
|
+
{key.replace(/_/g, ' ')}:
|
|
956
|
+
</span>
|
|
957
|
+
<span style={{
|
|
958
|
+
marginLeft: 'var(--space-xs, 0.375rem)',
|
|
959
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
960
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
961
|
+
}}>
|
|
962
|
+
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
|
963
|
+
</span>
|
|
964
|
+
</div>
|
|
965
|
+
))}
|
|
966
|
+
</div>
|
|
967
|
+
)}
|
|
968
|
+
|
|
969
|
+
{/* Chip fields */}
|
|
970
|
+
{chipFields.length > 0 && (
|
|
971
|
+
<div style={{
|
|
972
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
973
|
+
backgroundColor: colors.lightBg,
|
|
974
|
+
borderTop: '1px solid var(--color-border-light, #eef0f2)',
|
|
975
|
+
}}>
|
|
976
|
+
{chipFields.map(([key, value]) => (
|
|
977
|
+
<div key={key} style={{ marginBottom: 'var(--space-xs, 0.375rem)' }}>
|
|
978
|
+
<span className="gen-inline-label" style={{
|
|
979
|
+
display: 'block',
|
|
980
|
+
marginBottom: 'var(--space-2xs, 0.25rem)',
|
|
981
|
+
}}>
|
|
982
|
+
{key.replace(/_/g, ' ')}
|
|
983
|
+
</span>
|
|
984
|
+
<div style={{ display: 'flex', gap: 'var(--space-xs, 0.25rem)', flexWrap: 'wrap' }}>
|
|
985
|
+
{(value as string[]).map((v, vi) => (
|
|
986
|
+
<span key={vi} className="gen-keyword-tag">{String(v)}</span>
|
|
987
|
+
))}
|
|
988
|
+
</div>
|
|
989
|
+
</div>
|
|
990
|
+
))}
|
|
991
|
+
</div>
|
|
992
|
+
)}
|
|
993
|
+
</div>
|
|
994
|
+
);
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
// Single card → render as hero only if hero mode enabled
|
|
998
|
+
if (data.length === 1) {
|
|
999
|
+
return (
|
|
1000
|
+
<div style={{ ...so?.items_container }}>
|
|
1001
|
+
{renderCard(data[0], 0, useHero)}
|
|
1002
|
+
</div>
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Default: uniform grid layout
|
|
1007
|
+
if (!useHero) {
|
|
1008
|
+
return (
|
|
1009
|
+
<div style={{
|
|
1010
|
+
display: 'grid',
|
|
1011
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
1012
|
+
gap: 'var(--space-md, 1rem)',
|
|
1013
|
+
...so?.items_container,
|
|
1014
|
+
}}>
|
|
1015
|
+
{data.map((item, i) => renderCard(item, i, false))}
|
|
1016
|
+
</div>
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Multi-card layout: hero on top, rest in 2-column grid
|
|
1021
|
+
return (
|
|
1022
|
+
<div style={{
|
|
1023
|
+
display: 'flex', flexDirection: 'column',
|
|
1024
|
+
gap: 'var(--space-md, 1rem)',
|
|
1025
|
+
...so?.items_container,
|
|
1026
|
+
}}>
|
|
1027
|
+
{renderCard(data[heroIdx], heroIdx, true)}
|
|
1028
|
+
|
|
1029
|
+
{data.length > 1 && (
|
|
1030
|
+
<div style={{
|
|
1031
|
+
display: 'grid',
|
|
1032
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
1033
|
+
gap: 'var(--space-md, 1rem)',
|
|
1034
|
+
}}>
|
|
1035
|
+
{data.map((item, i) => {
|
|
1036
|
+
if (i === heroIdx) return null;
|
|
1037
|
+
return renderCard(item, i, false);
|
|
1038
|
+
})}
|
|
1039
|
+
</div>
|
|
1040
|
+
)}
|
|
1041
|
+
</div>
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ── KeyValueTable ────────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
function KeyValueTable({ data, config }: SubRendererProps) {
|
|
1048
|
+
const { tokens } = useDesignTokens();
|
|
1049
|
+
const so = getSO(config);
|
|
1050
|
+
|
|
1051
|
+
const firstObj = Array.isArray(data)
|
|
1052
|
+
? data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined
|
|
1053
|
+
: undefined;
|
|
1054
|
+
const auto = firstObj ? autoDetectFields(firstObj) : {};
|
|
1055
|
+
const keyField = resolveField(config, 'key_field', auto, 'key');
|
|
1056
|
+
const valueField = resolveField(config, 'value_field', auto, 'value');
|
|
1057
|
+
|
|
1058
|
+
let rows: Array<{ key: string; value: string }> = [];
|
|
1059
|
+
|
|
1060
|
+
if (Array.isArray(data)) {
|
|
1061
|
+
rows = data.map(item => {
|
|
1062
|
+
if (typeof item !== 'object' || item === null) return { key: '', value: String(item) };
|
|
1063
|
+
const obj = item as Record<string, unknown>;
|
|
1064
|
+
return {
|
|
1065
|
+
key: getField(obj, keyField) || Object.keys(obj)[0] || '',
|
|
1066
|
+
value: getField(obj, valueField) || String(Object.values(obj).find(v => typeof v === 'string' && v.length > 20) ?? Object.values(obj)[1] ?? ''),
|
|
1067
|
+
};
|
|
1068
|
+
});
|
|
1069
|
+
} else if (typeof data === 'object' && data !== null) {
|
|
1070
|
+
rows = Object.entries(data as Record<string, unknown>).map(([k, v]) => ({
|
|
1071
|
+
key: k,
|
|
1072
|
+
value: typeof v === 'object' ? JSON.stringify(v) : String(v ?? ''),
|
|
1073
|
+
}));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (rows.length === 0) return null;
|
|
1077
|
+
|
|
1078
|
+
const isNumeric = (val: string) => /^[\d,.]+%?$/.test(val.trim());
|
|
1079
|
+
|
|
1080
|
+
return (
|
|
1081
|
+
<div style={{
|
|
1082
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
1083
|
+
overflow: 'hidden',
|
|
1084
|
+
border: '1px solid var(--color-border, #e2e5e9)',
|
|
1085
|
+
boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
|
|
1086
|
+
...so?.card,
|
|
1087
|
+
}}>
|
|
1088
|
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
1089
|
+
<tbody>
|
|
1090
|
+
{rows.map((row, i) => (
|
|
1091
|
+
<tr key={i} style={{
|
|
1092
|
+
backgroundColor: i % 2 === 0
|
|
1093
|
+
? 'var(--color-surface, #ffffff)'
|
|
1094
|
+
: 'var(--color-surface-alt, #f8f9fa)',
|
|
1095
|
+
}}>
|
|
1096
|
+
<td style={{
|
|
1097
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
1098
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
1099
|
+
fontWeight: 'var(--weight-medium, 500)' as unknown as number,
|
|
1100
|
+
color: tokens.surfaces.text_default,
|
|
1101
|
+
width: '30%', verticalAlign: 'top',
|
|
1102
|
+
textTransform: 'capitalize' as const,
|
|
1103
|
+
letterSpacing: '0.02em',
|
|
1104
|
+
borderRight: `2px solid ${tokens.surfaces.border_accent}`,
|
|
1105
|
+
backgroundColor: i % 2 === 0
|
|
1106
|
+
? tokens.surfaces.surface_alt
|
|
1107
|
+
: tokens.surfaces.surface_inset,
|
|
1108
|
+
...so?.stat_label,
|
|
1109
|
+
}}>
|
|
1110
|
+
{row.key.replace(/_/g, ' ')}
|
|
1111
|
+
</td>
|
|
1112
|
+
<td style={{
|
|
1113
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
1114
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
1115
|
+
fontWeight: isNumeric(row.value)
|
|
1116
|
+
? ('var(--weight-semibold, 600)' as unknown as number)
|
|
1117
|
+
: ('var(--weight-normal, 400)' as unknown as number),
|
|
1118
|
+
fontFamily: isNumeric(row.value) ? 'var(--font-mono, monospace)' : 'inherit',
|
|
1119
|
+
color: 'var(--color-text, #1a1d23)',
|
|
1120
|
+
lineHeight: 'var(--leading-normal, 1.5)',
|
|
1121
|
+
...(isNumeric(row.value) ? so?.stat_number : {}),
|
|
1122
|
+
}}>
|
|
1123
|
+
{row.value}
|
|
1124
|
+
</td>
|
|
1125
|
+
</tr>
|
|
1126
|
+
))}
|
|
1127
|
+
</tbody>
|
|
1128
|
+
</table>
|
|
1129
|
+
</div>
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ── ProseBlock → Formatted Analysis ─────────────────────
|
|
1134
|
+
|
|
1135
|
+
interface ProseSegment {
|
|
1136
|
+
type: 'paragraph' | 'blockquote' | 'hr' | 'heading';
|
|
1137
|
+
content: string;
|
|
1138
|
+
level?: number;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function parseProseContent(text: string): ProseSegment[] {
|
|
1142
|
+
const lines = text.split('\n');
|
|
1143
|
+
const segments: ProseSegment[] = [];
|
|
1144
|
+
let currentParagraph: string[] = [];
|
|
1145
|
+
let currentBlockquote: string[] = [];
|
|
1146
|
+
|
|
1147
|
+
function flushParagraph() {
|
|
1148
|
+
if (currentParagraph.length > 0) {
|
|
1149
|
+
const joined = currentParagraph.join(' ').trim();
|
|
1150
|
+
if (joined) segments.push({ type: 'paragraph', content: joined });
|
|
1151
|
+
currentParagraph = [];
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function flushBlockquote() {
|
|
1156
|
+
if (currentBlockquote.length > 0) {
|
|
1157
|
+
const joined = currentBlockquote.join(' ').trim();
|
|
1158
|
+
if (joined) segments.push({ type: 'blockquote', content: joined });
|
|
1159
|
+
currentBlockquote = [];
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
for (const line of lines) {
|
|
1164
|
+
const trimmed = line.trim();
|
|
1165
|
+
|
|
1166
|
+
if (/^(---+|\*\*\*+)$/.test(trimmed)) {
|
|
1167
|
+
flushParagraph();
|
|
1168
|
+
flushBlockquote();
|
|
1169
|
+
segments.push({ type: 'hr', content: '' });
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)/);
|
|
1174
|
+
if (headingMatch) {
|
|
1175
|
+
flushParagraph();
|
|
1176
|
+
flushBlockquote();
|
|
1177
|
+
segments.push({ type: 'heading', content: headingMatch[2], level: headingMatch[1].length });
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (trimmed.startsWith('>')) {
|
|
1182
|
+
flushParagraph();
|
|
1183
|
+
currentBlockquote.push(trimmed.replace(/^>\s*/, ''));
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if (!trimmed) {
|
|
1188
|
+
flushBlockquote();
|
|
1189
|
+
flushParagraph();
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
flushBlockquote();
|
|
1194
|
+
currentParagraph.push(trimmed);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
flushParagraph();
|
|
1198
|
+
flushBlockquote();
|
|
1199
|
+
|
|
1200
|
+
return segments;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function ProseBlock({ data, config }: SubRendererProps) {
|
|
1204
|
+
const { tokens } = useDesignTokens();
|
|
1205
|
+
const so = getSO(config);
|
|
1206
|
+
if (!data) return null;
|
|
1207
|
+
|
|
1208
|
+
const text = typeof data === 'string'
|
|
1209
|
+
? data
|
|
1210
|
+
: typeof data === 'object' && data !== null
|
|
1211
|
+
? Object.values(data).filter(v => typeof v === 'string').join('\n\n')
|
|
1212
|
+
: String(data);
|
|
1213
|
+
|
|
1214
|
+
if (!text.trim()) return null;
|
|
1215
|
+
|
|
1216
|
+
const accentHex = so?.accent_color || tokens.components.page_accent;
|
|
1217
|
+
const segments = parseProseContent(text);
|
|
1218
|
+
|
|
1219
|
+
let paragraphIndex = 0;
|
|
1220
|
+
|
|
1221
|
+
return (
|
|
1222
|
+
<div style={{
|
|
1223
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
1224
|
+
lineHeight: 'var(--leading-relaxed, 1.65)',
|
|
1225
|
+
color: 'var(--color-text, #1a1d23)',
|
|
1226
|
+
...so?.prose,
|
|
1227
|
+
}}>
|
|
1228
|
+
{segments.map((segment, i) => {
|
|
1229
|
+
if (segment.type === 'hr') {
|
|
1230
|
+
return (
|
|
1231
|
+
<hr key={i} style={{
|
|
1232
|
+
border: 'none',
|
|
1233
|
+
height: '1px',
|
|
1234
|
+
backgroundColor: 'var(--color-border, #e2e5e9)',
|
|
1235
|
+
margin: 'var(--space-lg, 1.5rem) var(--space-xl, 2rem)',
|
|
1236
|
+
}} />
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (segment.type === 'heading') {
|
|
1241
|
+
const headingSizes: Record<number, string> = {
|
|
1242
|
+
1: 'var(--type-heading, 1.375rem)',
|
|
1243
|
+
2: 'var(--type-subheading, 1.125rem)',
|
|
1244
|
+
3: 'var(--type-body, 0.9375rem)',
|
|
1245
|
+
};
|
|
1246
|
+
return (
|
|
1247
|
+
<p key={i} style={{
|
|
1248
|
+
fontSize: headingSizes[segment.level || 3],
|
|
1249
|
+
fontWeight: 'var(--weight-bold, 700)' as unknown as number,
|
|
1250
|
+
color: tokens.surfaces.text_default,
|
|
1251
|
+
marginTop: 'var(--space-lg, 1.5rem)',
|
|
1252
|
+
marginBottom: 'var(--space-sm, 0.5rem)',
|
|
1253
|
+
lineHeight: 'var(--leading-tight, 1.2)',
|
|
1254
|
+
}}>
|
|
1255
|
+
{segment.content}
|
|
1256
|
+
</p>
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (segment.type === 'blockquote') {
|
|
1261
|
+
return (
|
|
1262
|
+
<blockquote key={i} style={{
|
|
1263
|
+
margin: 'var(--space-md, 1rem) 0',
|
|
1264
|
+
padding: 'var(--space-md, 1rem) var(--space-lg, 1.5rem)',
|
|
1265
|
+
borderLeft: `4px solid ${accentHex}`,
|
|
1266
|
+
backgroundColor: tokens.components.prose_blockquote_bg,
|
|
1267
|
+
borderRadius: '0 var(--radius-md, 8px) var(--radius-md, 8px) 0',
|
|
1268
|
+
fontStyle: 'italic',
|
|
1269
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
1270
|
+
lineHeight: 'var(--leading-loose, 1.8)',
|
|
1271
|
+
...so?.prose_quote,
|
|
1272
|
+
}}>
|
|
1273
|
+
{renderInlineMarkdown(segment.content, accentHex)}
|
|
1274
|
+
</blockquote>
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Paragraph: first paragraph is lede
|
|
1279
|
+
const isLede = paragraphIndex === 0;
|
|
1280
|
+
paragraphIndex++;
|
|
1281
|
+
|
|
1282
|
+
if (isLede) {
|
|
1283
|
+
return (
|
|
1284
|
+
<p key={i} style={{
|
|
1285
|
+
fontSize: 'var(--type-subheading, 1.125rem)',
|
|
1286
|
+
fontWeight: 'var(--weight-medium, 500)' as unknown as number,
|
|
1287
|
+
lineHeight: 'var(--leading-snug, 1.35)',
|
|
1288
|
+
color: 'var(--color-text, #1a1d23)',
|
|
1289
|
+
marginTop: 0,
|
|
1290
|
+
marginBottom: 'var(--space-md, 1rem)',
|
|
1291
|
+
...so?.prose_lede,
|
|
1292
|
+
}}>
|
|
1293
|
+
{renderInlineMarkdown(segment.content, accentHex)}
|
|
1294
|
+
</p>
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
return (
|
|
1299
|
+
<p key={i} style={{
|
|
1300
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
1301
|
+
fontWeight: 'var(--weight-normal, 400)' as unknown as number,
|
|
1302
|
+
lineHeight: 'var(--leading-relaxed, 1.65)',
|
|
1303
|
+
color: 'var(--color-text, #1a1d23)',
|
|
1304
|
+
marginTop: 0,
|
|
1305
|
+
marginBottom: 'var(--space-md, 1rem)',
|
|
1306
|
+
...so?.prose_body,
|
|
1307
|
+
}}>
|
|
1308
|
+
{renderInlineMarkdown(segment.content, accentHex)}
|
|
1309
|
+
</p>
|
|
1310
|
+
);
|
|
1311
|
+
})}
|
|
1312
|
+
</div>
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ── StatRow ──────────────────────────────────────────────
|
|
1317
|
+
|
|
1318
|
+
function StatRow({ data, config }: SubRendererProps) {
|
|
1319
|
+
const { tokens } = useDesignTokens();
|
|
1320
|
+
if (!data || typeof data !== 'object') return null;
|
|
1321
|
+
const so = getSO(config);
|
|
1322
|
+
|
|
1323
|
+
const obj = data as Record<string, unknown>;
|
|
1324
|
+
const entries = Object.entries(obj).filter(([, v]) => v !== null && v !== undefined);
|
|
1325
|
+
|
|
1326
|
+
if (entries.length === 0) return null;
|
|
1327
|
+
|
|
1328
|
+
return (
|
|
1329
|
+
<div style={{
|
|
1330
|
+
display: 'grid',
|
|
1331
|
+
gridTemplateColumns: `repeat(${Math.min(entries.length, 4)}, 1fr)`,
|
|
1332
|
+
gap: 'var(--space-md, 0.75rem)',
|
|
1333
|
+
...so?.items_container,
|
|
1334
|
+
}}>
|
|
1335
|
+
{entries.map(([key, value]) => (
|
|
1336
|
+
<div key={key} style={{
|
|
1337
|
+
padding: 'var(--space-md, 1rem)',
|
|
1338
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
1339
|
+
backgroundColor: 'var(--color-surface, #ffffff)',
|
|
1340
|
+
border: '1px solid var(--color-border-light, #eef0f2)',
|
|
1341
|
+
boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
|
|
1342
|
+
textAlign: 'center' as const,
|
|
1343
|
+
...so?.card,
|
|
1344
|
+
}}>
|
|
1345
|
+
<div style={{
|
|
1346
|
+
fontSize: 'var(--type-number, 1.25rem)',
|
|
1347
|
+
fontFamily: 'var(--font-mono, monospace)',
|
|
1348
|
+
fontWeight: 'var(--weight-bold, 700)' as unknown as number,
|
|
1349
|
+
color: tokens.components.stat_number_color,
|
|
1350
|
+
lineHeight: 'var(--leading-tight, 1.2)',
|
|
1351
|
+
...so?.stat_number,
|
|
1352
|
+
}}>
|
|
1353
|
+
{typeof value === 'number' ? value.toLocaleString() : String(value)}
|
|
1354
|
+
</div>
|
|
1355
|
+
<div className="gen-inline-label" style={{
|
|
1356
|
+
marginTop: 'var(--space-xs, 0.25rem)',
|
|
1357
|
+
...so?.stat_label,
|
|
1358
|
+
}}>
|
|
1359
|
+
{key.replace(/_/g, ' ')}
|
|
1360
|
+
</div>
|
|
1361
|
+
</div>
|
|
1362
|
+
))}
|
|
1363
|
+
</div>
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ── ComparisonPanel ──────────────────────────────────────
|
|
1368
|
+
|
|
1369
|
+
function ComparisonPanel({ data, config }: SubRendererProps) {
|
|
1370
|
+
const { tokens } = useDesignTokens();
|
|
1371
|
+
if (!data || !Array.isArray(data)) return null;
|
|
1372
|
+
const so = getSO(config);
|
|
1373
|
+
|
|
1374
|
+
// Capture mode support (threaded from AccordionRenderer)
|
|
1375
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
1376
|
+
const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
|
|
1377
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
1378
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
1379
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
1380
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
1381
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
1382
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
1383
|
+
|
|
1384
|
+
const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
|
|
1385
|
+
const longStrings = firstObj
|
|
1386
|
+
? Object.entries(firstObj).filter(([, v]) => typeof v === 'string' && (v as string).length > 20).map(([k]) => k)
|
|
1387
|
+
: [];
|
|
1388
|
+
const leftField = (config.left_field as string | undefined) || longStrings[0];
|
|
1389
|
+
const rightField = (config.right_field as string | undefined) || longStrings[1];
|
|
1390
|
+
|
|
1391
|
+
const leftLabel = leftField ? leftField.replace(/_/g, ' ') : 'Left';
|
|
1392
|
+
const rightLabel = rightField ? rightField.replace(/_/g, ' ') : 'Right';
|
|
1393
|
+
|
|
1394
|
+
return (
|
|
1395
|
+
<div style={{
|
|
1396
|
+
display: 'flex', flexDirection: 'column',
|
|
1397
|
+
gap: 'var(--space-md, 0.75rem)',
|
|
1398
|
+
...so?.items_container,
|
|
1399
|
+
}}>
|
|
1400
|
+
{/* Column headers */}
|
|
1401
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px' }}>
|
|
1402
|
+
<div style={{
|
|
1403
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
1404
|
+
backgroundColor: tokens.primitives.series_palette[0],
|
|
1405
|
+
color: tokens.surfaces.text_on_accent,
|
|
1406
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
1407
|
+
fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
|
|
1408
|
+
textTransform: 'uppercase' as const,
|
|
1409
|
+
letterSpacing: '0.06em',
|
|
1410
|
+
borderRadius: 'var(--radius-md, 8px) 0 0 0',
|
|
1411
|
+
...so?.card_header,
|
|
1412
|
+
}}>
|
|
1413
|
+
{leftLabel}
|
|
1414
|
+
</div>
|
|
1415
|
+
<div style={{
|
|
1416
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
1417
|
+
backgroundColor: tokens.primitives.series_palette[1],
|
|
1418
|
+
color: 'var(--dt-text-inverse)',
|
|
1419
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
1420
|
+
fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
|
|
1421
|
+
textTransform: 'uppercase' as const,
|
|
1422
|
+
letterSpacing: '0.06em',
|
|
1423
|
+
borderRadius: '0 var(--radius-md, 8px) 0 0',
|
|
1424
|
+
...so?.card_header,
|
|
1425
|
+
}}>
|
|
1426
|
+
{rightLabel}
|
|
1427
|
+
</div>
|
|
1428
|
+
</div>
|
|
1429
|
+
|
|
1430
|
+
{data.map((item, i) => {
|
|
1431
|
+
if (typeof item !== 'object' || item === null) return null;
|
|
1432
|
+
const obj = item as Record<string, unknown>;
|
|
1433
|
+
|
|
1434
|
+
const left = getField(obj, leftField);
|
|
1435
|
+
const right = getField(obj, rightField);
|
|
1436
|
+
|
|
1437
|
+
const otherFields = Object.entries(obj).filter(
|
|
1438
|
+
([k, v]) => k !== leftField && k !== rightField && v !== null && v !== undefined && v !== ''
|
|
1439
|
+
);
|
|
1440
|
+
|
|
1441
|
+
// Build a label for this comparison row
|
|
1442
|
+
const rowLabel = otherFields.length > 0
|
|
1443
|
+
? otherFields.map(([k, v]) => `${k.replace(/_/g, ' ')}: ${String(v)}`).join(', ')
|
|
1444
|
+
: `Row ${i + 1}`;
|
|
1445
|
+
|
|
1446
|
+
return (
|
|
1447
|
+
<div key={i} style={{
|
|
1448
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
1449
|
+
border: '1px solid var(--color-border, #e2e5e9)',
|
|
1450
|
+
overflow: 'hidden',
|
|
1451
|
+
boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
|
|
1452
|
+
}}>
|
|
1453
|
+
{/* Header bar with other fields + capture button */}
|
|
1454
|
+
{(otherFields.length > 0 || (captureMode && onCapture)) && (
|
|
1455
|
+
<div style={{
|
|
1456
|
+
padding: 'var(--space-xs, 0.375rem) var(--space-md, 0.75rem)',
|
|
1457
|
+
backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
|
|
1458
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
1459
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
1460
|
+
borderBottom: '1px solid var(--color-border-light, #eef0f2)',
|
|
1461
|
+
display: 'flex', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap',
|
|
1462
|
+
alignItems: 'center',
|
|
1463
|
+
}}>
|
|
1464
|
+
{otherFields.map(([k, v]) => (
|
|
1465
|
+
<span key={k}>
|
|
1466
|
+
<strong style={{ textTransform: 'capitalize' as const }}>{k.replace(/_/g, ' ')}</strong>: {String(v)}
|
|
1467
|
+
</span>
|
|
1468
|
+
))}
|
|
1469
|
+
{captureMode && onCapture && (
|
|
1470
|
+
<button
|
|
1471
|
+
title="Capture this comparison"
|
|
1472
|
+
onClick={e => {
|
|
1473
|
+
e.stopPropagation();
|
|
1474
|
+
onCapture({
|
|
1475
|
+
source_view_key: captureViewKey || '',
|
|
1476
|
+
source_section_key: parentSectionKey,
|
|
1477
|
+
source_item_index: i,
|
|
1478
|
+
source_renderer_type: 'comparison_panel',
|
|
1479
|
+
content_type: 'item',
|
|
1480
|
+
selected_text: `${leftLabel}: ${left || '—'} vs ${rightLabel}: ${right || '—'}`.slice(0, 500),
|
|
1481
|
+
structured_data: obj,
|
|
1482
|
+
context_title: parentSectionKey
|
|
1483
|
+
? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${rowLabel}`
|
|
1484
|
+
: `${captureViewKey || 'Analysis'} > ${rowLabel}`,
|
|
1485
|
+
source_type: (captureSourceType || 'analysis') as string,
|
|
1486
|
+
entity_id: captureEntityId || captureJobId || '',
|
|
1487
|
+
depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
|
|
1488
|
+
parent_context: parentSectionKey ? {
|
|
1489
|
+
section_key: parentSectionKey,
|
|
1490
|
+
section_title: parentSectionTitle || '',
|
|
1491
|
+
} : undefined,
|
|
1492
|
+
});
|
|
1493
|
+
}}
|
|
1494
|
+
style={{
|
|
1495
|
+
marginLeft: 'auto',
|
|
1496
|
+
flexShrink: 0,
|
|
1497
|
+
background: 'none',
|
|
1498
|
+
border: '1px solid var(--color-border, #ccc)',
|
|
1499
|
+
borderRadius: '4px',
|
|
1500
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
1501
|
+
cursor: 'pointer',
|
|
1502
|
+
padding: '2px 6px',
|
|
1503
|
+
fontSize: '0.7rem',
|
|
1504
|
+
lineHeight: 1,
|
|
1505
|
+
}}
|
|
1506
|
+
>
|
|
1507
|
+
📌
|
|
1508
|
+
</button>
|
|
1509
|
+
)}
|
|
1510
|
+
</div>
|
|
1511
|
+
)}
|
|
1512
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr' }}>
|
|
1513
|
+
<div style={{
|
|
1514
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
1515
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
1516
|
+
color: 'var(--color-text, #1a1d23)',
|
|
1517
|
+
lineHeight: 'var(--leading-normal, 1.5)',
|
|
1518
|
+
...so?.card_body,
|
|
1519
|
+
}}>
|
|
1520
|
+
{left || <span style={{ color: 'var(--color-text-faint, #9ca3af)', fontStyle: 'italic' }}>—</span>}
|
|
1521
|
+
</div>
|
|
1522
|
+
{/* Vertical divider */}
|
|
1523
|
+
<div style={{
|
|
1524
|
+
width: '2px',
|
|
1525
|
+
background: `linear-gradient(to bottom, ${tokens.surfaces.border_light}, ${tokens.surfaces.border_accent}, ${tokens.surfaces.border_light})`,
|
|
1526
|
+
}} />
|
|
1527
|
+
<div style={{
|
|
1528
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
1529
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
1530
|
+
color: 'var(--color-text, #1a1d23)',
|
|
1531
|
+
lineHeight: 'var(--leading-normal, 1.5)',
|
|
1532
|
+
...so?.card_body,
|
|
1533
|
+
}}>
|
|
1534
|
+
{right || <span style={{ color: 'var(--color-text-faint, #9ca3af)', fontStyle: 'italic' }}>—</span>}
|
|
1535
|
+
</div>
|
|
1536
|
+
</div>
|
|
1537
|
+
</div>
|
|
1538
|
+
);
|
|
1539
|
+
})}
|
|
1540
|
+
</div>
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// ── TimelineStrip → Evolution Arc ────────────────────────
|
|
1545
|
+
|
|
1546
|
+
function TimelineStrip({ data, config }: SubRendererProps) {
|
|
1547
|
+
const { tokens } = useDesignTokens();
|
|
1548
|
+
const so = getSO(config);
|
|
1549
|
+
const [expandedCard, setExpandedCard] = React.useState<string | null>(null);
|
|
1550
|
+
if (!data || !Array.isArray(data)) return null;
|
|
1551
|
+
|
|
1552
|
+
// TimelineStrip uses HSL-derived progressive coloring for evolution arcs.
|
|
1553
|
+
// Derive accent HSL from the token system's accent color.
|
|
1554
|
+
const accent = parseAccentHSL(so?.accent_color || tokens.components.page_accent);
|
|
1555
|
+
|
|
1556
|
+
const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
|
|
1557
|
+
const auto = firstObj ? autoDetectFields(firstObj) : {};
|
|
1558
|
+
const labelField = resolveField(config, 'label_field', auto, 'label');
|
|
1559
|
+
const stagesField = (config.stages_field as string | undefined)
|
|
1560
|
+
|| (firstObj ? Object.entries(firstObj).find(([, v]) => Array.isArray(v))?.[0] : undefined);
|
|
1561
|
+
|
|
1562
|
+
function renderStageNode(stage: unknown, j: number, totalStages: number) {
|
|
1563
|
+
// Visual progression: size and saturation increase from left to right
|
|
1564
|
+
const progress = totalStages > 1 ? j / (totalStages - 1) : 0.5;
|
|
1565
|
+
const saturation = Math.max(accent.s * 0.3, 12) + progress * 30;
|
|
1566
|
+
const bgLightness = 96 - progress * 8;
|
|
1567
|
+
const textLightness = 30 - progress * 10;
|
|
1568
|
+
const borderSat = Math.max(accent.s * 0.4, 15) + progress * 20;
|
|
1569
|
+
|
|
1570
|
+
// Size increases with progress
|
|
1571
|
+
const padV = `${0.4 + progress * 0.3}rem`;
|
|
1572
|
+
const padH = `${0.6 + progress * 0.4}rem`;
|
|
1573
|
+
const fontSize = progress > 0.6
|
|
1574
|
+
? 'var(--type-caption, 0.8125rem)'
|
|
1575
|
+
: 'var(--type-label, 0.6875rem)';
|
|
1576
|
+
|
|
1577
|
+
if (typeof stage === 'string') {
|
|
1578
|
+
return (
|
|
1579
|
+
<div style={{
|
|
1580
|
+
padding: `${padV} ${padH}`,
|
|
1581
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
1582
|
+
backgroundColor: `hsl(${accent.h}, ${saturation}%, ${bgLightness}%)`,
|
|
1583
|
+
border: `1.5px solid hsl(${accent.h}, ${borderSat}%, ${78 - progress * 10}%)`,
|
|
1584
|
+
fontSize,
|
|
1585
|
+
fontWeight: 'var(--weight-medium, 500)' as unknown as number,
|
|
1586
|
+
lineHeight: 'var(--leading-snug, 1.35)',
|
|
1587
|
+
color: `hsl(${accent.h}, ${Math.min(accent.s + 10, 75)}%, ${textLightness}%)`,
|
|
1588
|
+
minWidth: `${130 + progress * 40}px`,
|
|
1589
|
+
maxWidth: '280px',
|
|
1590
|
+
flexShrink: 0,
|
|
1591
|
+
boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
|
|
1592
|
+
...so?.timeline_node,
|
|
1593
|
+
}}>
|
|
1594
|
+
{stage}
|
|
1595
|
+
</div>
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (typeof stage === 'object' && stage !== null) {
|
|
1600
|
+
const obj = stage as Record<string, unknown>;
|
|
1601
|
+
const stageAuto = autoDetectFields(obj);
|
|
1602
|
+
const primaryLabel = getField(obj, 'form') || getField(obj, stageAuto.title)
|
|
1603
|
+
|| getField(obj, 'label') || getField(obj, 'name');
|
|
1604
|
+
const periodLabel = getField(obj, 'period') || getField(obj, 'era') || getField(obj, 'date') || getField(obj, 'year');
|
|
1605
|
+
const secondaryFields = Object.entries(obj).filter(
|
|
1606
|
+
([k, v]) => !['form', 'name', 'title', 'label', 'period', 'era', 'date', 'year'].includes(k)
|
|
1607
|
+
&& typeof v === 'string' && (v as string).length > 0
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
return (
|
|
1611
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
|
|
1612
|
+
{/* Temporal marker above */}
|
|
1613
|
+
{periodLabel && (
|
|
1614
|
+
<div style={{
|
|
1615
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
1616
|
+
fontFamily: 'var(--font-mono, monospace)',
|
|
1617
|
+
fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
|
|
1618
|
+
color: `hsl(${accent.h}, ${Math.min(accent.s + 10, 70)}%, 45%)`,
|
|
1619
|
+
letterSpacing: '0.04em',
|
|
1620
|
+
marginBottom: 'var(--space-xs, 0.25rem)',
|
|
1621
|
+
textAlign: 'center' as const,
|
|
1622
|
+
}}>
|
|
1623
|
+
{periodLabel}
|
|
1624
|
+
</div>
|
|
1625
|
+
)}
|
|
1626
|
+
{/* Stage card */}
|
|
1627
|
+
<div style={{
|
|
1628
|
+
padding: `${padV} ${padH}`,
|
|
1629
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
1630
|
+
backgroundColor: `hsl(${accent.h}, ${saturation}%, ${bgLightness}%)`,
|
|
1631
|
+
border: `1.5px solid hsl(${accent.h}, ${borderSat}%, ${78 - progress * 10}%)`,
|
|
1632
|
+
fontSize,
|
|
1633
|
+
lineHeight: 'var(--leading-snug, 1.35)',
|
|
1634
|
+
color: `hsl(${accent.h}, ${Math.min(accent.s + 10, 75)}%, ${textLightness}%)`,
|
|
1635
|
+
minWidth: `${130 + progress * 40}px`,
|
|
1636
|
+
maxWidth: '280px',
|
|
1637
|
+
boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
|
|
1638
|
+
...so?.timeline_node,
|
|
1639
|
+
}}>
|
|
1640
|
+
<div style={{
|
|
1641
|
+
fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
|
|
1642
|
+
marginBottom: secondaryFields.length > 0 ? 'var(--space-2xs, 0.25rem)' : 0,
|
|
1643
|
+
}}>
|
|
1644
|
+
{primaryLabel || 'Stage ' + (j + 1)}
|
|
1645
|
+
</div>
|
|
1646
|
+
{secondaryFields.slice(0, 2).map(([k, v]) => (
|
|
1647
|
+
<div key={k} style={{
|
|
1648
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
1649
|
+
color: `hsl(${accent.h}, ${Math.max(accent.s * 0.5, 15)}%, ${40 - progress * 5}%)`,
|
|
1650
|
+
marginTop: 'var(--space-2xs, 0.15rem)',
|
|
1651
|
+
}}>
|
|
1652
|
+
<span className="gen-inline-label">
|
|
1653
|
+
{k.replace(/_/g, ' ')}:
|
|
1654
|
+
</span>{' '}
|
|
1655
|
+
{String(v).length > 80 ? String(v).slice(0, 77) + '...' : String(v)}
|
|
1656
|
+
</div>
|
|
1657
|
+
))}
|
|
1658
|
+
</div>
|
|
1659
|
+
</div>
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
return (
|
|
1664
|
+
<div style={{
|
|
1665
|
+
padding: `${padV} ${padH}`,
|
|
1666
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
1667
|
+
backgroundColor: `hsl(${accent.h}, ${saturation}%, ${bgLightness}%)`,
|
|
1668
|
+
border: `1px solid hsl(${accent.h}, ${borderSat}%, ${80 - progress * 8}%)`,
|
|
1669
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
1670
|
+
color: `hsl(${accent.h}, ${Math.min(accent.s + 10, 70)}%, ${textLightness}%)`,
|
|
1671
|
+
flexShrink: 0,
|
|
1672
|
+
...so?.timeline_node,
|
|
1673
|
+
}}>
|
|
1674
|
+
{String(stage)}
|
|
1675
|
+
</div>
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** Render the connecting arrow between stages with gradient */
|
|
1680
|
+
function renderConnector(j: number, totalStages: number) {
|
|
1681
|
+
const progress = totalStages > 1 ? j / (totalStages - 1) : 0;
|
|
1682
|
+
const nextProgress = totalStages > 1 ? (j + 1) / (totalStages - 1) : 1;
|
|
1683
|
+
const startLight = 82 - progress * 25;
|
|
1684
|
+
const endLight = 82 - nextProgress * 25;
|
|
1685
|
+
const startSat = Math.max(accent.s * 0.3, 12) + progress * 25;
|
|
1686
|
+
const endSat = Math.max(accent.s * 0.3, 12) + nextProgress * 25;
|
|
1687
|
+
|
|
1688
|
+
return (
|
|
1689
|
+
<div style={{
|
|
1690
|
+
display: 'flex', alignItems: 'center',
|
|
1691
|
+
flexShrink: 0, padding: '0 2px',
|
|
1692
|
+
alignSelf: 'center',
|
|
1693
|
+
...so?.timeline_connector,
|
|
1694
|
+
}}>
|
|
1695
|
+
<div style={{
|
|
1696
|
+
width: '24px', height: '3px',
|
|
1697
|
+
background: `linear-gradient(to right, hsl(${accent.h}, ${startSat}%, ${startLight}%), hsl(${accent.h}, ${endSat}%, ${endLight}%))`,
|
|
1698
|
+
borderRadius: '2px',
|
|
1699
|
+
position: 'relative' as const,
|
|
1700
|
+
}}>
|
|
1701
|
+
<div style={{
|
|
1702
|
+
position: 'absolute' as const, right: '-5px', top: '-4px',
|
|
1703
|
+
width: 0, height: 0,
|
|
1704
|
+
borderTop: '5.5px solid transparent',
|
|
1705
|
+
borderBottom: '5.5px solid transparent',
|
|
1706
|
+
borderLeft: `8px solid hsl(${accent.h}, ${endSat}%, ${endLight}%)`,
|
|
1707
|
+
}} />
|
|
1708
|
+
</div>
|
|
1709
|
+
</div>
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
return (
|
|
1714
|
+
<div style={{
|
|
1715
|
+
display: 'flex', flexDirection: 'column',
|
|
1716
|
+
gap: 'var(--space-lg, 1.25rem)',
|
|
1717
|
+
...so?.items_container,
|
|
1718
|
+
}}>
|
|
1719
|
+
{data.map((item, i) => {
|
|
1720
|
+
if (typeof item !== 'object' || item === null) return null;
|
|
1721
|
+
const obj = item as Record<string, unknown>;
|
|
1722
|
+
const label = getField(obj, labelField);
|
|
1723
|
+
const stages = stagesField ? obj[stagesField] : null;
|
|
1724
|
+
const cardId = `${label || i}`;
|
|
1725
|
+
const isExpanded = expandedCard === cardId;
|
|
1726
|
+
|
|
1727
|
+
const metaFields = Object.entries(obj).filter(
|
|
1728
|
+
([k, v]) => k !== labelField && k !== stagesField && v !== null && v !== undefined && v !== ''
|
|
1729
|
+
);
|
|
1730
|
+
|
|
1731
|
+
return (
|
|
1732
|
+
<div key={i} style={{
|
|
1733
|
+
minWidth: 0, overflow: 'hidden',
|
|
1734
|
+
borderRadius: 'var(--radius-lg, 12px)',
|
|
1735
|
+
border: '1px solid var(--color-border, #e2e5e9)',
|
|
1736
|
+
backgroundColor: 'var(--color-surface, #ffffff)',
|
|
1737
|
+
boxShadow: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.06))',
|
|
1738
|
+
...so?.card,
|
|
1739
|
+
}}>
|
|
1740
|
+
{/* Header */}
|
|
1741
|
+
{label && (
|
|
1742
|
+
<div
|
|
1743
|
+
onClick={() => setExpandedCard(isExpanded ? null : cardId)}
|
|
1744
|
+
style={{
|
|
1745
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
1746
|
+
fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
|
|
1747
|
+
color: 'var(--color-text, #1a1d23)',
|
|
1748
|
+
padding: 'var(--space-sm, 0.625rem) var(--space-md, 1rem)',
|
|
1749
|
+
backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
|
|
1750
|
+
borderBottom: '1px solid var(--color-border-light, #eef0f2)',
|
|
1751
|
+
cursor: metaFields.length > 0 ? 'pointer' : 'default',
|
|
1752
|
+
display: 'flex', alignItems: 'center',
|
|
1753
|
+
gap: 'var(--space-sm, 0.5rem)',
|
|
1754
|
+
...so?.card_header,
|
|
1755
|
+
}}>
|
|
1756
|
+
{label}
|
|
1757
|
+
{Array.isArray(stages) && (
|
|
1758
|
+
<span style={{
|
|
1759
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
1760
|
+
fontWeight: 'var(--weight-medium, 500)' as unknown as number,
|
|
1761
|
+
color: 'var(--color-text-faint, #9ca3af)',
|
|
1762
|
+
backgroundColor: tokens.surfaces.surface_inset,
|
|
1763
|
+
padding: '2px 8px',
|
|
1764
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
1765
|
+
...so?.badge,
|
|
1766
|
+
}}>
|
|
1767
|
+
{stages.length} stages
|
|
1768
|
+
</span>
|
|
1769
|
+
)}
|
|
1770
|
+
</div>
|
|
1771
|
+
)}
|
|
1772
|
+
|
|
1773
|
+
{/* Timeline strip */}
|
|
1774
|
+
{Array.isArray(stages) && stages.length > 0 && (
|
|
1775
|
+
<div style={{
|
|
1776
|
+
padding: 'var(--space-md, 0.75rem)',
|
|
1777
|
+
overflowX: 'auto' as const,
|
|
1778
|
+
}}>
|
|
1779
|
+
<div style={{
|
|
1780
|
+
display: 'flex', alignItems: 'flex-end',
|
|
1781
|
+
gap: 0,
|
|
1782
|
+
minWidth: 'min-content',
|
|
1783
|
+
}}>
|
|
1784
|
+
{stages.map((stage, j) => (
|
|
1785
|
+
<React.Fragment key={j}>
|
|
1786
|
+
{renderStageNode(stage, j, stages.length)}
|
|
1787
|
+
{j < stages.length - 1 && renderConnector(j, stages.length)}
|
|
1788
|
+
</React.Fragment>
|
|
1789
|
+
))}
|
|
1790
|
+
</div>
|
|
1791
|
+
</div>
|
|
1792
|
+
)}
|
|
1793
|
+
|
|
1794
|
+
{/* Expanded metadata */}
|
|
1795
|
+
{isExpanded && metaFields.length > 0 && (
|
|
1796
|
+
<div style={{
|
|
1797
|
+
padding: 'var(--space-sm, 0.625rem) var(--space-md, 1rem)',
|
|
1798
|
+
borderTop: '1px solid var(--color-border-light, #eef0f2)',
|
|
1799
|
+
backgroundColor: 'var(--color-surface, #ffffff)',
|
|
1800
|
+
}}>
|
|
1801
|
+
{metaFields.map(([k, v]) => (
|
|
1802
|
+
<div key={k} style={{ marginBottom: 'var(--space-2xs, 0.25rem)' }}>
|
|
1803
|
+
<span className="gen-inline-label">
|
|
1804
|
+
{k.replace(/_/g, ' ')}:
|
|
1805
|
+
</span>
|
|
1806
|
+
<span style={{
|
|
1807
|
+
marginLeft: 'var(--space-xs, 0.375rem)',
|
|
1808
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
1809
|
+
color: 'var(--color-text, #1a1d23)',
|
|
1810
|
+
}}>
|
|
1811
|
+
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v)}
|
|
1812
|
+
</span>
|
|
1813
|
+
</div>
|
|
1814
|
+
))}
|
|
1815
|
+
</div>
|
|
1816
|
+
)}
|
|
1817
|
+
|
|
1818
|
+
{/* Non-stage fallback */}
|
|
1819
|
+
{(!stages || !Array.isArray(stages) || stages.length === 0) && metaFields.length > 0 && (
|
|
1820
|
+
<div style={{
|
|
1821
|
+
padding: 'var(--space-sm, 0.625rem) var(--space-md, 1rem)',
|
|
1822
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
1823
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
1824
|
+
}}>
|
|
1825
|
+
{metaFields.map(([k, v]) => (
|
|
1826
|
+
<div key={k} style={{ marginBottom: 'var(--space-2xs, 0.25rem)' }}>
|
|
1827
|
+
<strong style={{
|
|
1828
|
+
textTransform: 'capitalize' as const,
|
|
1829
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
1830
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
1831
|
+
}}>
|
|
1832
|
+
{k.replace(/_/g, ' ')}:
|
|
1833
|
+
</strong>{' '}
|
|
1834
|
+
{typeof v === 'string' ? v : JSON.stringify(v)}
|
|
1835
|
+
</div>
|
|
1836
|
+
))}
|
|
1837
|
+
</div>
|
|
1838
|
+
)}
|
|
1839
|
+
</div>
|
|
1840
|
+
);
|
|
1841
|
+
})}
|
|
1842
|
+
</div>
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// ── IntensityMatrix → Dashboard-style Intensity Bars ────────
|
|
1847
|
+
//
|
|
1848
|
+
// Each row: title + category badge + horizontal bar + expandable description.
|
|
1849
|
+
// Bars width proportional to ordinal intensity level. Sorted by intensity (highest first).
|
|
1850
|
+
//
|
|
1851
|
+
// Config:
|
|
1852
|
+
// title_field — row title
|
|
1853
|
+
// subtitle_field — category badge
|
|
1854
|
+
// intensity_field — ordinal intensity value
|
|
1855
|
+
// intensity_scale — ordered levels, e.g. ['low', 'medium', 'high']
|
|
1856
|
+
// description_field — expandable text
|
|
1857
|
+
// sort_by_intensity — sort by intensity descending (default true)
|
|
1858
|
+
|
|
1859
|
+
function IntensityMatrix({ data, config }: SubRendererProps) {
|
|
1860
|
+
const [expandedIdx, setExpandedIdx] = React.useState<Set<number>>(new Set());
|
|
1861
|
+
const { tokens, getSemanticColor } = useDesignTokens();
|
|
1862
|
+
|
|
1863
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
1864
|
+
const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
|
|
1865
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
1866
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
1867
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
1868
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
1869
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
1870
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
1871
|
+
|
|
1872
|
+
if (!data || !Array.isArray(data) || data.length === 0) return null;
|
|
1873
|
+
|
|
1874
|
+
const titleField = (config.title_field as string) || 'name';
|
|
1875
|
+
const subtitleField = (config.subtitle_field as string) || undefined;
|
|
1876
|
+
const intensityField = (config.intensity_field as string) || 'intensity';
|
|
1877
|
+
const descField = (config.description_field as string) || 'description';
|
|
1878
|
+
const sortByIntensity = config.sort_by_intensity !== false;
|
|
1879
|
+
|
|
1880
|
+
// Build intensity scale from config or infer from data
|
|
1881
|
+
const configScale = config.intensity_scale as string[] | undefined;
|
|
1882
|
+
const intensityScale: string[] = configScale
|
|
1883
|
+
|| (() => {
|
|
1884
|
+
const vals = new Set<string>();
|
|
1885
|
+
data.forEach(item => {
|
|
1886
|
+
if (typeof item === 'object' && item !== null) {
|
|
1887
|
+
const v = (item as Record<string, unknown>)[intensityField];
|
|
1888
|
+
if (typeof v === 'string') vals.add(v.toLowerCase());
|
|
1889
|
+
}
|
|
1890
|
+
});
|
|
1891
|
+
// Default ordering heuristic
|
|
1892
|
+
const ordered = ['rare', 'low', 'minimal', 'occasional', 'moderate', 'medium', 'frequent', 'significant', 'high', 'very_high', 'critical'];
|
|
1893
|
+
const found = ordered.filter(l => vals.has(l));
|
|
1894
|
+
return found.length > 0 ? found : Array.from(vals);
|
|
1895
|
+
})();
|
|
1896
|
+
|
|
1897
|
+
// Map intensity value to 0–1 fraction
|
|
1898
|
+
const getIntensityFraction = (val: string): number => {
|
|
1899
|
+
const lower = val.toLowerCase().replace(/\s+/g, '_');
|
|
1900
|
+
const idx = intensityScale.findIndex(s => s.toLowerCase().replace(/\s+/g, '_') === lower);
|
|
1901
|
+
if (idx === -1) return 0.5;
|
|
1902
|
+
if (intensityScale.length <= 1) return 1;
|
|
1903
|
+
return (idx + 1) / intensityScale.length;
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
// Color for intensity bar — use semantic severity or series palette
|
|
1907
|
+
const getBarColor = (fraction: number): string => {
|
|
1908
|
+
const palette = tokens.primitives.series_palette;
|
|
1909
|
+
if (fraction >= 0.8) {
|
|
1910
|
+
const sem = getSemanticColor('severity', 'high');
|
|
1911
|
+
return sem?.bg || palette[0];
|
|
1912
|
+
}
|
|
1913
|
+
if (fraction >= 0.5) {
|
|
1914
|
+
const sem = getSemanticColor('severity', 'medium');
|
|
1915
|
+
return sem?.bg || palette[4];
|
|
1916
|
+
}
|
|
1917
|
+
const sem = getSemanticColor('severity', 'low');
|
|
1918
|
+
return sem?.bg || palette[2];
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
const getBarTextColor = (fraction: number): string => {
|
|
1922
|
+
if (fraction >= 0.8) {
|
|
1923
|
+
const sem = getSemanticColor('severity', 'high');
|
|
1924
|
+
return sem?.text || tokens.surfaces.text_default;
|
|
1925
|
+
}
|
|
1926
|
+
if (fraction >= 0.5) {
|
|
1927
|
+
const sem = getSemanticColor('severity', 'medium');
|
|
1928
|
+
return sem?.text || tokens.surfaces.text_default;
|
|
1929
|
+
}
|
|
1930
|
+
const sem = getSemanticColor('severity', 'low');
|
|
1931
|
+
return sem?.text || tokens.surfaces.text_default;
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
// Prepare and optionally sort items
|
|
1935
|
+
const items = data
|
|
1936
|
+
.map((item, origIdx) => {
|
|
1937
|
+
if (typeof item !== 'object' || item === null) return null;
|
|
1938
|
+
const obj = item as Record<string, unknown>;
|
|
1939
|
+
const intensityVal = getField(obj, intensityField);
|
|
1940
|
+
const fraction = getIntensityFraction(intensityVal);
|
|
1941
|
+
return { obj, origIdx, intensityVal, fraction };
|
|
1942
|
+
})
|
|
1943
|
+
.filter(Boolean) as Array<{
|
|
1944
|
+
obj: Record<string, unknown>;
|
|
1945
|
+
origIdx: number;
|
|
1946
|
+
intensityVal: string;
|
|
1947
|
+
fraction: number;
|
|
1948
|
+
}>;
|
|
1949
|
+
|
|
1950
|
+
if (sortByIntensity) {
|
|
1951
|
+
items.sort((a, b) => b.fraction - a.fraction);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
const toggleExpand = (idx: number) => {
|
|
1955
|
+
setExpandedIdx(prev => {
|
|
1956
|
+
const next = new Set(prev);
|
|
1957
|
+
if (next.has(idx)) next.delete(idx);
|
|
1958
|
+
else next.add(idx);
|
|
1959
|
+
return next;
|
|
1960
|
+
});
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1963
|
+
const DESC_LIMIT = Infinity; // Show full text — no truncation
|
|
1964
|
+
|
|
1965
|
+
return (
|
|
1966
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
|
1967
|
+
{items.map((item, displayIdx) => {
|
|
1968
|
+
const { obj, origIdx, intensityVal, fraction } = item;
|
|
1969
|
+
const title = getField(obj, titleField);
|
|
1970
|
+
const subtitle = subtitleField ? getField(obj, subtitleField) : '';
|
|
1971
|
+
const desc = getField(obj, descField);
|
|
1972
|
+
const isExpanded = expandedIdx.has(displayIdx);
|
|
1973
|
+
const needsTruncation = desc.length > DESC_LIMIT;
|
|
1974
|
+
const barColor = getBarColor(fraction);
|
|
1975
|
+
const barTextColor = getBarTextColor(fraction);
|
|
1976
|
+
const barWidth = Math.max(fraction * 100, 8); // minimum 8% for visibility
|
|
1977
|
+
|
|
1978
|
+
return (
|
|
1979
|
+
<div
|
|
1980
|
+
key={origIdx}
|
|
1981
|
+
style={{
|
|
1982
|
+
backgroundColor: 'var(--color-surface, #ffffff)',
|
|
1983
|
+
borderLeft: `3px solid ${barColor}`,
|
|
1984
|
+
padding: '0',
|
|
1985
|
+
borderRadius: '0 var(--radius-sm, 4px) var(--radius-sm, 4px) 0',
|
|
1986
|
+
}}
|
|
1987
|
+
>
|
|
1988
|
+
{/* Main row */}
|
|
1989
|
+
<div
|
|
1990
|
+
style={{
|
|
1991
|
+
display: 'grid',
|
|
1992
|
+
gridTemplateColumns: '1fr auto minmax(80px, 160px)',
|
|
1993
|
+
gap: 'var(--space-sm, 0.5rem)',
|
|
1994
|
+
alignItems: 'center',
|
|
1995
|
+
padding: 'var(--space-xs, 0.375rem) var(--space-md, 1rem)',
|
|
1996
|
+
cursor: desc ? 'pointer' : 'default',
|
|
1997
|
+
}}
|
|
1998
|
+
onClick={() => desc && toggleExpand(displayIdx)}
|
|
1999
|
+
>
|
|
2000
|
+
{/* Title + subtitle */}
|
|
2001
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap', minWidth: 0 }}>
|
|
2002
|
+
<span style={{
|
|
2003
|
+
fontWeight: 600,
|
|
2004
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
2005
|
+
color: 'var(--color-text, #1a1d23)',
|
|
2006
|
+
lineHeight: 'var(--leading-snug, 1.4)',
|
|
2007
|
+
}}>
|
|
2008
|
+
{title}
|
|
2009
|
+
</span>
|
|
2010
|
+
{subtitle && (
|
|
2011
|
+
<span style={{
|
|
2012
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
2013
|
+
fontWeight: 500,
|
|
2014
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
2015
|
+
padding: '1px 8px',
|
|
2016
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
2017
|
+
backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
|
|
2018
|
+
whiteSpace: 'nowrap',
|
|
2019
|
+
}}>
|
|
2020
|
+
{subtitle}
|
|
2021
|
+
</span>
|
|
2022
|
+
)}
|
|
2023
|
+
{captureMode && onCapture && (
|
|
2024
|
+
<button
|
|
2025
|
+
title="Capture this item"
|
|
2026
|
+
onClick={e => {
|
|
2027
|
+
e.stopPropagation();
|
|
2028
|
+
onCapture({
|
|
2029
|
+
source_view_key: captureViewKey || '',
|
|
2030
|
+
source_item_index: origIdx,
|
|
2031
|
+
source_renderer_type: 'intensity_matrix',
|
|
2032
|
+
content_type: 'item',
|
|
2033
|
+
selected_text: `${title} [${intensityVal}]: ${desc}`.slice(0, 500),
|
|
2034
|
+
structured_data: obj,
|
|
2035
|
+
context_title: parentSectionKey
|
|
2036
|
+
? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${title}`
|
|
2037
|
+
: `${captureViewKey || 'Analysis'} > ${title}`,
|
|
2038
|
+
source_type: (captureSourceType || 'analysis') as string,
|
|
2039
|
+
entity_id: captureEntityId || captureJobId || '',
|
|
2040
|
+
depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
|
|
2041
|
+
parent_context: parentSectionKey ? {
|
|
2042
|
+
section_key: parentSectionKey,
|
|
2043
|
+
section_title: parentSectionTitle || '',
|
|
2044
|
+
} : undefined,
|
|
2045
|
+
});
|
|
2046
|
+
}}
|
|
2047
|
+
style={{
|
|
2048
|
+
background: 'none',
|
|
2049
|
+
border: '1px solid var(--color-border, #ccc)',
|
|
2050
|
+
borderRadius: '4px',
|
|
2051
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
2052
|
+
cursor: 'pointer',
|
|
2053
|
+
padding: '2px 6px',
|
|
2054
|
+
fontSize: '0.7rem',
|
|
2055
|
+
lineHeight: 1,
|
|
2056
|
+
}}
|
|
2057
|
+
>
|
|
2058
|
+
📌
|
|
2059
|
+
</button>
|
|
2060
|
+
)}
|
|
2061
|
+
</div>
|
|
2062
|
+
|
|
2063
|
+
{/* Intensity label */}
|
|
2064
|
+
<span style={{
|
|
2065
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
2066
|
+
fontWeight: 700,
|
|
2067
|
+
color: barTextColor,
|
|
2068
|
+
textTransform: 'uppercase',
|
|
2069
|
+
letterSpacing: '0.04em',
|
|
2070
|
+
whiteSpace: 'nowrap',
|
|
2071
|
+
}}>
|
|
2072
|
+
{intensityVal.replace(/_/g, ' ')}
|
|
2073
|
+
</span>
|
|
2074
|
+
|
|
2075
|
+
{/* Intensity bar */}
|
|
2076
|
+
<div style={{
|
|
2077
|
+
height: '8px',
|
|
2078
|
+
backgroundColor: 'var(--color-surface-alt, #f0f1f3)',
|
|
2079
|
+
borderRadius: '4px',
|
|
2080
|
+
overflow: 'hidden',
|
|
2081
|
+
}}>
|
|
2082
|
+
<div style={{
|
|
2083
|
+
width: `${barWidth}%`,
|
|
2084
|
+
height: '100%',
|
|
2085
|
+
backgroundColor: barColor,
|
|
2086
|
+
borderRadius: '4px',
|
|
2087
|
+
transition: 'width 300ms ease',
|
|
2088
|
+
}} />
|
|
2089
|
+
</div>
|
|
2090
|
+
</div>
|
|
2091
|
+
|
|
2092
|
+
{/* Expandable description */}
|
|
2093
|
+
{desc && isExpanded && (
|
|
2094
|
+
<div style={{
|
|
2095
|
+
padding: '0 var(--space-md, 1rem) var(--space-sm, 0.5rem)',
|
|
2096
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
2097
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
2098
|
+
lineHeight: 'var(--leading-relaxed, 1.65)',
|
|
2099
|
+
borderTop: '1px solid var(--color-border-light, #eef0f2)',
|
|
2100
|
+
marginTop: '2px',
|
|
2101
|
+
paddingTop: 'var(--space-xs, 0.375rem)',
|
|
2102
|
+
}}>
|
|
2103
|
+
{desc}
|
|
2104
|
+
</div>
|
|
2105
|
+
)}
|
|
2106
|
+
{desc && !isExpanded && needsTruncation && (
|
|
2107
|
+
<div style={{
|
|
2108
|
+
padding: '0 var(--space-md, 1rem) var(--space-xs, 0.25rem)',
|
|
2109
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
2110
|
+
color: 'var(--color-text-faint, #9ca3af)',
|
|
2111
|
+
}}>
|
|
2112
|
+
{desc.slice(0, DESC_LIMIT)}...
|
|
2113
|
+
</div>
|
|
2114
|
+
)}
|
|
2115
|
+
</div>
|
|
2116
|
+
);
|
|
2117
|
+
})}
|
|
2118
|
+
</div>
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// ── MoveRepertoire → Grouped Intellectual Gestures ──────────
|
|
2123
|
+
//
|
|
2124
|
+
// Items grouped by a category field. Each group has a colored header with
|
|
2125
|
+
// count badge. Items within groups are compact rows. Groups are collapsible.
|
|
2126
|
+
//
|
|
2127
|
+
// Config:
|
|
2128
|
+
// title_field — item title
|
|
2129
|
+
// group_field — field to group by
|
|
2130
|
+
// description_field — item description
|
|
2131
|
+
// badge_field — optional extra badge per item
|
|
2132
|
+
// collapse_groups — start collapsed (default false)
|
|
2133
|
+
|
|
2134
|
+
function MoveRepertoire({ data, config }: SubRendererProps) {
|
|
2135
|
+
const { tokens } = useDesignTokens();
|
|
2136
|
+
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
|
|
2137
|
+
const [expandedDescs, setExpandedDescs] = React.useState<Set<string>>(new Set());
|
|
2138
|
+
const [initialized, setInitialized] = React.useState(false);
|
|
2139
|
+
|
|
2140
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
2141
|
+
const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
|
|
2142
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
2143
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
2144
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
2145
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
2146
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
2147
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
2148
|
+
|
|
2149
|
+
if (!data || !Array.isArray(data) || data.length === 0) return null;
|
|
2150
|
+
|
|
2151
|
+
const titleField = (config.title_field as string) || 'name';
|
|
2152
|
+
const groupField = (config.group_field as string) || 'type';
|
|
2153
|
+
const descField = (config.description_field as string) || 'description';
|
|
2154
|
+
const badgeField = (config.badge_field as string) || undefined;
|
|
2155
|
+
const startCollapsed = config.collapse_groups === true;
|
|
2156
|
+
|
|
2157
|
+
// Group items
|
|
2158
|
+
const groups = new Map<string, Array<{ obj: Record<string, unknown>; origIdx: number }>>();
|
|
2159
|
+
data.forEach((item, idx) => {
|
|
2160
|
+
if (typeof item !== 'object' || item === null) return;
|
|
2161
|
+
const obj = item as Record<string, unknown>;
|
|
2162
|
+
const groupVal = getField(obj, groupField) || 'Other';
|
|
2163
|
+
if (!groups.has(groupVal)) groups.set(groupVal, []);
|
|
2164
|
+
groups.get(groupVal)!.push({ obj, origIdx: idx });
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
// Initialize collapsed state on first render with data
|
|
2168
|
+
if (!initialized && startCollapsed && groups.size > 0) {
|
|
2169
|
+
setCollapsedGroups(new Set(groups.keys()));
|
|
2170
|
+
setInitialized(true);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const toggleGroup = (group: string) => {
|
|
2174
|
+
setCollapsedGroups(prev => {
|
|
2175
|
+
const next = new Set(prev);
|
|
2176
|
+
if (next.has(group)) next.delete(group);
|
|
2177
|
+
else next.add(group);
|
|
2178
|
+
return next;
|
|
2179
|
+
});
|
|
2180
|
+
};
|
|
2181
|
+
|
|
2182
|
+
const toggleDesc = (key: string) => {
|
|
2183
|
+
setExpandedDescs(prev => {
|
|
2184
|
+
const next = new Set(prev);
|
|
2185
|
+
if (next.has(key)) next.delete(key);
|
|
2186
|
+
else next.add(key);
|
|
2187
|
+
return next;
|
|
2188
|
+
});
|
|
2189
|
+
};
|
|
2190
|
+
|
|
2191
|
+
const palette = tokens.primitives.series_palette;
|
|
2192
|
+
const DESC_LIMIT = Infinity; // Show full text — no truncation
|
|
2193
|
+
|
|
2194
|
+
const groupEntries = Array.from(groups.entries());
|
|
2195
|
+
|
|
2196
|
+
return (
|
|
2197
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md, 1rem)' }}>
|
|
2198
|
+
{groupEntries.map(([groupName, items], groupIdx) => {
|
|
2199
|
+
const isCollapsed = collapsedGroups.has(groupName);
|
|
2200
|
+
const groupColor = palette[groupIdx % palette.length];
|
|
2201
|
+
|
|
2202
|
+
return (
|
|
2203
|
+
<div key={groupName} style={{
|
|
2204
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
2205
|
+
border: '1px solid var(--color-border, #e2e5e9)',
|
|
2206
|
+
overflow: 'hidden',
|
|
2207
|
+
}}>
|
|
2208
|
+
{/* Group header */}
|
|
2209
|
+
<div
|
|
2210
|
+
onClick={() => toggleGroup(groupName)}
|
|
2211
|
+
style={{
|
|
2212
|
+
display: 'flex',
|
|
2213
|
+
alignItems: 'center',
|
|
2214
|
+
gap: 'var(--space-sm, 0.5rem)',
|
|
2215
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
2216
|
+
backgroundColor: groupColor,
|
|
2217
|
+
color: tokens.surfaces.text_on_accent,
|
|
2218
|
+
cursor: 'pointer',
|
|
2219
|
+
userSelect: 'none',
|
|
2220
|
+
}}
|
|
2221
|
+
>
|
|
2222
|
+
<span style={{
|
|
2223
|
+
fontSize: '11px',
|
|
2224
|
+
transition: 'transform 150ms ease',
|
|
2225
|
+
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
|
|
2226
|
+
display: 'inline-block',
|
|
2227
|
+
}}>
|
|
2228
|
+
▾
|
|
2229
|
+
</span>
|
|
2230
|
+
<span style={{
|
|
2231
|
+
fontWeight: 600,
|
|
2232
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
2233
|
+
textTransform: 'capitalize',
|
|
2234
|
+
}}>
|
|
2235
|
+
{groupName.replace(/_/g, ' ')}
|
|
2236
|
+
</span>
|
|
2237
|
+
<span style={{
|
|
2238
|
+
marginLeft: 'auto',
|
|
2239
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
2240
|
+
fontWeight: 700,
|
|
2241
|
+
backgroundColor: 'rgba(255,255,255,0.25)',
|
|
2242
|
+
padding: '1px 8px',
|
|
2243
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
2244
|
+
}}>
|
|
2245
|
+
{items.length}
|
|
2246
|
+
</span>
|
|
2247
|
+
</div>
|
|
2248
|
+
|
|
2249
|
+
{/* Group items */}
|
|
2250
|
+
{!isCollapsed && (
|
|
2251
|
+
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
2252
|
+
{items.map(({ obj, origIdx }, itemIdx) => {
|
|
2253
|
+
const title = getField(obj, titleField);
|
|
2254
|
+
const desc = getField(obj, descField);
|
|
2255
|
+
const badge = badgeField ? getField(obj, badgeField) : '';
|
|
2256
|
+
const descKey = `${groupName}-${itemIdx}`;
|
|
2257
|
+
const isDescExpanded = expandedDescs.has(descKey);
|
|
2258
|
+
const needsTruncation = desc.length > DESC_LIMIT;
|
|
2259
|
+
|
|
2260
|
+
return (
|
|
2261
|
+
<div
|
|
2262
|
+
key={itemIdx}
|
|
2263
|
+
style={{
|
|
2264
|
+
padding: 'var(--space-xs, 0.375rem) var(--space-md, 1rem)',
|
|
2265
|
+
borderBottom: itemIdx < items.length - 1 ? '1px solid var(--color-border-light, #eef0f2)' : 'none',
|
|
2266
|
+
backgroundColor: itemIdx % 2 === 0 ? 'var(--color-surface, #ffffff)' : 'var(--color-surface-alt, #fafbfc)',
|
|
2267
|
+
}}
|
|
2268
|
+
>
|
|
2269
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap' }}>
|
|
2270
|
+
<span style={{
|
|
2271
|
+
fontWeight: 600,
|
|
2272
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
2273
|
+
color: 'var(--color-text, #1a1d23)',
|
|
2274
|
+
lineHeight: 'var(--leading-snug, 1.4)',
|
|
2275
|
+
}}>
|
|
2276
|
+
{title}
|
|
2277
|
+
</span>
|
|
2278
|
+
{badge && (
|
|
2279
|
+
<span style={{
|
|
2280
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
2281
|
+
fontWeight: 500,
|
|
2282
|
+
color: groupColor,
|
|
2283
|
+
padding: '1px 8px',
|
|
2284
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
2285
|
+
backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
|
|
2286
|
+
}}>
|
|
2287
|
+
{badge}
|
|
2288
|
+
</span>
|
|
2289
|
+
)}
|
|
2290
|
+
{captureMode && onCapture && (
|
|
2291
|
+
<button
|
|
2292
|
+
title="Capture this item"
|
|
2293
|
+
onClick={e => {
|
|
2294
|
+
e.stopPropagation();
|
|
2295
|
+
onCapture({
|
|
2296
|
+
source_view_key: captureViewKey || '',
|
|
2297
|
+
source_item_index: origIdx,
|
|
2298
|
+
source_renderer_type: 'move_repertoire',
|
|
2299
|
+
content_type: 'item',
|
|
2300
|
+
selected_text: `[${groupName}] ${title}: ${desc}`.slice(0, 500),
|
|
2301
|
+
structured_data: obj,
|
|
2302
|
+
context_title: parentSectionKey
|
|
2303
|
+
? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${groupName} > ${title}`
|
|
2304
|
+
: `${captureViewKey || 'Analysis'} > ${groupName} > ${title}`,
|
|
2305
|
+
source_type: (captureSourceType || 'analysis') as string,
|
|
2306
|
+
entity_id: captureEntityId || captureJobId || '',
|
|
2307
|
+
depth_level: 'L2_element',
|
|
2308
|
+
parent_context: parentSectionKey ? {
|
|
2309
|
+
section_key: parentSectionKey,
|
|
2310
|
+
section_title: parentSectionTitle || '',
|
|
2311
|
+
} : undefined,
|
|
2312
|
+
});
|
|
2313
|
+
}}
|
|
2314
|
+
style={{
|
|
2315
|
+
background: 'none',
|
|
2316
|
+
border: '1px solid var(--color-border, #ccc)',
|
|
2317
|
+
borderRadius: '4px',
|
|
2318
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
2319
|
+
cursor: 'pointer',
|
|
2320
|
+
padding: '2px 6px',
|
|
2321
|
+
fontSize: '0.7rem',
|
|
2322
|
+
lineHeight: 1,
|
|
2323
|
+
marginLeft: 'auto',
|
|
2324
|
+
}}
|
|
2325
|
+
>
|
|
2326
|
+
📌
|
|
2327
|
+
</button>
|
|
2328
|
+
)}
|
|
2329
|
+
</div>
|
|
2330
|
+
{desc && (
|
|
2331
|
+
<div
|
|
2332
|
+
style={{
|
|
2333
|
+
marginTop: '2px',
|
|
2334
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
2335
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
2336
|
+
lineHeight: 'var(--leading-relaxed, 1.6)',
|
|
2337
|
+
cursor: needsTruncation ? 'pointer' : 'default',
|
|
2338
|
+
}}
|
|
2339
|
+
onClick={() => needsTruncation && toggleDesc(descKey)}
|
|
2340
|
+
>
|
|
2341
|
+
{needsTruncation && !isDescExpanded
|
|
2342
|
+
? desc.slice(0, DESC_LIMIT) + '...'
|
|
2343
|
+
: desc}
|
|
2344
|
+
</div>
|
|
2345
|
+
)}
|
|
2346
|
+
</div>
|
|
2347
|
+
);
|
|
2348
|
+
})}
|
|
2349
|
+
</div>
|
|
2350
|
+
)}
|
|
2351
|
+
</div>
|
|
2352
|
+
);
|
|
2353
|
+
})}
|
|
2354
|
+
</div>
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// ── DialecticalPair → Tension/Contrast Visualization ────────
|
|
2359
|
+
//
|
|
2360
|
+
// Two panels with a central tension indicator. Left = thesis/foregrounded,
|
|
2361
|
+
// right = antithesis/suppressed. Central node shows relationship type.
|
|
2362
|
+
//
|
|
2363
|
+
// Config:
|
|
2364
|
+
// left_key, right_key — field names or sub-section keys for left/right
|
|
2365
|
+
// left_label, right_label — panel headers
|
|
2366
|
+
// relationship_label — central node text (default "vs")
|
|
2367
|
+
// left_title_field, right_title_field — title fields within items
|
|
2368
|
+
// left_description_field, right_description_field — description fields
|
|
2369
|
+
|
|
2370
|
+
function DialecticalPair({ data, config }: SubRendererProps) {
|
|
2371
|
+
const { tokens } = useDesignTokens();
|
|
2372
|
+
const [expandedLeft, setExpandedLeft] = React.useState<Set<number>>(new Set());
|
|
2373
|
+
const [expandedRight, setExpandedRight] = React.useState<Set<number>>(new Set());
|
|
2374
|
+
|
|
2375
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
2376
|
+
const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
|
|
2377
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
2378
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
2379
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
2380
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
2381
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
2382
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
2383
|
+
|
|
2384
|
+
const leftKey = (config.left_key as string) || 'left';
|
|
2385
|
+
const rightKey = (config.right_key as string) || 'right';
|
|
2386
|
+
const leftLabel = (config.left_label as string) || leftKey.replace(/_/g, ' ');
|
|
2387
|
+
const rightLabel = (config.right_label as string) || rightKey.replace(/_/g, ' ');
|
|
2388
|
+
const relationshipLabel = (config.relationship_label as string) || 'vs';
|
|
2389
|
+
|
|
2390
|
+
const leftTitleField = (config.left_title_field as string) || undefined;
|
|
2391
|
+
const rightTitleField = (config.right_title_field as string) || undefined;
|
|
2392
|
+
const leftDescField = (config.left_description_field as string) || undefined;
|
|
2393
|
+
const rightDescField = (config.right_description_field as string) || undefined;
|
|
2394
|
+
|
|
2395
|
+
// Resolve data shape: could be {left_key: [...], right_key: [...]} or [{left_field, right_field}, ...]
|
|
2396
|
+
let leftItems: Array<Record<string, unknown>> = [];
|
|
2397
|
+
let rightItems: Array<Record<string, unknown>> = [];
|
|
2398
|
+
|
|
2399
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
2400
|
+
// Object with left/right sub-arrays
|
|
2401
|
+
const obj = data as Record<string, unknown>;
|
|
2402
|
+
const rawLeft = obj[leftKey];
|
|
2403
|
+
const rawRight = obj[rightKey];
|
|
2404
|
+
if (Array.isArray(rawLeft)) leftItems = rawLeft.filter(x => typeof x === 'object' && x !== null) as Array<Record<string, unknown>>;
|
|
2405
|
+
if (Array.isArray(rawRight)) rightItems = rawRight.filter(x => typeof x === 'object' && x !== null) as Array<Record<string, unknown>>;
|
|
2406
|
+
} else if (Array.isArray(data)) {
|
|
2407
|
+
// Array of paired objects — split using left_key/right_key as field names
|
|
2408
|
+
data.forEach(item => {
|
|
2409
|
+
if (typeof item !== 'object' || item === null) return;
|
|
2410
|
+
const obj = item as Record<string, unknown>;
|
|
2411
|
+
// If fields exist, treat as paired data
|
|
2412
|
+
const leftVal = obj[leftKey];
|
|
2413
|
+
const rightVal = obj[rightKey];
|
|
2414
|
+
if (leftVal !== undefined || rightVal !== undefined) {
|
|
2415
|
+
leftItems.push({ text: leftVal, ...obj });
|
|
2416
|
+
rightItems.push({ text: rightVal, ...obj });
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
if (leftItems.length === 0 && rightItems.length === 0) return null;
|
|
2422
|
+
|
|
2423
|
+
const palette = tokens.primitives.series_palette;
|
|
2424
|
+
const leftColor = palette[0];
|
|
2425
|
+
const rightColor = palette[1];
|
|
2426
|
+
const DESC_LIMIT = Infinity; // Show full text — no truncation
|
|
2427
|
+
|
|
2428
|
+
const autoFindTitle = (obj: Record<string, unknown>, explicitField?: string): string => {
|
|
2429
|
+
if (explicitField && obj[explicitField]) return String(obj[explicitField]);
|
|
2430
|
+
for (const k of TITLE_HINTS) {
|
|
2431
|
+
if (obj[k] && typeof obj[k] === 'string') return String(obj[k]);
|
|
2432
|
+
}
|
|
2433
|
+
// Fallback: first short string
|
|
2434
|
+
const firstShort = Object.entries(obj).find(([, v]) => typeof v === 'string' && (v as string).length < 80);
|
|
2435
|
+
return firstShort ? String(firstShort[1]) : '';
|
|
2436
|
+
};
|
|
2437
|
+
|
|
2438
|
+
const autoFindDesc = (obj: Record<string, unknown>, explicitField?: string): string => {
|
|
2439
|
+
if (explicitField && obj[explicitField]) return String(obj[explicitField]);
|
|
2440
|
+
for (const k of DESC_HINTS) {
|
|
2441
|
+
if (obj[k] && typeof obj[k] === 'string') return String(obj[k]);
|
|
2442
|
+
}
|
|
2443
|
+
// Fallback: longest string
|
|
2444
|
+
let longest = '';
|
|
2445
|
+
Object.values(obj).forEach(v => {
|
|
2446
|
+
if (typeof v === 'string' && v.length > longest.length) longest = v;
|
|
2447
|
+
});
|
|
2448
|
+
return longest;
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
const toggleLeft = (idx: number) => {
|
|
2452
|
+
setExpandedLeft(prev => {
|
|
2453
|
+
const next = new Set(prev);
|
|
2454
|
+
if (next.has(idx)) next.delete(idx); else next.add(idx);
|
|
2455
|
+
return next;
|
|
2456
|
+
});
|
|
2457
|
+
};
|
|
2458
|
+
const toggleRight = (idx: number) => {
|
|
2459
|
+
setExpandedRight(prev => {
|
|
2460
|
+
const next = new Set(prev);
|
|
2461
|
+
if (next.has(idx)) next.delete(idx); else next.add(idx);
|
|
2462
|
+
return next;
|
|
2463
|
+
});
|
|
2464
|
+
};
|
|
2465
|
+
|
|
2466
|
+
const renderPanel = (
|
|
2467
|
+
items: Array<Record<string, unknown>>,
|
|
2468
|
+
side: 'left' | 'right',
|
|
2469
|
+
color: string,
|
|
2470
|
+
titleFieldOverride: string | undefined,
|
|
2471
|
+
descFieldOverride: string | undefined,
|
|
2472
|
+
expandedSet: Set<number>,
|
|
2473
|
+
toggleFn: (idx: number) => void,
|
|
2474
|
+
) => (
|
|
2475
|
+
<div style={{
|
|
2476
|
+
flex: 1,
|
|
2477
|
+
display: 'flex',
|
|
2478
|
+
flexDirection: 'column',
|
|
2479
|
+
maxHeight: '400px',
|
|
2480
|
+
overflowY: 'auto',
|
|
2481
|
+
}}>
|
|
2482
|
+
{items.map((obj, idx) => {
|
|
2483
|
+
const title = autoFindTitle(obj, titleFieldOverride);
|
|
2484
|
+
const desc = autoFindDesc(obj, descFieldOverride);
|
|
2485
|
+
const isExpanded = expandedSet.has(idx);
|
|
2486
|
+
const needsTruncation = desc.length > DESC_LIMIT;
|
|
2487
|
+
|
|
2488
|
+
return (
|
|
2489
|
+
<div
|
|
2490
|
+
key={idx}
|
|
2491
|
+
style={{
|
|
2492
|
+
padding: 'var(--space-xs, 0.375rem) var(--space-sm, 0.5rem)',
|
|
2493
|
+
borderBottom: idx < items.length - 1 ? '1px solid var(--color-border-light, #eef0f2)' : 'none',
|
|
2494
|
+
cursor: needsTruncation ? 'pointer' : 'default',
|
|
2495
|
+
}}
|
|
2496
|
+
onClick={() => needsTruncation && toggleFn(idx)}
|
|
2497
|
+
>
|
|
2498
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-xs, 0.25rem)' }}>
|
|
2499
|
+
<span style={{
|
|
2500
|
+
fontWeight: 600,
|
|
2501
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
2502
|
+
color: 'var(--color-text, #1a1d23)',
|
|
2503
|
+
lineHeight: 'var(--leading-snug, 1.4)',
|
|
2504
|
+
}}>
|
|
2505
|
+
{title}
|
|
2506
|
+
</span>
|
|
2507
|
+
{captureMode && onCapture && (
|
|
2508
|
+
<button
|
|
2509
|
+
title={`Capture ${side} item`}
|
|
2510
|
+
onClick={e => {
|
|
2511
|
+
e.stopPropagation();
|
|
2512
|
+
onCapture({
|
|
2513
|
+
source_view_key: captureViewKey || '',
|
|
2514
|
+
source_item_index: idx,
|
|
2515
|
+
source_renderer_type: 'dialectical_pair',
|
|
2516
|
+
content_type: 'item',
|
|
2517
|
+
selected_text: `[${side === 'left' ? leftLabel : rightLabel}] ${title}: ${desc}`.slice(0, 500),
|
|
2518
|
+
structured_data: obj,
|
|
2519
|
+
context_title: parentSectionKey
|
|
2520
|
+
? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${side === 'left' ? leftLabel : rightLabel} > ${title}`
|
|
2521
|
+
: `${captureViewKey || 'Analysis'} > ${side === 'left' ? leftLabel : rightLabel} > ${title}`,
|
|
2522
|
+
source_type: (captureSourceType || 'analysis') as string,
|
|
2523
|
+
entity_id: captureEntityId || captureJobId || '',
|
|
2524
|
+
depth_level: 'L2_element',
|
|
2525
|
+
parent_context: parentSectionKey ? {
|
|
2526
|
+
section_key: parentSectionKey,
|
|
2527
|
+
section_title: parentSectionTitle || '',
|
|
2528
|
+
} : undefined,
|
|
2529
|
+
});
|
|
2530
|
+
}}
|
|
2531
|
+
style={{
|
|
2532
|
+
background: 'none',
|
|
2533
|
+
border: '1px solid var(--color-border, #ccc)',
|
|
2534
|
+
borderRadius: '4px',
|
|
2535
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
2536
|
+
cursor: 'pointer',
|
|
2537
|
+
padding: '2px 6px',
|
|
2538
|
+
fontSize: '0.7rem',
|
|
2539
|
+
lineHeight: 1,
|
|
2540
|
+
marginLeft: 'auto',
|
|
2541
|
+
flexShrink: 0,
|
|
2542
|
+
}}
|
|
2543
|
+
>
|
|
2544
|
+
📌
|
|
2545
|
+
</button>
|
|
2546
|
+
)}
|
|
2547
|
+
</div>
|
|
2548
|
+
{desc && (
|
|
2549
|
+
<div style={{
|
|
2550
|
+
marginTop: '2px',
|
|
2551
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
2552
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
2553
|
+
lineHeight: 'var(--leading-relaxed, 1.6)',
|
|
2554
|
+
}}>
|
|
2555
|
+
{needsTruncation && !isExpanded
|
|
2556
|
+
? desc.slice(0, DESC_LIMIT) + '...'
|
|
2557
|
+
: desc}
|
|
2558
|
+
</div>
|
|
2559
|
+
)}
|
|
2560
|
+
</div>
|
|
2561
|
+
);
|
|
2562
|
+
})}
|
|
2563
|
+
</div>
|
|
2564
|
+
);
|
|
2565
|
+
|
|
2566
|
+
return (
|
|
2567
|
+
<div style={{
|
|
2568
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
2569
|
+
border: '1px solid var(--color-border, #e2e5e9)',
|
|
2570
|
+
overflow: 'hidden',
|
|
2571
|
+
}}>
|
|
2572
|
+
{/* Column headers */}
|
|
2573
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr' }}>
|
|
2574
|
+
<div style={{
|
|
2575
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
2576
|
+
backgroundColor: leftColor,
|
|
2577
|
+
color: tokens.surfaces.text_on_accent,
|
|
2578
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
2579
|
+
fontWeight: 600,
|
|
2580
|
+
textTransform: 'uppercase',
|
|
2581
|
+
letterSpacing: '0.06em',
|
|
2582
|
+
textAlign: 'center',
|
|
2583
|
+
}}>
|
|
2584
|
+
{leftLabel}
|
|
2585
|
+
</div>
|
|
2586
|
+
{/* Central tension node */}
|
|
2587
|
+
<div style={{
|
|
2588
|
+
display: 'flex',
|
|
2589
|
+
alignItems: 'center',
|
|
2590
|
+
justifyContent: 'center',
|
|
2591
|
+
padding: '0 var(--space-sm, 0.5rem)',
|
|
2592
|
+
backgroundColor: tokens.surfaces.surface_alt,
|
|
2593
|
+
position: 'relative',
|
|
2594
|
+
}}>
|
|
2595
|
+
<span style={{
|
|
2596
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
2597
|
+
fontWeight: 800,
|
|
2598
|
+
color: tokens.surfaces.text_muted,
|
|
2599
|
+
textTransform: 'uppercase',
|
|
2600
|
+
letterSpacing: '0.1em',
|
|
2601
|
+
padding: '2px 10px',
|
|
2602
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
2603
|
+
border: `2px solid ${tokens.surfaces.border_accent}`,
|
|
2604
|
+
backgroundColor: tokens.surfaces.surface_default,
|
|
2605
|
+
whiteSpace: 'nowrap',
|
|
2606
|
+
}}>
|
|
2607
|
+
{relationshipLabel}
|
|
2608
|
+
</span>
|
|
2609
|
+
</div>
|
|
2610
|
+
<div style={{
|
|
2611
|
+
padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
|
|
2612
|
+
backgroundColor: rightColor,
|
|
2613
|
+
color: tokens.surfaces.text_on_accent,
|
|
2614
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
2615
|
+
fontWeight: 600,
|
|
2616
|
+
textTransform: 'uppercase',
|
|
2617
|
+
letterSpacing: '0.06em',
|
|
2618
|
+
textAlign: 'center',
|
|
2619
|
+
}}>
|
|
2620
|
+
{rightLabel}
|
|
2621
|
+
</div>
|
|
2622
|
+
</div>
|
|
2623
|
+
|
|
2624
|
+
{/* Panels */}
|
|
2625
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr' }}>
|
|
2626
|
+
{renderPanel(leftItems, 'left', leftColor, leftTitleField, leftDescField, expandedLeft, toggleLeft)}
|
|
2627
|
+
{/* Vertical tension line */}
|
|
2628
|
+
<div style={{
|
|
2629
|
+
width: '3px',
|
|
2630
|
+
background: `linear-gradient(to bottom, ${leftColor}, ${tokens.surfaces.border_accent}, ${rightColor})`,
|
|
2631
|
+
}} />
|
|
2632
|
+
{renderPanel(rightItems, 'right', rightColor, rightTitleField, rightDescField, expandedRight, toggleRight)}
|
|
2633
|
+
</div>
|
|
2634
|
+
</div>
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
// ── OrderedFlow → Sequential Content Units ───────────────
|
|
2639
|
+
//
|
|
2640
|
+
// Generic renderer for any ordered sequence of content units:
|
|
2641
|
+
// chapters, argument steps, methodology phases, dialectical moves,
|
|
2642
|
+
// policy stages, evidence links, etc.
|
|
2643
|
+
//
|
|
2644
|
+
// Config:
|
|
2645
|
+
// title_field — primary label (auto-strips "Chapter N:" / "Step N:" prefixes)
|
|
2646
|
+
// subtitle_field — category/role badge (colored by design token semantic lookup)
|
|
2647
|
+
// description_field — expandable detail text
|
|
2648
|
+
// number_field — explicit numbering field (falls back to extracting from title or index)
|
|
2649
|
+
// strip_prefix — regex to strip from title (default: /^(Chapter|Step|Phase|Stage)\s+\d+[.:]\s*/i)
|
|
2650
|
+
|
|
2651
|
+
function OrderedFlow({ data, config }: SubRendererProps) {
|
|
2652
|
+
const [expandedIdx, setExpandedIdx] = React.useState<number | null>(null);
|
|
2653
|
+
const { tokens, getCategoryColor } = useDesignTokens();
|
|
2654
|
+
|
|
2655
|
+
if (!data || !Array.isArray(data) || data.length === 0) return null;
|
|
2656
|
+
|
|
2657
|
+
const titleField = (config.title_field as string) || 'title';
|
|
2658
|
+
const subtitleField = (config.subtitle_field as string) || 'type';
|
|
2659
|
+
const descriptionField = (config.description_field as string) || 'description';
|
|
2660
|
+
const numberField = (config.number_field as string) || undefined;
|
|
2661
|
+
const stripPrefix = config.strip_prefix as string | undefined;
|
|
2662
|
+
|
|
2663
|
+
const prefixRegex = stripPrefix
|
|
2664
|
+
? new RegExp(stripPrefix, 'i')
|
|
2665
|
+
: /^(Chapter|Step|Phase|Stage|Part|Section|Move)\s+\d+[.:]\s*/i;
|
|
2666
|
+
|
|
2667
|
+
const lineColor = tokens.surfaces?.border_default || '#e2e5e9';
|
|
2668
|
+
|
|
2669
|
+
return (
|
|
2670
|
+
<div style={{ position: 'relative', paddingLeft: '36px' }}>
|
|
2671
|
+
{/* Vertical connecting line */}
|
|
2672
|
+
<div style={{
|
|
2673
|
+
position: 'absolute',
|
|
2674
|
+
left: '11px',
|
|
2675
|
+
top: '12px',
|
|
2676
|
+
bottom: '12px',
|
|
2677
|
+
width: '2px',
|
|
2678
|
+
backgroundColor: lineColor,
|
|
2679
|
+
}} />
|
|
2680
|
+
|
|
2681
|
+
{data.map((item, idx) => {
|
|
2682
|
+
if (typeof item !== 'object' || item === null) return null;
|
|
2683
|
+
const obj = item as Record<string, unknown>;
|
|
2684
|
+
const title = getField(obj, titleField);
|
|
2685
|
+
const category = getField(obj, subtitleField);
|
|
2686
|
+
const categoryLower = category.toLowerCase();
|
|
2687
|
+
const desc = getField(obj, descriptionField);
|
|
2688
|
+
const isExpanded = expandedIdx === idx;
|
|
2689
|
+
const isLast = idx === data.length - 1;
|
|
2690
|
+
|
|
2691
|
+
// Resolve step number: explicit field → extract from title → index
|
|
2692
|
+
let stepLabel: string;
|
|
2693
|
+
if (numberField && obj[numberField] !== undefined) {
|
|
2694
|
+
stepLabel = String(obj[numberField]);
|
|
2695
|
+
} else {
|
|
2696
|
+
const numMatch = title.match(/(\d+)/);
|
|
2697
|
+
stepLabel = numMatch ? numMatch[1] : String(idx + 1);
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
// Strip common prefixes for cleaner display
|
|
2701
|
+
const cleanTitle = title.replace(prefixRegex, '');
|
|
2702
|
+
|
|
2703
|
+
// Color from design tokens: try categorical lookup, then series palette fallback
|
|
2704
|
+
const catColor = getCategoryColor?.(subtitleField, categoryLower);
|
|
2705
|
+
const dotColor = catColor?.text
|
|
2706
|
+
|| tokens.primitives.series_palette[idx % tokens.primitives.series_palette.length];
|
|
2707
|
+
|
|
2708
|
+
const DESC_LIMIT = Infinity; // Show full text — no truncation
|
|
2709
|
+
const needsTruncation = desc.length > DESC_LIMIT;
|
|
2710
|
+
const displayDesc = isExpanded || !needsTruncation
|
|
2711
|
+
? desc
|
|
2712
|
+
: desc.slice(0, DESC_LIMIT) + '...';
|
|
2713
|
+
|
|
2714
|
+
return (
|
|
2715
|
+
<div
|
|
2716
|
+
key={idx}
|
|
2717
|
+
onClick={() => needsTruncation && setExpandedIdx(isExpanded ? null : idx)}
|
|
2718
|
+
style={{
|
|
2719
|
+
position: 'relative',
|
|
2720
|
+
paddingBottom: isLast ? 0 : 'var(--space-xs, 0.375rem)',
|
|
2721
|
+
marginBottom: isLast ? 0 : 'var(--space-xs, 0.375rem)',
|
|
2722
|
+
cursor: needsTruncation ? 'pointer' : 'default',
|
|
2723
|
+
}}
|
|
2724
|
+
>
|
|
2725
|
+
{/* Step dot */}
|
|
2726
|
+
<div style={{
|
|
2727
|
+
position: 'absolute',
|
|
2728
|
+
left: '-36px',
|
|
2729
|
+
top: '2px',
|
|
2730
|
+
width: '24px',
|
|
2731
|
+
height: '24px',
|
|
2732
|
+
borderRadius: '50%',
|
|
2733
|
+
backgroundColor: dotColor,
|
|
2734
|
+
color: '#fff',
|
|
2735
|
+
display: 'flex',
|
|
2736
|
+
alignItems: 'center',
|
|
2737
|
+
justifyContent: 'center',
|
|
2738
|
+
fontSize: '11px',
|
|
2739
|
+
fontWeight: 700,
|
|
2740
|
+
zIndex: 1,
|
|
2741
|
+
fontFamily: 'var(--font-mono, monospace)',
|
|
2742
|
+
}}>
|
|
2743
|
+
{stepLabel}
|
|
2744
|
+
</div>
|
|
2745
|
+
|
|
2746
|
+
{/* Title + category */}
|
|
2747
|
+
<div style={{
|
|
2748
|
+
display: 'flex',
|
|
2749
|
+
alignItems: 'baseline',
|
|
2750
|
+
gap: 'var(--space-sm, 0.5rem)',
|
|
2751
|
+
flexWrap: 'wrap',
|
|
2752
|
+
lineHeight: 'var(--leading-snug, 1.4)',
|
|
2753
|
+
}}>
|
|
2754
|
+
<span style={{
|
|
2755
|
+
fontWeight: 600,
|
|
2756
|
+
fontSize: 'var(--type-body, 0.9375rem)',
|
|
2757
|
+
color: 'var(--color-text, #1a1d23)',
|
|
2758
|
+
}}>
|
|
2759
|
+
{cleanTitle}
|
|
2760
|
+
</span>
|
|
2761
|
+
|
|
2762
|
+
{category && (
|
|
2763
|
+
<span style={{
|
|
2764
|
+
fontSize: '10px',
|
|
2765
|
+
fontWeight: 600,
|
|
2766
|
+
textTransform: 'uppercase',
|
|
2767
|
+
letterSpacing: '0.06em',
|
|
2768
|
+
color: dotColor,
|
|
2769
|
+
opacity: 0.85,
|
|
2770
|
+
}}>
|
|
2771
|
+
{category}
|
|
2772
|
+
</span>
|
|
2773
|
+
)}
|
|
2774
|
+
</div>
|
|
2775
|
+
|
|
2776
|
+
{/* Description */}
|
|
2777
|
+
{desc && (
|
|
2778
|
+
<div style={{
|
|
2779
|
+
marginTop: '2px',
|
|
2780
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
2781
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
2782
|
+
lineHeight: 'var(--leading-relaxed, 1.6)',
|
|
2783
|
+
}}>
|
|
2784
|
+
{displayDesc}
|
|
2785
|
+
</div>
|
|
2786
|
+
)}
|
|
2787
|
+
</div>
|
|
2788
|
+
);
|
|
2789
|
+
})}
|
|
2790
|
+
</div>
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// ── RichDescriptionList → Readable Stacked Items ──────────
|
|
2795
|
+
//
|
|
2796
|
+
// Vertically-stacked list where each item gets room to breathe.
|
|
2797
|
+
// Handles BOTH string arrays ("Label: description") and object arrays.
|
|
2798
|
+
//
|
|
2799
|
+
// Config:
|
|
2800
|
+
// title_field — For object arrays: field name for label
|
|
2801
|
+
// description_field — For object arrays: field name for description
|
|
2802
|
+
// separator — For string arrays: split label from description (default ":")
|
|
2803
|
+
// max_visible_chars — Auto-collapse longer descriptions (default 200)
|
|
2804
|
+
// badge_fields — Optional extra fields to show as badges
|
|
2805
|
+
|
|
2806
|
+
function RichDescriptionList({ data, config }: SubRendererProps) {
|
|
2807
|
+
const [expandedIdx, setExpandedIdx] = React.useState<Set<number>>(new Set());
|
|
2808
|
+
const { tokens } = useDesignTokens();
|
|
2809
|
+
|
|
2810
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
2811
|
+
const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
|
|
2812
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
2813
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
2814
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
2815
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
2816
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
2817
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
2818
|
+
|
|
2819
|
+
if (!data || !Array.isArray(data) || data.length === 0) return null;
|
|
2820
|
+
|
|
2821
|
+
const titleField = config.title_field as string | undefined;
|
|
2822
|
+
const descriptionField = config.description_field as string | undefined;
|
|
2823
|
+
const separator = (config.separator as string) || ':';
|
|
2824
|
+
const maxChars = (config.max_visible_chars as number) || Infinity; // Show full text — no truncation
|
|
2825
|
+
const badgeFields = (config.badge_fields as string[]) || [];
|
|
2826
|
+
|
|
2827
|
+
const palette = tokens.primitives.series_palette;
|
|
2828
|
+
|
|
2829
|
+
// Parse items — handle string arrays and object arrays
|
|
2830
|
+
const items: Array<{ label: string; description: string; badges: string[]; raw: unknown }> = data.map(item => {
|
|
2831
|
+
if (typeof item === 'string') {
|
|
2832
|
+
// Parse "Label: description..." format
|
|
2833
|
+
const sepIdx = item.indexOf(separator);
|
|
2834
|
+
if (sepIdx > 0 && sepIdx < 60) {
|
|
2835
|
+
return {
|
|
2836
|
+
label: item.slice(0, sepIdx).trim(),
|
|
2837
|
+
description: item.slice(sepIdx + separator.length).trim(),
|
|
2838
|
+
badges: [],
|
|
2839
|
+
raw: item,
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
// No separator found — use first ~40 chars as label
|
|
2843
|
+
const firstSpace = item.indexOf(' ', 30);
|
|
2844
|
+
if (firstSpace > 0) {
|
|
2845
|
+
return { label: item.slice(0, firstSpace), description: item.slice(firstSpace + 1), badges: [], raw: item };
|
|
2846
|
+
}
|
|
2847
|
+
return { label: item, description: '', badges: [], raw: item };
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
if (typeof item === 'object' && item !== null) {
|
|
2851
|
+
const obj = item as Record<string, unknown>;
|
|
2852
|
+
const label = titleField ? getField(obj, titleField) : '';
|
|
2853
|
+
const desc = descriptionField ? getField(obj, descriptionField) : '';
|
|
2854
|
+
const badges = badgeFields.map(f => getField(obj, f)).filter(Boolean);
|
|
2855
|
+
|
|
2856
|
+
// If no explicit fields, auto-detect
|
|
2857
|
+
if (!label && !desc) {
|
|
2858
|
+
const auto = autoDetectFields(obj);
|
|
2859
|
+
return {
|
|
2860
|
+
label: auto.title ? getField(obj, auto.title) : '',
|
|
2861
|
+
description: auto.description ? getField(obj, auto.description) : '',
|
|
2862
|
+
badges,
|
|
2863
|
+
raw: item,
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
return { label, description: desc, badges, raw: item };
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
return { label: String(item), description: '', badges: [], raw: item };
|
|
2870
|
+
});
|
|
2871
|
+
|
|
2872
|
+
const toggleExpand = (idx: number) => {
|
|
2873
|
+
setExpandedIdx(prev => {
|
|
2874
|
+
const next = new Set(prev);
|
|
2875
|
+
if (next.has(idx)) next.delete(idx); else next.add(idx);
|
|
2876
|
+
return next;
|
|
2877
|
+
});
|
|
2878
|
+
};
|
|
2879
|
+
|
|
2880
|
+
return (
|
|
2881
|
+
<div style={{
|
|
2882
|
+
display: 'flex',
|
|
2883
|
+
flexDirection: 'column',
|
|
2884
|
+
gap: 0,
|
|
2885
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
2886
|
+
border: '1px solid var(--color-border, #e2e5e9)',
|
|
2887
|
+
overflow: 'hidden',
|
|
2888
|
+
}}>
|
|
2889
|
+
{items.map((item, idx) => {
|
|
2890
|
+
const color = palette[idx % palette.length];
|
|
2891
|
+
const isExpanded = expandedIdx.has(idx);
|
|
2892
|
+
const needsTruncation = item.description.length > maxChars;
|
|
2893
|
+
const isLast = idx === items.length - 1;
|
|
2894
|
+
|
|
2895
|
+
return (
|
|
2896
|
+
<div
|
|
2897
|
+
key={idx}
|
|
2898
|
+
style={{
|
|
2899
|
+
display: 'flex',
|
|
2900
|
+
borderBottom: isLast ? 'none' : '1px solid var(--color-border-light, #eef0f2)',
|
|
2901
|
+
}}
|
|
2902
|
+
>
|
|
2903
|
+
{/* Colored left border */}
|
|
2904
|
+
<div style={{
|
|
2905
|
+
width: '4px',
|
|
2906
|
+
backgroundColor: color,
|
|
2907
|
+
flexShrink: 0,
|
|
2908
|
+
}} />
|
|
2909
|
+
|
|
2910
|
+
{/* Content */}
|
|
2911
|
+
<div style={{
|
|
2912
|
+
flex: 1,
|
|
2913
|
+
padding: 'var(--space-sm, 0.75rem) var(--space-md, 1rem)',
|
|
2914
|
+
}}>
|
|
2915
|
+
{/* Header row: dot + label + count + capture */}
|
|
2916
|
+
<div style={{
|
|
2917
|
+
display: 'flex',
|
|
2918
|
+
alignItems: 'baseline',
|
|
2919
|
+
gap: 'var(--space-xs, 0.25rem)',
|
|
2920
|
+
}}>
|
|
2921
|
+
<span style={{
|
|
2922
|
+
color,
|
|
2923
|
+
fontSize: '14px',
|
|
2924
|
+
lineHeight: 1,
|
|
2925
|
+
flexShrink: 0,
|
|
2926
|
+
}}>●</span>
|
|
2927
|
+
<span style={{
|
|
2928
|
+
fontWeight: 700,
|
|
2929
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
2930
|
+
textTransform: 'uppercase',
|
|
2931
|
+
letterSpacing: '0.04em',
|
|
2932
|
+
color: 'var(--color-text, #1a1d23)',
|
|
2933
|
+
lineHeight: 'var(--leading-snug, 1.4)',
|
|
2934
|
+
}}>
|
|
2935
|
+
{item.label || `Item ${idx + 1}`}
|
|
2936
|
+
</span>
|
|
2937
|
+
|
|
2938
|
+
{/* Badges */}
|
|
2939
|
+
{item.badges.map((badge, bi) => (
|
|
2940
|
+
<span key={bi} style={{
|
|
2941
|
+
fontSize: '10px',
|
|
2942
|
+
fontWeight: 600,
|
|
2943
|
+
textTransform: 'uppercase',
|
|
2944
|
+
letterSpacing: '0.05em',
|
|
2945
|
+
padding: '1px 6px',
|
|
2946
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
2947
|
+
backgroundColor: `${palette[(idx + bi + 1) % palette.length]}22`,
|
|
2948
|
+
color: palette[(idx + bi + 1) % palette.length],
|
|
2949
|
+
}}>
|
|
2950
|
+
{badge}
|
|
2951
|
+
</span>
|
|
2952
|
+
))}
|
|
2953
|
+
|
|
2954
|
+
{/* Count badge */}
|
|
2955
|
+
<span style={{
|
|
2956
|
+
marginLeft: 'auto',
|
|
2957
|
+
fontSize: '10px',
|
|
2958
|
+
fontWeight: 500,
|
|
2959
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
2960
|
+
flexShrink: 0,
|
|
2961
|
+
}}>
|
|
2962
|
+
[{idx + 1}/{items.length}]
|
|
2963
|
+
</span>
|
|
2964
|
+
|
|
2965
|
+
{/* Capture button */}
|
|
2966
|
+
{captureMode && onCapture && (
|
|
2967
|
+
<button
|
|
2968
|
+
title="Capture this item"
|
|
2969
|
+
onClick={e => {
|
|
2970
|
+
e.stopPropagation();
|
|
2971
|
+
onCapture({
|
|
2972
|
+
source_view_key: captureViewKey || '',
|
|
2973
|
+
source_item_index: idx,
|
|
2974
|
+
source_renderer_type: 'rich_description_list',
|
|
2975
|
+
content_type: 'item',
|
|
2976
|
+
selected_text: `${item.label}: ${item.description}`.slice(0, 500),
|
|
2977
|
+
structured_data: item.raw,
|
|
2978
|
+
context_title: parentSectionKey
|
|
2979
|
+
? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${item.label}`
|
|
2980
|
+
: `${captureViewKey || 'Analysis'} > ${item.label}`,
|
|
2981
|
+
source_type: (captureSourceType || 'analysis') as string,
|
|
2982
|
+
entity_id: captureEntityId || captureJobId || '',
|
|
2983
|
+
depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
|
|
2984
|
+
parent_context: parentSectionKey ? {
|
|
2985
|
+
section_key: parentSectionKey,
|
|
2986
|
+
section_title: parentSectionTitle || '',
|
|
2987
|
+
} : undefined,
|
|
2988
|
+
});
|
|
2989
|
+
}}
|
|
2990
|
+
style={{
|
|
2991
|
+
background: 'none',
|
|
2992
|
+
border: '1px solid var(--color-border, #ccc)',
|
|
2993
|
+
borderRadius: '4px',
|
|
2994
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
2995
|
+
cursor: 'pointer',
|
|
2996
|
+
padding: '2px 6px',
|
|
2997
|
+
fontSize: '0.7rem',
|
|
2998
|
+
lineHeight: 1,
|
|
2999
|
+
flexShrink: 0,
|
|
3000
|
+
}}
|
|
3001
|
+
>
|
|
3002
|
+
📌
|
|
3003
|
+
</button>
|
|
3004
|
+
)}
|
|
3005
|
+
</div>
|
|
3006
|
+
|
|
3007
|
+
{/* Description */}
|
|
3008
|
+
{item.description && (
|
|
3009
|
+
<div style={{
|
|
3010
|
+
marginTop: 'var(--space-2xs, 0.25rem)',
|
|
3011
|
+
fontSize: 'var(--type-caption, 0.8125rem)',
|
|
3012
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
3013
|
+
lineHeight: 'var(--leading-relaxed, 1.6)',
|
|
3014
|
+
}}>
|
|
3015
|
+
{needsTruncation && !isExpanded
|
|
3016
|
+
? item.description.slice(0, maxChars) + '...'
|
|
3017
|
+
: item.description
|
|
3018
|
+
}
|
|
3019
|
+
{needsTruncation && (
|
|
3020
|
+
<button
|
|
3021
|
+
onClick={(e) => { e.stopPropagation(); toggleExpand(idx); }}
|
|
3022
|
+
className="gen-show-more-link"
|
|
3023
|
+
style={{
|
|
3024
|
+
background: 'none',
|
|
3025
|
+
border: 'none',
|
|
3026
|
+
color,
|
|
3027
|
+
cursor: 'pointer',
|
|
3028
|
+
fontSize: '0.75rem',
|
|
3029
|
+
padding: '0 4px',
|
|
3030
|
+
marginLeft: '4px',
|
|
3031
|
+
fontWeight: 600,
|
|
3032
|
+
}}
|
|
3033
|
+
>
|
|
3034
|
+
{isExpanded ? 'collapse ▴' : 'expand ▾'}
|
|
3035
|
+
</button>
|
|
3036
|
+
)}
|
|
3037
|
+
</div>
|
|
3038
|
+
)}
|
|
3039
|
+
</div>
|
|
3040
|
+
</div>
|
|
3041
|
+
);
|
|
3042
|
+
})}
|
|
3043
|
+
</div>
|
|
3044
|
+
);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// ── PhaseTimeline → Connected Phase Nodes ──────────────────
|
|
3048
|
+
//
|
|
3049
|
+
// Horizontal connected timeline with prominent phase nodes. Designed for
|
|
3050
|
+
// temporal/sequential data stored as an OBJECT with a phases array and
|
|
3051
|
+
// optional mode badge.
|
|
3052
|
+
//
|
|
3053
|
+
// Config:
|
|
3054
|
+
// phases_field — key containing the phases array (default "phases")
|
|
3055
|
+
// label_field — field within each phase for the label (default "label")
|
|
3056
|
+
// description_field — field within each phase for the description (default "description")
|
|
3057
|
+
// mode_field — optional top-level field for a mode badge (default "mode")
|
|
3058
|
+
|
|
3059
|
+
function PhaseTimeline({ data, config }: SubRendererProps) {
|
|
3060
|
+
const [expandedIdx, setExpandedIdx] = React.useState<Set<number>>(new Set());
|
|
3061
|
+
const { tokens } = useDesignTokens();
|
|
3062
|
+
|
|
3063
|
+
const captureMode = config._captureMode as boolean | undefined;
|
|
3064
|
+
const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
|
|
3065
|
+
const captureJobId = config._captureJobId as string | undefined;
|
|
3066
|
+
const captureViewKey = config._captureViewKey as string | undefined;
|
|
3067
|
+
const captureSourceType = config._captureSourceType as string | undefined;
|
|
3068
|
+
const captureEntityId = config._captureEntityId as string | undefined;
|
|
3069
|
+
const parentSectionKey = config._parentSectionKey as string | undefined;
|
|
3070
|
+
const parentSectionTitle = config._parentSectionTitle as string | undefined;
|
|
3071
|
+
|
|
3072
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
3073
|
+
|
|
3074
|
+
const obj = data as Record<string, unknown>;
|
|
3075
|
+
const phasesField = (config.phases_field as string) || 'phases';
|
|
3076
|
+
const labelField = (config.label_field as string) || 'label';
|
|
3077
|
+
const descField = (config.description_field as string) || 'description';
|
|
3078
|
+
const modeField = (config.mode_field as string) || 'mode';
|
|
3079
|
+
|
|
3080
|
+
const rawPhases = obj[phasesField];
|
|
3081
|
+
if (!Array.isArray(rawPhases) || rawPhases.length === 0) return null;
|
|
3082
|
+
|
|
3083
|
+
const mode = obj[modeField] as string | undefined;
|
|
3084
|
+
const palette = tokens.primitives.series_palette;
|
|
3085
|
+
const DESC_LIMIT = Infinity; // Show full text — no truncation
|
|
3086
|
+
|
|
3087
|
+
const phases = rawPhases
|
|
3088
|
+
.filter(p => typeof p === 'object' && p !== null)
|
|
3089
|
+
.map(p => p as Record<string, unknown>);
|
|
3090
|
+
|
|
3091
|
+
const toggleExpand = (idx: number) => {
|
|
3092
|
+
setExpandedIdx(prev => {
|
|
3093
|
+
const next = new Set(prev);
|
|
3094
|
+
if (next.has(idx)) next.delete(idx); else next.add(idx);
|
|
3095
|
+
return next;
|
|
3096
|
+
});
|
|
3097
|
+
};
|
|
3098
|
+
|
|
3099
|
+
return (
|
|
3100
|
+
<div>
|
|
3101
|
+
{/* Mode badge */}
|
|
3102
|
+
{mode && (
|
|
3103
|
+
<div style={{
|
|
3104
|
+
marginBottom: 'var(--space-sm, 0.75rem)',
|
|
3105
|
+
display: 'flex',
|
|
3106
|
+
justifyContent: 'center',
|
|
3107
|
+
}}>
|
|
3108
|
+
<span style={{
|
|
3109
|
+
fontSize: '10px',
|
|
3110
|
+
fontWeight: 700,
|
|
3111
|
+
textTransform: 'uppercase',
|
|
3112
|
+
letterSpacing: '0.08em',
|
|
3113
|
+
padding: '3px 12px',
|
|
3114
|
+
borderRadius: 'var(--radius-pill, 9999px)',
|
|
3115
|
+
backgroundColor: `${palette[0]}18`,
|
|
3116
|
+
color: palette[0],
|
|
3117
|
+
border: `1px solid ${palette[0]}40`,
|
|
3118
|
+
}}>
|
|
3119
|
+
{mode} temporality
|
|
3120
|
+
</span>
|
|
3121
|
+
</div>
|
|
3122
|
+
)}
|
|
3123
|
+
|
|
3124
|
+
{/* Timeline — horizontal layout */}
|
|
3125
|
+
<div style={{
|
|
3126
|
+
display: 'flex',
|
|
3127
|
+
alignItems: 'flex-start',
|
|
3128
|
+
gap: 0,
|
|
3129
|
+
overflow: 'hidden',
|
|
3130
|
+
}}>
|
|
3131
|
+
{phases.map((phase, idx) => {
|
|
3132
|
+
const label = getField(phase, labelField);
|
|
3133
|
+
const desc = getField(phase, descField);
|
|
3134
|
+
const color = palette[idx % palette.length];
|
|
3135
|
+
const isExpanded = expandedIdx.has(idx);
|
|
3136
|
+
const needsTruncation = desc.length > DESC_LIMIT;
|
|
3137
|
+
const isLast = idx === phases.length - 1;
|
|
3138
|
+
|
|
3139
|
+
return (
|
|
3140
|
+
<React.Fragment key={idx}>
|
|
3141
|
+
{/* Phase node */}
|
|
3142
|
+
<div style={{
|
|
3143
|
+
flex: 1,
|
|
3144
|
+
display: 'flex',
|
|
3145
|
+
flexDirection: 'column',
|
|
3146
|
+
alignItems: 'center',
|
|
3147
|
+
minWidth: 0,
|
|
3148
|
+
}}>
|
|
3149
|
+
{/* Node circle */}
|
|
3150
|
+
<div style={{
|
|
3151
|
+
width: '36px',
|
|
3152
|
+
height: '36px',
|
|
3153
|
+
borderRadius: '50%',
|
|
3154
|
+
backgroundColor: color,
|
|
3155
|
+
color: '#fff',
|
|
3156
|
+
display: 'flex',
|
|
3157
|
+
alignItems: 'center',
|
|
3158
|
+
justifyContent: 'center',
|
|
3159
|
+
fontSize: '14px',
|
|
3160
|
+
fontWeight: 700,
|
|
3161
|
+
flexShrink: 0,
|
|
3162
|
+
zIndex: 1,
|
|
3163
|
+
fontFamily: 'var(--font-mono, monospace)',
|
|
3164
|
+
boxShadow: `0 0 0 3px ${color}30`,
|
|
3165
|
+
}}>
|
|
3166
|
+
{idx + 1}
|
|
3167
|
+
</div>
|
|
3168
|
+
|
|
3169
|
+
{/* Label card */}
|
|
3170
|
+
<div style={{
|
|
3171
|
+
marginTop: 'var(--space-xs, 0.375rem)',
|
|
3172
|
+
textAlign: 'center',
|
|
3173
|
+
padding: 'var(--space-xs, 0.375rem) var(--space-sm, 0.5rem)',
|
|
3174
|
+
borderRadius: 'var(--radius-md, 8px)',
|
|
3175
|
+
border: `1px solid ${color}40`,
|
|
3176
|
+
backgroundColor: `${color}08`,
|
|
3177
|
+
width: '100%',
|
|
3178
|
+
minHeight: '44px',
|
|
3179
|
+
display: 'flex',
|
|
3180
|
+
flexDirection: 'column',
|
|
3181
|
+
alignItems: 'center',
|
|
3182
|
+
justifyContent: 'center',
|
|
3183
|
+
}}>
|
|
3184
|
+
<span style={{
|
|
3185
|
+
fontWeight: 700,
|
|
3186
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
3187
|
+
textTransform: 'uppercase',
|
|
3188
|
+
letterSpacing: '0.04em',
|
|
3189
|
+
color: 'var(--color-text, #1a1d23)',
|
|
3190
|
+
lineHeight: 'var(--leading-snug, 1.3)',
|
|
3191
|
+
}}>
|
|
3192
|
+
{label || `Phase ${idx + 1}`}
|
|
3193
|
+
</span>
|
|
3194
|
+
</div>
|
|
3195
|
+
|
|
3196
|
+
{/* Description below node */}
|
|
3197
|
+
{desc && (
|
|
3198
|
+
<div
|
|
3199
|
+
onClick={() => needsTruncation && toggleExpand(idx)}
|
|
3200
|
+
style={{
|
|
3201
|
+
marginTop: 'var(--space-2xs, 0.25rem)',
|
|
3202
|
+
fontSize: 'var(--type-label, 0.6875rem)',
|
|
3203
|
+
color: 'var(--color-text-muted, #6b7280)',
|
|
3204
|
+
lineHeight: 'var(--leading-relaxed, 1.6)',
|
|
3205
|
+
textAlign: 'center',
|
|
3206
|
+
padding: '0 var(--space-2xs, 0.125rem)',
|
|
3207
|
+
cursor: needsTruncation ? 'pointer' : 'default',
|
|
3208
|
+
}}
|
|
3209
|
+
>
|
|
3210
|
+
{needsTruncation && !isExpanded
|
|
3211
|
+
? desc.slice(0, DESC_LIMIT) + '...'
|
|
3212
|
+
: desc
|
|
3213
|
+
}
|
|
3214
|
+
{needsTruncation && (
|
|
3215
|
+
<button
|
|
3216
|
+
onClick={e => { e.stopPropagation(); toggleExpand(idx); }}
|
|
3217
|
+
style={{
|
|
3218
|
+
background: 'none',
|
|
3219
|
+
border: 'none',
|
|
3220
|
+
color,
|
|
3221
|
+
cursor: 'pointer',
|
|
3222
|
+
fontSize: '0.7rem',
|
|
3223
|
+
padding: '0 3px',
|
|
3224
|
+
marginLeft: '2px',
|
|
3225
|
+
fontWeight: 600,
|
|
3226
|
+
}}
|
|
3227
|
+
>
|
|
3228
|
+
{isExpanded ? '▴' : '▾'}
|
|
3229
|
+
</button>
|
|
3230
|
+
)}
|
|
3231
|
+
</div>
|
|
3232
|
+
)}
|
|
3233
|
+
|
|
3234
|
+
{/* Capture button */}
|
|
3235
|
+
{captureMode && onCapture && (
|
|
3236
|
+
<button
|
|
3237
|
+
title="Capture this phase"
|
|
3238
|
+
onClick={e => {
|
|
3239
|
+
e.stopPropagation();
|
|
3240
|
+
onCapture({
|
|
3241
|
+
source_view_key: captureViewKey || '',
|
|
3242
|
+
source_item_index: idx,
|
|
3243
|
+
source_renderer_type: 'phase_timeline',
|
|
3244
|
+
content_type: 'item',
|
|
3245
|
+
selected_text: `[Phase ${idx + 1}] ${label}: ${desc}`.slice(0, 500),
|
|
3246
|
+
structured_data: phase,
|
|
3247
|
+
context_title: parentSectionKey
|
|
3248
|
+
? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > Phase: ${label}`
|
|
3249
|
+
: `${captureViewKey || 'Analysis'} > Phase: ${label}`,
|
|
3250
|
+
source_type: (captureSourceType || 'analysis') as string,
|
|
3251
|
+
entity_id: captureEntityId || captureJobId || '',
|
|
3252
|
+
depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
|
|
3253
|
+
parent_context: parentSectionKey ? {
|
|
3254
|
+
section_key: parentSectionKey,
|
|
3255
|
+
section_title: parentSectionTitle || '',
|
|
3256
|
+
} : undefined,
|
|
3257
|
+
});
|
|
3258
|
+
}}
|
|
3259
|
+
style={{
|
|
3260
|
+
marginTop: 'var(--space-2xs, 0.25rem)',
|
|
3261
|
+
background: 'none',
|
|
3262
|
+
border: '1px solid var(--color-border, #ccc)',
|
|
3263
|
+
borderRadius: '4px',
|
|
3264
|
+
color: 'var(--dt-text-faint, #94a3b8)',
|
|
3265
|
+
cursor: 'pointer',
|
|
3266
|
+
padding: '2px 6px',
|
|
3267
|
+
fontSize: '0.7rem',
|
|
3268
|
+
lineHeight: 1,
|
|
3269
|
+
}}
|
|
3270
|
+
>
|
|
3271
|
+
📌
|
|
3272
|
+
</button>
|
|
3273
|
+
)}
|
|
3274
|
+
</div>
|
|
3275
|
+
|
|
3276
|
+
{/* Connector line between nodes */}
|
|
3277
|
+
{!isLast && (
|
|
3278
|
+
<div style={{
|
|
3279
|
+
display: 'flex',
|
|
3280
|
+
alignItems: 'center',
|
|
3281
|
+
paddingTop: '18px',
|
|
3282
|
+
flexShrink: 0,
|
|
3283
|
+
}}>
|
|
3284
|
+
<div style={{
|
|
3285
|
+
width: '28px',
|
|
3286
|
+
height: '2px',
|
|
3287
|
+
background: `linear-gradient(to right, ${color}, ${palette[(idx + 1) % palette.length]})`,
|
|
3288
|
+
}} />
|
|
3289
|
+
<div style={{
|
|
3290
|
+
width: 0,
|
|
3291
|
+
height: 0,
|
|
3292
|
+
borderTop: '4px solid transparent',
|
|
3293
|
+
borderBottom: '4px solid transparent',
|
|
3294
|
+
borderLeft: `6px solid ${palette[(idx + 1) % palette.length]}`,
|
|
3295
|
+
}} />
|
|
3296
|
+
</div>
|
|
3297
|
+
)}
|
|
3298
|
+
</React.Fragment>
|
|
3299
|
+
);
|
|
3300
|
+
})}
|
|
3301
|
+
</div>
|
|
3302
|
+
</div>
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
|
|
3307
|
+
// ── Distribution Summary ────────────────────────────────
|
|
3308
|
+
/**
|
|
3309
|
+
* Visual distribution bar chart with optional dominant pattern highlight
|
|
3310
|
+
* and collapsible narrative. Configurable via JSON view definitions.
|
|
3311
|
+
*
|
|
3312
|
+
* Data shape (object):
|
|
3313
|
+
* {
|
|
3314
|
+
* distribution: Record<string, number> | Array<{ key: string; count: number; major_count?: number }>
|
|
3315
|
+
* dominant?: string
|
|
3316
|
+
* narrative?: string
|
|
3317
|
+
* }
|
|
3318
|
+
*
|
|
3319
|
+
* Field mapping config (for data that doesn't use these exact field names):
|
|
3320
|
+
* distribution_field?: string -- field name for distribution data (default: "distribution")
|
|
3321
|
+
* dominant_field?: string -- field name for dominant item (default: "dominant")
|
|
3322
|
+
* narrative_field?: string -- field name for narrative text (default: "narrative")
|
|
3323
|
+
*
|
|
3324
|
+
* Display config:
|
|
3325
|
+
* category?: string -- design token category for colors (e.g., "tactic", "relationship")
|
|
3326
|
+
* dominant_label?: string -- label above dominant value (default: "Dominant")
|
|
3327
|
+
* count_noun?: string -- noun for total count (default: "items")
|
|
3328
|
+
* type_noun?: string -- noun for type count (default: "types")
|
|
3329
|
+
* severity_value?: string -- label for severity badges (default: "major")
|
|
3330
|
+
*
|
|
3331
|
+
* Interactive mode (passed by CardGridRenderer via _ prefix):
|
|
3332
|
+
* _onFilterClick?: (key: string) => void -- makes bars clickable
|
|
3333
|
+
* _activeFilter?: string | null -- highlights active bar
|
|
3334
|
+
* _groups?: Group[] -- live groups (overrides distribution field)
|
|
3335
|
+
*/
|
|
3336
|
+
export function DistributionSummary({ data, config }: SubRendererProps) {
|
|
3337
|
+
const { getCategoryColor, getLabel } = useDesignTokens();
|
|
3338
|
+
const [narrativeExpanded, setNarrativeExpanded] = useState(false);
|
|
3339
|
+
|
|
3340
|
+
const obj = (data && typeof data === 'object' && !Array.isArray(data))
|
|
3341
|
+
? data as Record<string, unknown>
|
|
3342
|
+
: null;
|
|
3343
|
+
if (!obj) return null;
|
|
3344
|
+
|
|
3345
|
+
// Config
|
|
3346
|
+
const category = config.category as string | undefined;
|
|
3347
|
+
const dominantLabel = (config.dominant_label as string) || 'Dominant';
|
|
3348
|
+
const countNoun = (config.count_noun as string) || 'items';
|
|
3349
|
+
const typeNoun = (config.type_noun as string) || 'types';
|
|
3350
|
+
const severityValue = (config.severity_value as string) || 'major';
|
|
3351
|
+
|
|
3352
|
+
// Field mapping (allows data to use different field names)
|
|
3353
|
+
const distField = (config.distribution_field as string) || 'distribution';
|
|
3354
|
+
const domField = (config.dominant_field as string) || 'dominant';
|
|
3355
|
+
const narrField = (config.narrative_field as string) || 'narrative';
|
|
3356
|
+
|
|
3357
|
+
// Interactive mode (injected by CardGridRenderer)
|
|
3358
|
+
const onFilterClick = config._onFilterClick as ((key: string) => void) | undefined;
|
|
3359
|
+
const activeFilter = config._activeFilter as string | null | undefined;
|
|
3360
|
+
const liveGroups = config._groups as Array<{
|
|
3361
|
+
key: string; label: string;
|
|
3362
|
+
style: { bg: string; text: string; border: string };
|
|
3363
|
+
items: Array<Record<string, unknown>>;
|
|
3364
|
+
}> | undefined;
|
|
3365
|
+
|
|
3366
|
+
// Build entries — prefer live groups (interactive) over static distribution
|
|
3367
|
+
const formatName = (key: string) =>
|
|
3368
|
+
(category ? getLabel(category, key) : null) || key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
3369
|
+
|
|
3370
|
+
let entries: Array<{ key: string; label: string; count: number; majorCount: number;
|
|
3371
|
+
colors: { bg: string; text: string; border: string } }> = [];
|
|
3372
|
+
|
|
3373
|
+
const fallbackColors = { bg: '#f1f5f9', text: '#64748b', border: '#e2e8f0' };
|
|
3374
|
+
|
|
3375
|
+
if (liveGroups && liveGroups.length > 0) {
|
|
3376
|
+
entries = liveGroups.map(g => ({
|
|
3377
|
+
key: g.key,
|
|
3378
|
+
label: g.label,
|
|
3379
|
+
count: g.items.length,
|
|
3380
|
+
majorCount: g.items.filter(i => String(i.severity || '').toLowerCase() === severityValue).length,
|
|
3381
|
+
colors: (category ? getCategoryColor(category, g.key) : null) || g.style || fallbackColors,
|
|
3382
|
+
}));
|
|
3383
|
+
} else {
|
|
3384
|
+
const rawDist = obj[distField];
|
|
3385
|
+
if (rawDist && typeof rawDist === 'object' && !Array.isArray(rawDist)) {
|
|
3386
|
+
entries = Object.entries(rawDist as Record<string, number>)
|
|
3387
|
+
.map(([key, count]) => ({
|
|
3388
|
+
key, count: Number(count) || 0, majorCount: 0,
|
|
3389
|
+
label: formatName(key),
|
|
3390
|
+
colors: (category ? getCategoryColor(category, key) : null) || fallbackColors,
|
|
3391
|
+
}))
|
|
3392
|
+
.sort((a, b) => b.count - a.count);
|
|
3393
|
+
} else if (Array.isArray(rawDist)) {
|
|
3394
|
+
entries = (rawDist as Array<Record<string, unknown>>).map(item => {
|
|
3395
|
+
const key = String(item.key || '');
|
|
3396
|
+
return {
|
|
3397
|
+
key, count: Number(item.count || 0), majorCount: Number(item.major_count || 0),
|
|
3398
|
+
label: String(item.label || '') || formatName(key),
|
|
3399
|
+
colors: (category ? getCategoryColor(category, key) : null) || fallbackColors,
|
|
3400
|
+
};
|
|
3401
|
+
}).sort((a, b) => b.count - a.count);
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
if (entries.length === 0) return null;
|
|
3406
|
+
|
|
3407
|
+
const totalCount = entries.reduce((sum, e) => sum + e.count, 0);
|
|
3408
|
+
const maxCount = Math.max(...entries.map(e => e.count));
|
|
3409
|
+
const dominant = obj[domField] as string | undefined;
|
|
3410
|
+
const narrative = obj[narrField] as string | undefined;
|
|
3411
|
+
const isInteractive = Boolean(onFilterClick);
|
|
3412
|
+
|
|
3413
|
+
return (
|
|
3414
|
+
<div className="ar-dist-summary">
|
|
3415
|
+
{/* Header: dominant + total count */}
|
|
3416
|
+
<div className="gen-summary-header">
|
|
3417
|
+
{dominant && (
|
|
3418
|
+
<div className="gen-summary-stat">
|
|
3419
|
+
<span className="gen-stat-label">{dominantLabel}</span>
|
|
3420
|
+
<span className="gen-stat-value">{formatName(dominant)}</span>
|
|
3421
|
+
</div>
|
|
3422
|
+
)}
|
|
3423
|
+
<div className="gen-summary-counts">
|
|
3424
|
+
<span className="gen-summary-count-big">{totalCount}</span>
|
|
3425
|
+
<span className="gen-summary-count-label">{countNoun} across {entries.length} {typeNoun}</span>
|
|
3426
|
+
</div>
|
|
3427
|
+
</div>
|
|
3428
|
+
|
|
3429
|
+
{/* Distribution bars */}
|
|
3430
|
+
<div className="gen-dist-bars">
|
|
3431
|
+
{entries.map(entry => {
|
|
3432
|
+
const pct = Math.max(8, (entry.count / maxCount) * 100);
|
|
3433
|
+
const isActive = activeFilter === entry.key;
|
|
3434
|
+
const BarTag = isInteractive ? 'button' : 'div';
|
|
3435
|
+
|
|
3436
|
+
return (
|
|
3437
|
+
<BarTag
|
|
3438
|
+
key={entry.key}
|
|
3439
|
+
type={isInteractive ? 'button' : undefined}
|
|
3440
|
+
className={`gen-dist-bar-row ${isActive ? 'gen-dist-bar-row--active' : ''}`}
|
|
3441
|
+
onClick={isInteractive ? () => onFilterClick!(entry.key) : undefined}
|
|
3442
|
+
>
|
|
3443
|
+
<span className="gen-dist-bar-label">{entry.label}</span>
|
|
3444
|
+
<span className="gen-dist-bar-track">
|
|
3445
|
+
<span
|
|
3446
|
+
className="gen-dist-bar-fill"
|
|
3447
|
+
style={{
|
|
3448
|
+
width: `${pct}%`,
|
|
3449
|
+
backgroundColor: entry.colors.text,
|
|
3450
|
+
opacity: isActive ? 1 : 0.7,
|
|
3451
|
+
}}
|
|
3452
|
+
/>
|
|
3453
|
+
</span>
|
|
3454
|
+
<span className="gen-dist-bar-count" style={{ color: entry.colors.text }}>
|
|
3455
|
+
{entry.count}
|
|
3456
|
+
</span>
|
|
3457
|
+
{entry.majorCount > 0 && (
|
|
3458
|
+
<span className="gen-dist-bar-severity">{entry.majorCount} {severityValue}</span>
|
|
3459
|
+
)}
|
|
3460
|
+
</BarTag>
|
|
3461
|
+
);
|
|
3462
|
+
})}
|
|
3463
|
+
{isInteractive && activeFilter && (
|
|
3464
|
+
<button type="button" className="gen-dist-bar-clear" onClick={() => onFilterClick!('')}>
|
|
3465
|
+
Clear filter
|
|
3466
|
+
</button>
|
|
3467
|
+
)}
|
|
3468
|
+
</div>
|
|
3469
|
+
|
|
3470
|
+
{/* Narrative */}
|
|
3471
|
+
{narrative && (
|
|
3472
|
+
<>
|
|
3473
|
+
<div className={`gen-pattern-narrative-wrap ${narrativeExpanded ? 'gen-pattern-narrative-wrap--expanded' : ''}`}>
|
|
3474
|
+
<p className="gen-pattern-narrative">{narrative}</p>
|
|
3475
|
+
</div>
|
|
3476
|
+
<button
|
|
3477
|
+
type="button"
|
|
3478
|
+
className="gen-narrative-toggle"
|
|
3479
|
+
onClick={() => setNarrativeExpanded(!narrativeExpanded)}
|
|
3480
|
+
>
|
|
3481
|
+
{narrativeExpanded ? 'Show less' : 'Read full analysis'}
|
|
3482
|
+
</button>
|
|
3483
|
+
</>
|
|
3484
|
+
)}
|
|
3485
|
+
</div>
|
|
3486
|
+
);
|
|
3487
|
+
}
|