@the-syllabus/analysis-renderers 0.2.0 → 0.4.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.
@@ -21,6 +21,8 @@
21
21
  * dialectical_pair — Two-panel tension visualization for thesis/antithesis contrasts
22
22
  * rich_description_list — Stacked items with colored borders for paragraph-length descriptions
23
23
  * phase_timeline — Connected timeline with prominent phase nodes for temporal data
24
+ * annotated_prose — Enhanced prose with paragraph markers, pull-quotes, and rhetorical highlighting
25
+ * dependency_matrix — Adjacency matrix / heatmap for directed chapter dependencies
24
26
  * distribution_summary — Visual bar chart with dominant highlight, counts, and optional narrative
25
27
  */
26
28
 
@@ -57,6 +59,8 @@ const SUB_RENDERER_MAP: Record<string, React.FC<SubRendererProps>> = {
57
59
  dialectical_pair: DialecticalPair,
58
60
  rich_description_list: RichDescriptionList,
59
61
  phase_timeline: PhaseTimeline,
62
+ dependency_matrix: DependencyMatrix,
63
+ annotated_prose: AnnotatedProse,
60
64
  distribution_summary: DistributionSummary,
61
65
  };
62
66
 
@@ -3333,6 +3337,361 @@ function PhaseTimeline({ data, config }: SubRendererProps) {
3333
3337
  * _activeFilter?: string | null -- highlights active bar
3334
3338
  * _groups?: Group[] -- live groups (overrides distribution field)
3335
3339
  */
3340
+
3341
+ // ── annotated_prose ───────────────────────────────────────────
3342
+ // Enhanced prose rendering with paragraph detection, pull-quote extraction,
3343
+ // and subtle numbered markers. Upgrades prose_block for longer analytical text.
3344
+ //
3345
+ // Config:
3346
+ // pull_quote_min_length — minimum sentence length to be considered a pull-quote (default 80)
3347
+ // show_paragraph_numbers — show subtle paragraph numbers (default true)
3348
+ // highlight_markers — rhetorical markers to highlight (default: common analytical terms)
3349
+
3350
+ const DEFAULT_MARKERS = [
3351
+ 'crucial', 'fundamentally', 'the key claim', 'central to', 'decisive',
3352
+ 'turning point', 'paradox', 'contradiction', 'however', 'nevertheless',
3353
+ 'most importantly', 'significantly', 'critically', 'the core',
3354
+ ];
3355
+
3356
+ function AnnotatedProse({ data, config }: SubRendererProps) {
3357
+ const { tokens } = useDesignTokens();
3358
+
3359
+ const text = typeof data === 'string'
3360
+ ? data
3361
+ : typeof data === 'object' && data !== null
3362
+ ? (data as Record<string, unknown>)._prose_output as string
3363
+ || (data as Record<string, unknown>).text as string
3364
+ || (data as Record<string, unknown>).content as string
3365
+ || (data as Record<string, unknown>).prose as string
3366
+ || ''
3367
+ : '';
3368
+
3369
+ if (!text) return null;
3370
+
3371
+ const pullQuoteMinLen = (config.pull_quote_min_length as number) || 80;
3372
+ const showParaNums = config.show_paragraph_numbers !== false;
3373
+ const markers = (config.highlight_markers as string[]) || DEFAULT_MARKERS;
3374
+
3375
+ // Split into paragraphs
3376
+ const paragraphs = text.split(/\n\n+/).map(p => p.trim()).filter(Boolean);
3377
+
3378
+ // Find the best pull-quote: longest sentence across all paragraphs that contains a marker
3379
+ let bestQuote = '';
3380
+ let bestQuoteParaIdx = -1;
3381
+ for (let pi = 0; pi < paragraphs.length; pi++) {
3382
+ const sentences = paragraphs[pi].split(/(?<=[.!?])\s+/);
3383
+ for (const sent of sentences) {
3384
+ if (sent.length >= pullQuoteMinLen && sent.length > bestQuote.length) {
3385
+ const lower = sent.toLowerCase();
3386
+ if (markers.some(m => lower.includes(m))) {
3387
+ bestQuote = sent;
3388
+ bestQuoteParaIdx = pi;
3389
+ }
3390
+ }
3391
+ }
3392
+ }
3393
+ // Fallback: just pick longest sentence if no marker match
3394
+ if (!bestQuote) {
3395
+ for (let pi = 0; pi < paragraphs.length; pi++) {
3396
+ const sentences = paragraphs[pi].split(/(?<=[.!?])\s+/);
3397
+ for (const sent of sentences) {
3398
+ if (sent.length >= pullQuoteMinLen && sent.length > bestQuote.length) {
3399
+ bestQuote = sent;
3400
+ bestQuoteParaIdx = pi;
3401
+ }
3402
+ }
3403
+ }
3404
+ }
3405
+
3406
+ const accentColor = tokens.primitives.series_palette[0] || '#6366f1';
3407
+
3408
+ // Highlight markers in text
3409
+ const highlightText = (text: string): React.ReactNode[] => {
3410
+ if (markers.length === 0) return [text];
3411
+ const pattern = new RegExp(`(${markers.map(m => m.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
3412
+ const parts = text.split(pattern);
3413
+ return parts.map((part, i) => {
3414
+ if (pattern.test(part)) {
3415
+ return React.createElement('span', {
3416
+ key: i,
3417
+ style: {
3418
+ fontWeight: 600,
3419
+ color: accentColor,
3420
+ },
3421
+ }, part);
3422
+ }
3423
+ return part;
3424
+ });
3425
+ };
3426
+
3427
+ return (
3428
+ <div style={{
3429
+ fontFamily: 'var(--font-serif, Georgia, serif)',
3430
+ lineHeight: 1.75,
3431
+ color: 'var(--dt-ink-primary, #1a1a2e)',
3432
+ }}>
3433
+ {paragraphs.map((para, idx) => {
3434
+ const isQuotePara = idx === bestQuoteParaIdx;
3435
+
3436
+ return React.createElement(React.Fragment, { key: idx },
3437
+ // Pull-quote callout (inserted after the paragraph it was extracted from)
3438
+ showParaNums ? React.createElement('div', {
3439
+ style: {
3440
+ display: 'flex',
3441
+ gap: '12px',
3442
+ marginBottom: idx === paragraphs.length - 1 ? 0 : '1.1em',
3443
+ },
3444
+ },
3445
+ // Paragraph number
3446
+ React.createElement('span', {
3447
+ style: {
3448
+ fontSize: '0.7rem',
3449
+ fontFamily: 'var(--font-mono, monospace)',
3450
+ color: 'var(--dt-ink-tertiary, #9ca3af)',
3451
+ minWidth: '18px',
3452
+ textAlign: 'right',
3453
+ paddingTop: '3px',
3454
+ userSelect: 'none',
3455
+ },
3456
+ }, `${idx + 1}`),
3457
+ // Paragraph text
3458
+ React.createElement('p', {
3459
+ style: {
3460
+ margin: 0,
3461
+ fontSize: '0.92rem',
3462
+ },
3463
+ }, ...highlightText(para)),
3464
+ ) : React.createElement('p', {
3465
+ style: {
3466
+ margin: 0,
3467
+ marginBottom: idx === paragraphs.length - 1 ? 0 : '1.1em',
3468
+ fontSize: '0.92rem',
3469
+ },
3470
+ }, ...highlightText(para)),
3471
+
3472
+ // Pull-quote callout after the source paragraph
3473
+ isQuotePara && bestQuote ? React.createElement('blockquote', {
3474
+ key: `quote-${idx}`,
3475
+ style: {
3476
+ margin: '1em 0 1.2em 0',
3477
+ padding: '12px 16px',
3478
+ borderLeft: `3px solid ${accentColor}`,
3479
+ background: 'var(--dt-surface-card, #f8f6f3)',
3480
+ borderRadius: '0 var(--radius-sm, 4px) var(--radius-sm, 4px) 0',
3481
+ fontStyle: 'italic',
3482
+ fontSize: '0.88rem',
3483
+ lineHeight: 1.65,
3484
+ color: 'var(--dt-ink-secondary, #374151)',
3485
+ },
3486
+ }, `"${bestQuote}"`) : null,
3487
+ );
3488
+ })}
3489
+ </div>
3490
+ );
3491
+ }
3492
+
3493
+ // ── dependency_matrix ─────────────────────────────────────────
3494
+ // Adjacency matrix / heatmap for directed relationships.
3495
+ // Data: array of {source, target, type} objects.
3496
+ // Config:
3497
+ // source_field — field name for row source (default "chapter")
3498
+ // target_field — field name for column target (default "depends_on")
3499
+ // type_field — field name for relationship type (default "dependency_type")
3500
+ // abbreviate_labels — shorten labels (default true)
3501
+
3502
+ function DependencyMatrix({ data, config }: SubRendererProps) {
3503
+ const [hoveredCell, setHoveredCell] = React.useState<{ row: number; col: number } | null>(null);
3504
+ const { tokens } = useDesignTokens();
3505
+
3506
+ if (!data || !Array.isArray(data) || data.length === 0) return null;
3507
+
3508
+ const sourceField = (config.source_field as string) || 'chapter';
3509
+ const targetField = (config.target_field as string) || 'depends_on';
3510
+ const typeField = (config.type_field as string) || 'dependency_type';
3511
+ const abbreviate = config.abbreviate_labels !== false;
3512
+
3513
+ const palette = tokens.primitives.series_palette;
3514
+
3515
+ // Extract unique labels (preserving order of first appearance)
3516
+ const labelSet = new Set<string>();
3517
+ for (const item of data) {
3518
+ const obj = item as Record<string, unknown>;
3519
+ const src = String(obj[sourceField] || '');
3520
+ const tgt = String(obj[targetField] || '');
3521
+ if (src) labelSet.add(src);
3522
+ if (tgt) labelSet.add(tgt);
3523
+ }
3524
+ const labels = Array.from(labelSet);
3525
+
3526
+ // Extract unique types for legend
3527
+ const typeSet = new Set<string>();
3528
+ for (const item of data) {
3529
+ const obj = item as Record<string, unknown>;
3530
+ const t = String(obj[typeField] || '');
3531
+ if (t) typeSet.add(t);
3532
+ }
3533
+ const types = Array.from(typeSet);
3534
+ const typeColorMap: Record<string, string> = {};
3535
+ types.forEach((t, i) => { typeColorMap[t] = palette[i % palette.length]; });
3536
+
3537
+ // Build adjacency map: [rowIdx][colIdx] = type
3538
+ const adjacency: Record<string, Record<string, string>> = {};
3539
+ for (const item of data) {
3540
+ const obj = item as Record<string, unknown>;
3541
+ const src = String(obj[sourceField] || '');
3542
+ const tgt = String(obj[targetField] || '');
3543
+ const typ = String(obj[typeField] || '');
3544
+ if (src && tgt) {
3545
+ if (!adjacency[src]) adjacency[src] = {};
3546
+ adjacency[src][tgt] = typ;
3547
+ }
3548
+ }
3549
+
3550
+ // Abbreviation helper
3551
+ const abbrev = (label: string): string => {
3552
+ if (!abbreviate) return label;
3553
+ // "Chapter 1" → "Ch1", "Appendix 1" → "App1", or first 4 chars
3554
+ return label
3555
+ .replace(/^Chapter\s*/i, 'Ch')
3556
+ .replace(/^Appendix\s*/i, 'App')
3557
+ .replace(/^Part\s*/i, 'P')
3558
+ .slice(0, 6);
3559
+ };
3560
+
3561
+ const cellSize = 32;
3562
+ const labelWidth = 80;
3563
+ const hovered = hoveredCell ? {
3564
+ src: labels[hoveredCell.row],
3565
+ tgt: labels[hoveredCell.col],
3566
+ type: adjacency[labels[hoveredCell.row]]?.[labels[hoveredCell.col]],
3567
+ } : null;
3568
+
3569
+ return (
3570
+ <div style={{ overflowX: 'auto' }}>
3571
+ {/* Tooltip */}
3572
+ {hovered?.type && (
3573
+ <div style={{
3574
+ padding: '6px 10px',
3575
+ marginBottom: '8px',
3576
+ fontSize: '0.82rem',
3577
+ background: 'var(--dt-surface-card, #f8f6f3)',
3578
+ border: '1px solid var(--color-border, #e2e5e9)',
3579
+ borderRadius: 'var(--radius-sm, 4px)',
3580
+ color: 'var(--dt-ink-primary, #1a1a2e)',
3581
+ }}>
3582
+ <strong>{hovered.src}</strong> → <strong>{hovered.tgt}</strong>: {hovered.type.replace(/_/g, ' ')}
3583
+ </div>
3584
+ )}
3585
+
3586
+ <table style={{
3587
+ borderCollapse: 'collapse',
3588
+ fontSize: '0.75rem',
3589
+ fontFamily: 'var(--font-mono, monospace)',
3590
+ }}>
3591
+ {/* Column headers */}
3592
+ <thead>
3593
+ <tr>
3594
+ <th style={{ width: labelWidth, minWidth: labelWidth }} />
3595
+ {labels.map((label, ci) => (
3596
+ <th key={ci} style={{
3597
+ width: cellSize,
3598
+ minWidth: cellSize,
3599
+ textAlign: 'center',
3600
+ padding: '2px',
3601
+ fontWeight: 500,
3602
+ color: 'var(--dt-ink-secondary, #6b7280)',
3603
+ writingMode: labels.length > 6 ? 'vertical-rl' : undefined,
3604
+ transform: labels.length > 6 ? 'rotate(180deg)' : undefined,
3605
+ height: labels.length > 6 ? 60 : undefined,
3606
+ }}>
3607
+ {abbrev(label)}
3608
+ </th>
3609
+ ))}
3610
+ </tr>
3611
+ </thead>
3612
+ <tbody>
3613
+ {labels.map((rowLabel, ri) => (
3614
+ <tr key={ri}>
3615
+ <td style={{
3616
+ padding: '2px 6px',
3617
+ textAlign: 'right',
3618
+ fontWeight: 500,
3619
+ color: 'var(--dt-ink-secondary, #6b7280)',
3620
+ whiteSpace: 'nowrap',
3621
+ }}>
3622
+ {abbrev(rowLabel)}
3623
+ </td>
3624
+ {labels.map((colLabel, ci) => {
3625
+ const cellType = adjacency[rowLabel]?.[colLabel];
3626
+ const isDiagonal = ri === ci;
3627
+ const isHovered = hoveredCell?.row === ri && hoveredCell?.col === ci;
3628
+
3629
+ return (
3630
+ <td
3631
+ key={ci}
3632
+ onMouseEnter={() => setHoveredCell({ row: ri, col: ci })}
3633
+ onMouseLeave={() => setHoveredCell(null)}
3634
+ style={{
3635
+ width: cellSize,
3636
+ height: cellSize,
3637
+ textAlign: 'center',
3638
+ border: '1px solid var(--color-border-light, #eef0f2)',
3639
+ background: isDiagonal
3640
+ ? 'var(--dt-surface-bg, #f0ede6)'
3641
+ : cellType
3642
+ ? typeColorMap[cellType]
3643
+ : 'transparent',
3644
+ opacity: cellType ? (isHovered ? 1 : 0.75) : 1,
3645
+ cursor: cellType ? 'pointer' : 'default',
3646
+ transition: 'opacity 0.15s',
3647
+ borderRadius: isHovered ? '2px' : undefined,
3648
+ boxShadow: isHovered && cellType ? '0 0 0 2px var(--dt-ink-primary, #1a1a2e)' : undefined,
3649
+ }}
3650
+ >
3651
+ {cellType && (
3652
+ <span style={{
3653
+ display: 'inline-block',
3654
+ width: 10,
3655
+ height: 10,
3656
+ borderRadius: 2,
3657
+ background: '#fff',
3658
+ opacity: 0.6,
3659
+ }} />
3660
+ )}
3661
+ </td>
3662
+ );
3663
+ })}
3664
+ </tr>
3665
+ ))}
3666
+ </tbody>
3667
+ </table>
3668
+
3669
+ {/* Legend */}
3670
+ <div style={{
3671
+ display: 'flex',
3672
+ gap: '12px',
3673
+ marginTop: '10px',
3674
+ flexWrap: 'wrap',
3675
+ fontSize: '0.78rem',
3676
+ color: 'var(--dt-ink-secondary, #6b7280)',
3677
+ }}>
3678
+ {types.map((type, i) => (
3679
+ <span key={type} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
3680
+ <span style={{
3681
+ display: 'inline-block',
3682
+ width: 12,
3683
+ height: 12,
3684
+ borderRadius: 2,
3685
+ background: typeColorMap[type],
3686
+ }} />
3687
+ {type.replace(/_/g, ' ')}
3688
+ </span>
3689
+ ))}
3690
+ </div>
3691
+ </div>
3692
+ );
3693
+ }
3694
+
3336
3695
  export function DistributionSummary({ data, config }: SubRendererProps) {
3337
3696
  const { getCategoryColor, getLabel } = useDesignTokens();
3338
3697
  const [narrativeExpanded, setNarrativeExpanded] = useState(false);