pystylometry 1.0.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

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.
Files changed (64) hide show
  1. pystylometry/README.md +42 -0
  2. pystylometry/__init__.py +45 -3
  3. pystylometry/_types.py +1017 -259
  4. pystylometry/authorship/README.md +21 -0
  5. pystylometry/authorship/__init__.py +28 -4
  6. pystylometry/authorship/additional_methods.py +260 -40
  7. pystylometry/authorship/compression.py +175 -0
  8. pystylometry/authorship/kilgarriff.py +354 -0
  9. pystylometry/character/README.md +17 -0
  10. pystylometry/character/character_metrics.py +267 -179
  11. pystylometry/cli.py +427 -0
  12. pystylometry/consistency/README.md +27 -0
  13. pystylometry/consistency/__init__.py +57 -0
  14. pystylometry/consistency/_thresholds.py +162 -0
  15. pystylometry/consistency/drift.py +549 -0
  16. pystylometry/dialect/README.md +26 -0
  17. pystylometry/dialect/__init__.py +65 -0
  18. pystylometry/dialect/_data/dialect_markers.json +1134 -0
  19. pystylometry/dialect/_loader.py +360 -0
  20. pystylometry/dialect/detector.py +533 -0
  21. pystylometry/lexical/README.md +23 -0
  22. pystylometry/lexical/advanced_diversity.py +61 -22
  23. pystylometry/lexical/function_words.py +255 -56
  24. pystylometry/lexical/hapax.py +182 -52
  25. pystylometry/lexical/mtld.py +108 -26
  26. pystylometry/lexical/ttr.py +76 -10
  27. pystylometry/lexical/word_frequency_sophistication.py +1522 -298
  28. pystylometry/lexical/yule.py +136 -50
  29. pystylometry/ngrams/README.md +18 -0
  30. pystylometry/ngrams/entropy.py +150 -49
  31. pystylometry/ngrams/extended_ngrams.py +314 -69
  32. pystylometry/prosody/README.md +17 -0
  33. pystylometry/prosody/rhythm_prosody.py +773 -11
  34. pystylometry/readability/README.md +23 -0
  35. pystylometry/readability/additional_formulas.py +1887 -762
  36. pystylometry/readability/ari.py +144 -82
  37. pystylometry/readability/coleman_liau.py +136 -109
  38. pystylometry/readability/flesch.py +177 -73
  39. pystylometry/readability/gunning_fog.py +165 -161
  40. pystylometry/readability/smog.py +123 -42
  41. pystylometry/stylistic/README.md +20 -0
  42. pystylometry/stylistic/cohesion_coherence.py +669 -13
  43. pystylometry/stylistic/genre_register.py +1560 -17
  44. pystylometry/stylistic/markers.py +611 -17
  45. pystylometry/stylistic/vocabulary_overlap.py +354 -13
  46. pystylometry/syntactic/README.md +20 -0
  47. pystylometry/syntactic/advanced_syntactic.py +76 -14
  48. pystylometry/syntactic/pos_ratios.py +70 -6
  49. pystylometry/syntactic/sentence_stats.py +55 -12
  50. pystylometry/syntactic/sentence_types.py +71 -15
  51. pystylometry/viz/README.md +27 -0
  52. pystylometry/viz/__init__.py +71 -0
  53. pystylometry/viz/drift.py +589 -0
  54. pystylometry/viz/jsx/__init__.py +31 -0
  55. pystylometry/viz/jsx/_base.py +144 -0
  56. pystylometry/viz/jsx/report.py +677 -0
  57. pystylometry/viz/jsx/timeline.py +716 -0
  58. pystylometry/viz/jsx/viewer.py +1032 -0
  59. pystylometry-1.3.0.dist-info/METADATA +136 -0
  60. pystylometry-1.3.0.dist-info/RECORD +76 -0
  61. {pystylometry-1.0.0.dist-info → pystylometry-1.3.0.dist-info}/WHEEL +1 -1
  62. pystylometry-1.3.0.dist-info/entry_points.txt +4 -0
  63. pystylometry-1.0.0.dist-info/METADATA +0 -275
  64. pystylometry-1.0.0.dist-info/RECORD +0 -46
@@ -0,0 +1,677 @@
1
+ """Interactive report visualization for Kilgarriff drift detection.
2
+
3
+ Creates a multi-panel dashboard with:
4
+ - Timeline chart
5
+ - Distribution histogram
6
+ - Summary statistics
7
+ - Zone classification
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from ._base import (
16
+ CARD_STYLES,
17
+ COLOR_INTERPOLATION_JS,
18
+ generate_html_document,
19
+ write_html_file,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from ..._types import KilgarriffDriftResult
24
+
25
+ # Reference bounds for zone classification
26
+ MEAN_CHI_LOW = 100
27
+ MEAN_CHI_HIGH = 250
28
+ CV_LOW = 0.08
29
+ CV_HIGH = 0.20
30
+
31
+
32
+ def export_drift_report_jsx(
33
+ result: "KilgarriffDriftResult",
34
+ output_file: str | Path,
35
+ label: str = "Document",
36
+ title: str | None = None,
37
+ chunks: list[str] | None = None,
38
+ ) -> Path:
39
+ """
40
+ Export an interactive multi-panel report as a standalone HTML file.
41
+
42
+ Creates a self-contained HTML file with React via CDN featuring:
43
+ - Interactive timeline chart with hover details
44
+ - Chi-squared distribution histogram
45
+ - Summary statistics panel
46
+ - Zone classification with visual indicators
47
+ - Top contributing words at spike location
48
+
49
+ Args:
50
+ result: KilgarriffDriftResult from compute_kilgarriff_drift()
51
+ output_file: Path to write the HTML file (e.g., "report.html")
52
+ label: Document label for title
53
+ title: Custom title (auto-generated if None)
54
+ chunks: Optional list of chunk text content for hover display
55
+
56
+ Returns:
57
+ Path to the generated HTML file
58
+
59
+ Example:
60
+ >>> result = compute_kilgarriff_drift(text)
61
+ >>> export_drift_report_jsx(result, "report.html", label="My Document")
62
+ """
63
+ chi_values = [s["chi_squared"] for s in result.pairwise_scores]
64
+
65
+ if len(chi_values) < 2:
66
+ raise ValueError("Need at least 2 window comparisons for report")
67
+
68
+ # Compute CV
69
+ mean_chi = result.mean_chi_squared
70
+ std_chi = result.std_chi_squared
71
+ cv = std_chi / mean_chi if mean_chi > 0 else 0
72
+ spike_threshold = mean_chi + 2 * std_chi if mean_chi > 0 else 100
73
+
74
+ # Build histogram bins
75
+ n_bins = min(15, len(chi_values))
76
+ min_val = min(chi_values)
77
+ max_val = max(chi_values)
78
+ bin_width = (max_val - min_val) / n_bins if max_val > min_val else 1
79
+ bins = [0] * n_bins
80
+ for chi in chi_values:
81
+ bin_idx = min(n_bins - 1, int((chi - min_val) / bin_width))
82
+ bins[bin_idx] += 1
83
+
84
+ histogram_data = {
85
+ "bins": bins,
86
+ "binWidth": round(bin_width, 2),
87
+ "minVal": round(min_val, 2),
88
+ "maxVal": round(max_val, 2),
89
+ }
90
+
91
+ # Build timeline points
92
+ points_data = []
93
+ for i, chi in enumerate(chi_values):
94
+ if std_chi > 0:
95
+ z_score = abs(chi - mean_chi) / std_chi
96
+ distance = min(1, z_score / 3)
97
+ else:
98
+ distance = 0 if chi == mean_chi else 1
99
+
100
+ point = {
101
+ "index": i,
102
+ "chi": round(chi, 2),
103
+ "distance": round(distance, 3),
104
+ }
105
+
106
+ # Add chunk text if available
107
+ if chunks:
108
+ if i < len(chunks):
109
+ point["chunkA"] = chunks[i]
110
+ if i + 1 < len(chunks):
111
+ point["chunkB"] = chunks[i + 1]
112
+
113
+ points_data.append(point)
114
+
115
+ # Get top words at spike if available
116
+ top_words = []
117
+ if result.max_location is not None and result.max_location < len(result.pairwise_scores):
118
+ spike_data = result.pairwise_scores[result.max_location]
119
+ if "top_words" in spike_data and spike_data["top_words"]:
120
+ top_words = [
121
+ {"word": w[0], "contribution": round(w[1], 2)} for w in spike_data["top_words"][:8]
122
+ ]
123
+
124
+ # Zone classification
125
+ if mean_chi < MEAN_CHI_LOW:
126
+ baseline_zone = "AI-like"
127
+ baseline_color = "#dc2626"
128
+ elif mean_chi > MEAN_CHI_HIGH:
129
+ baseline_zone = "Human-like"
130
+ baseline_color = "#22c55e"
131
+ else:
132
+ baseline_zone = "Transition"
133
+ baseline_color = "#f59e0b"
134
+
135
+ if cv < CV_LOW:
136
+ volatility_zone = "Very stable"
137
+ volatility_color = "#3b82f6"
138
+ elif cv > CV_HIGH:
139
+ volatility_zone = "Volatile"
140
+ volatility_color = "#ef4444"
141
+ else:
142
+ volatility_zone = "Normal"
143
+ volatility_color = "#22c55e"
144
+
145
+ display_title = title or f"Drift Analysis Report: {label}"
146
+
147
+ config = {
148
+ "title": display_title,
149
+ "label": label,
150
+ "points": points_data,
151
+ "hasChunks": chunks is not None and len(chunks) > 0,
152
+ "histogram": histogram_data,
153
+ "topWords": top_words,
154
+ "thresholds": {
155
+ "mean": round(mean_chi, 2),
156
+ "spike": round(spike_threshold, 2),
157
+ },
158
+ "zones": {
159
+ "baseline": {
160
+ "name": baseline_zone,
161
+ "color": baseline_color,
162
+ },
163
+ "volatility": {
164
+ "name": volatility_zone,
165
+ "color": volatility_color,
166
+ },
167
+ },
168
+ "stats": {
169
+ "pattern": result.pattern,
170
+ "confidence": round(result.pattern_confidence, 3),
171
+ "meanChi": round(mean_chi, 2),
172
+ "stdChi": round(std_chi, 2),
173
+ "minChi": round(result.min_chi_squared, 2),
174
+ "maxChi": round(result.max_chi_squared, 2),
175
+ "cv": round(cv, 4),
176
+ "trend": round(result.trend, 4),
177
+ "maxLocation": result.max_location,
178
+ "windowCount": result.window_count,
179
+ "windowSize": result.window_size,
180
+ "stride": result.stride,
181
+ "overlapRatio": round(result.overlap_ratio, 2),
182
+ },
183
+ "bounds": {
184
+ "MEAN_CHI_LOW": MEAN_CHI_LOW,
185
+ "MEAN_CHI_HIGH": MEAN_CHI_HIGH,
186
+ "CV_LOW": CV_LOW,
187
+ "CV_HIGH": CV_HIGH,
188
+ },
189
+ }
190
+
191
+ component = _get_report_component()
192
+ html_content = generate_html_document(
193
+ title=display_title,
194
+ config=config,
195
+ react_component=component,
196
+ component_name="DriftReport",
197
+ extra_styles=CARD_STYLES + _get_report_styles(),
198
+ )
199
+
200
+ return write_html_file(output_file, html_content)
201
+
202
+
203
+ def _get_report_styles() -> str:
204
+ """Additional CSS for report layout."""
205
+ return """
206
+ .report-grid {
207
+ display: grid;
208
+ grid-template-columns: 1fr 1fr;
209
+ grid-template-rows: auto auto;
210
+ gap: 16px;
211
+ }
212
+ .report-grid .full-width {
213
+ grid-column: 1 / -1;
214
+ }
215
+ .zone-badge {
216
+ display: inline-block;
217
+ padding: 4px 10px;
218
+ border-radius: 4px;
219
+ font-size: 12px;
220
+ font-weight: 500;
221
+ color: white;
222
+ }
223
+ .word-bar {
224
+ display: flex;
225
+ align-items: center;
226
+ margin: 4px 0;
227
+ font-size: 12px;
228
+ }
229
+ .word-label {
230
+ width: 80px;
231
+ font-family: ui-monospace, monospace;
232
+ overflow: hidden;
233
+ text-overflow: ellipsis;
234
+ }
235
+ .word-bar-bg {
236
+ flex: 1;
237
+ height: 16px;
238
+ background: #e2e8f0;
239
+ border-radius: 2px;
240
+ margin-left: 8px;
241
+ overflow: hidden;
242
+ }
243
+ .word-bar-fill {
244
+ height: 100%;
245
+ background: #2563eb;
246
+ border-radius: 2px;
247
+ }
248
+ """
249
+
250
+
251
+ def _get_report_component() -> str:
252
+ """Return the React component code for the report visualization."""
253
+ return f"""
254
+ {COLOR_INTERPOLATION_JS}
255
+
256
+ function DriftReport() {{
257
+ const [hoveredIndex, setHoveredIndex] = React.useState(null);
258
+ const [selectedIndex, setSelectedIndex] = React.useState(null);
259
+
260
+ const {{
261
+ title, points, hasChunks, histogram, topWords, thresholds, zones, stats, bounds
262
+ }} = CONFIG;
263
+
264
+ // Active index: selected takes priority, then hovered
265
+ const activeIndex = selectedIndex !== null ? selectedIndex : hoveredIndex;
266
+ const activePoint = activeIndex !== null ? points[activeIndex] : null;
267
+ const isSelected = selectedIndex !== null;
268
+ const selectedPoint = selectedIndex !== null ? points[selectedIndex] : null;
269
+
270
+ // Handle click to select/deselect
271
+ const handlePointClick = (i) => {{
272
+ if (selectedIndex === i) {{
273
+ setSelectedIndex(null);
274
+ }} else {{
275
+ setSelectedIndex(i);
276
+ }}
277
+ }};
278
+
279
+ // Timeline dimensions
280
+ const timelineWidth = 700;
281
+ const timelineHeight = 220;
282
+ const padding = {{ top: 30, right: 20, bottom: 40, left: 60 }};
283
+ const plotWidth = timelineWidth - padding.left - padding.right;
284
+ const plotHeight = timelineHeight - padding.top - padding.bottom;
285
+
286
+ const xMax = points.length - 1;
287
+ const yMax = Math.max(...points.map(p => p.chi)) * 1.1;
288
+
289
+ const scaleX = (idx) => padding.left + (idx / xMax) * plotWidth;
290
+ const scaleY = (chi) => padding.top + plotHeight - (chi / yMax) * plotHeight;
291
+
292
+ // Build path for line
293
+ const linePath = points.map((p, i) =>
294
+ `${{i === 0 ? 'M' : 'L'}} ${{scaleX(p.index)}} ${{scaleY(p.chi)}}`
295
+ ).join(' ');
296
+
297
+ const fillPath = linePath +
298
+ ` L ${{scaleX(points[points.length - 1].index)}} ${{scaleY(0)}}` +
299
+ ` L ${{scaleX(0)}} ${{scaleY(0)}} Z`;
300
+
301
+ // Histogram dimensions
302
+ const histWidth = 320;
303
+ const histHeight = 180;
304
+ const histPadding = {{ top: 20, right: 20, bottom: 30, left: 50 }};
305
+ const histPlotWidth = histWidth - histPadding.left - histPadding.right;
306
+ const histPlotHeight = histHeight - histPadding.top - histPadding.bottom;
307
+
308
+ const maxBinCount = Math.max(...histogram.bins);
309
+ const binWidth = histPlotWidth / histogram.bins.length;
310
+
311
+ // Calculate max word contribution for scaling
312
+ const maxContribution = topWords.length > 0
313
+ ? Math.max(...topWords.map(w => w.contribution))
314
+ : 1;
315
+
316
+ return (
317
+ <div style={{{{ maxWidth: 1050, margin: '0 auto' }}}}>
318
+ <h1 style={{{{ fontSize: 18, fontWeight: 600, marginBottom: 20, color: '#1f2937' }}}}>
319
+ {{title}}
320
+ </h1>
321
+
322
+ <div className="report-grid">
323
+ {{/* Timeline - full width */}}
324
+ <div className="card full-width">
325
+ <h3 className="card-title">Chi-squared Timeline</h3>
326
+ <svg width={{timelineWidth}} height={{timelineHeight}}>
327
+ {{/* Fill under curve */}}
328
+ <path d={{fillPath}} fill="#dbeafe" opacity={{0.4}} />
329
+
330
+ {{/* Mean line */}}
331
+ <line
332
+ x1={{padding.left}}
333
+ y1={{scaleY(thresholds.mean)}}
334
+ x2={{timelineWidth - padding.right}}
335
+ y2={{scaleY(thresholds.mean)}}
336
+ stroke="#6b7280"
337
+ strokeWidth={{1}}
338
+ opacity={{0.6}}
339
+ />
340
+
341
+ {{/* Max location marker */}}
342
+ {{stats.maxLocation !== null && (
343
+ <>
344
+ <line
345
+ x1={{scaleX(stats.maxLocation)}}
346
+ y1={{padding.top}}
347
+ x2={{scaleX(stats.maxLocation)}}
348
+ y2={{timelineHeight - padding.bottom}}
349
+ stroke="#dc2626"
350
+ strokeWidth={{2}}
351
+ strokeDasharray="6,4"
352
+ opacity={{0.6}}
353
+ />
354
+ <circle
355
+ cx={{scaleX(stats.maxLocation)}}
356
+ cy={{scaleY(points[stats.maxLocation].chi)}}
357
+ r={{8}}
358
+ fill="#dc2626"
359
+ stroke="white"
360
+ strokeWidth={{2}}
361
+ />
362
+ </>
363
+ )}}
364
+
365
+ {{/* Main line */}}
366
+ <path
367
+ d={{linePath}}
368
+ fill="none"
369
+ stroke="#2563eb"
370
+ strokeWidth={{2}}
371
+ strokeLinejoin="round"
372
+ />
373
+
374
+ {{/* Data points */}}
375
+ {{points.map((point, i) => {{
376
+ const isActive = activeIndex === i;
377
+ const isPinned = selectedIndex === i;
378
+ return (
379
+ <circle
380
+ key={{i}}
381
+ cx={{scaleX(point.index)}}
382
+ cy={{scaleY(point.chi)}}
383
+ r={{isActive ? 7 : 5}}
384
+ fill={{getPointColor(point.distance)}}
385
+ stroke={{isPinned ? '#dc2626' : isActive ? '#1f2937' : 'white'}}
386
+ strokeWidth={{isPinned ? 3 : isActive ? 2 : 1.5}}
387
+ style={{{{ cursor: 'pointer' }}}}
388
+ onMouseEnter={{() => setHoveredIndex(i)}}
389
+ onMouseLeave={{() => setHoveredIndex(null)}}
390
+ onClick={{() => handlePointClick(i)}}
391
+ />
392
+ );
393
+ }})}}
394
+
395
+ {{/* Axes */}}
396
+ <line
397
+ x1={{padding.left}}
398
+ y1={{timelineHeight - padding.bottom}}
399
+ x2={{timelineWidth - padding.right}}
400
+ y2={{timelineHeight - padding.bottom}}
401
+ stroke="#374151"
402
+ />
403
+ <line
404
+ x1={{padding.left}}
405
+ y1={{padding.top}}
406
+ x2={{padding.left}}
407
+ y2={{timelineHeight - padding.bottom}}
408
+ stroke="#374151"
409
+ />
410
+ <text x={{timelineWidth / 2}} y={{timelineHeight - 8}} textAnchor="middle" fontSize={{11}}>
411
+ Window Pair Index
412
+ </text>
413
+ <text x={{15}} y={{timelineHeight / 2}} textAnchor="middle" fontSize={{11}} transform={{`rotate(-90, 15, ${{timelineHeight / 2}})`}}>
414
+ χ²
415
+ </text>
416
+ </svg>
417
+ {{/* Quick info bar for hovered/selected point */}}
418
+ {{activePoint && (
419
+ <div style={{{{ marginTop: 8, padding: '8px 12px', background: '#f8fafc', borderRadius: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center', border: isSelected ? '2px solid #dc2626' : '1px solid #e2e8f0' }}}}>
420
+ <div style={{{{ fontSize: 12 }}}}>
421
+ {{isSelected && <span style={{{{ color: '#dc2626', marginRight: 6 }}}}>📌</span>}}
422
+ <strong>Window {{activePoint.index}}:</strong> χ² = {{activePoint.chi.toFixed(2)}}
423
+ {{hasChunks && !isSelected && <span style={{{{ color: '#9ca3af', marginLeft: 8 }}}}>(click to view chunks)</span>}}
424
+ </div>
425
+ {{isSelected && (
426
+ <button
427
+ onClick={{() => setSelectedIndex(null)}}
428
+ style={{{{
429
+ background: 'none',
430
+ border: 'none',
431
+ fontSize: 16,
432
+ cursor: 'pointer',
433
+ color: '#6b7280',
434
+ padding: '0 4px',
435
+ }}}}
436
+ title="Close"
437
+ >×</button>
438
+ )}}
439
+ </div>
440
+ )}}
441
+ </div>
442
+
443
+ {{/* Histogram */}}
444
+ <div className="card">
445
+ <h3 className="card-title">Distribution</h3>
446
+ <svg width={{histWidth}} height={{histHeight}}>
447
+ {{histogram.bins.map((count, i) => (
448
+ <rect
449
+ key={{i}}
450
+ x={{histPadding.left + i * binWidth + 1}}
451
+ y={{histPadding.top + histPlotHeight - (count / maxBinCount) * histPlotHeight}}
452
+ width={{binWidth - 2}}
453
+ height={{(count / maxBinCount) * histPlotHeight}}
454
+ fill="#2563eb"
455
+ opacity={{0.7}}
456
+ />
457
+ ))}}
458
+ {{/* Mean line */}}
459
+ <line
460
+ x1={{histPadding.left + ((thresholds.mean - histogram.minVal) / (histogram.maxVal - histogram.minVal)) * histPlotWidth}}
461
+ y1={{histPadding.top}}
462
+ x2={{histPadding.left + ((thresholds.mean - histogram.minVal) / (histogram.maxVal - histogram.minVal)) * histPlotWidth}}
463
+ y2={{histHeight - histPadding.bottom}}
464
+ stroke="#dc2626"
465
+ strokeWidth={{2}}
466
+ strokeDasharray="4,4"
467
+ />
468
+ {{/* Axes */}}
469
+ <line
470
+ x1={{histPadding.left}}
471
+ y1={{histHeight - histPadding.bottom}}
472
+ x2={{histWidth - histPadding.right}}
473
+ y2={{histHeight - histPadding.bottom}}
474
+ stroke="#374151"
475
+ />
476
+ <text x={{histWidth / 2}} y={{histHeight - 8}} textAnchor="middle" fontSize={{10}}>χ²</text>
477
+ </svg>
478
+ </div>
479
+
480
+ {{/* Summary Statistics */}}
481
+ <div className="card">
482
+ <h3 className="card-title">Chi-Squared Statistics</h3>
483
+ <div className="stat-row">
484
+ <span className="stat-label">Mean χ²:</span>
485
+ <span className="stat-value">{{stats.meanChi.toFixed(2)}}</span>
486
+ </div>
487
+ <div className="stat-row">
488
+ <span className="stat-label">Std χ²:</span>
489
+ <span className="stat-value">{{stats.stdChi.toFixed(2)}}</span>
490
+ </div>
491
+ <div className="stat-row">
492
+ <span className="stat-label">CV:</span>
493
+ <span className="stat-value">{{stats.cv.toFixed(4)}}</span>
494
+ </div>
495
+ <div className="stat-row">
496
+ <span className="stat-label">Range:</span>
497
+ <span className="stat-value">{{stats.minChi.toFixed(1)}} – {{stats.maxChi.toFixed(1)}}</span>
498
+ </div>
499
+ <div className="stat-row">
500
+ <span className="stat-label">Trend:</span>
501
+ <span className="stat-value">{{stats.trend >= 0 ? '+' : ''}}{{stats.trend.toFixed(4)}}</span>
502
+ </div>
503
+ {{stats.maxLocation !== null && (
504
+ <div className="stat-row">
505
+ <span className="stat-label">Max at:</span>
506
+ <span className="stat-value">Window {{stats.maxLocation}}</span>
507
+ </div>
508
+ )}}
509
+ <hr style={{{{ margin: '8px 0', border: 'none', borderTop: '1px solid #e2e8f0' }}}} />
510
+ <div className="stat-row">
511
+ <span className="stat-label">Spike threshold:</span>
512
+ <span className="stat-value">{{thresholds.spike.toFixed(2)}}</span>
513
+ </div>
514
+ </div>
515
+
516
+ {{/* Zone Classification */}}
517
+ <div className="card">
518
+ <h3 className="card-title">Zone Classification</h3>
519
+ <div style={{{{ marginBottom: 16 }}}}>
520
+ <div style={{{{ fontSize: 12, color: '#6b7280', marginBottom: 4 }}}}>Baseline</div>
521
+ <span className="zone-badge" style={{{{ background: zones.baseline.color }}}}>
522
+ {{zones.baseline.name}}
523
+ </span>
524
+ <div style={{{{ fontSize: 11, color: '#9ca3af', marginTop: 4 }}}}>
525
+ Mean χ² = {{stats.meanChi.toFixed(1)}}
526
+ </div>
527
+ </div>
528
+ <div style={{{{ marginBottom: 16 }}}}>
529
+ <div style={{{{ fontSize: 12, color: '#6b7280', marginBottom: 4 }}}}>Volatility</div>
530
+ <span className="zone-badge" style={{{{ background: zones.volatility.color }}}}>
531
+ {{zones.volatility.name}}
532
+ </span>
533
+ <div style={{{{ fontSize: 11, color: '#9ca3af', marginTop: 4 }}}}>
534
+ CV = {{stats.cv.toFixed(4)}}
535
+ </div>
536
+ </div>
537
+ <hr style={{{{ margin: '12px 0', border: 'none', borderTop: '1px solid #e2e8f0' }}}} />
538
+ <div style={{{{ fontSize: 11, color: '#6b7280' }}}}>
539
+ <div><strong>Reference Bounds:</strong></div>
540
+ <div>AI: Mean χ² &lt; {{bounds.MEAN_CHI_LOW}}</div>
541
+ <div>Human: Mean χ² &gt; {{bounds.MEAN_CHI_HIGH}}</div>
542
+ <div>Stable: CV &lt; {{bounds.CV_LOW}}</div>
543
+ <div>Volatile: CV &gt; {{bounds.CV_HIGH}}</div>
544
+ </div>
545
+ </div>
546
+
547
+ {{/* Analysis Results */}}
548
+ <div className="card">
549
+ <h3 className="card-title">Analysis Results</h3>
550
+ <div className="stat-row">
551
+ <span className="stat-label">Pattern:</span>
552
+ <span className="stat-value">{{stats.pattern.replace('_', ' ')}}</span>
553
+ </div>
554
+ <div className="stat-row">
555
+ <span className="stat-label">Confidence:</span>
556
+ <span className="stat-value">{{(stats.confidence * 100).toFixed(1)}}%</span>
557
+ </div>
558
+ </div>
559
+
560
+ {{/* Parameters */}}
561
+ <div className="card">
562
+ <h3 className="card-title">Parameters</h3>
563
+ <div className="stat-row">
564
+ <span className="stat-label">Window size:</span>
565
+ <span className="stat-value">{{stats.windowSize}} tokens</span>
566
+ </div>
567
+ <div className="stat-row">
568
+ <span className="stat-label">Stride:</span>
569
+ <span className="stat-value">{{stats.stride}} tokens</span>
570
+ </div>
571
+ <div className="stat-row">
572
+ <span className="stat-label">Overlap:</span>
573
+ <span className="stat-value">{{(stats.overlapRatio * 100).toFixed(0)}}%</span>
574
+ </div>
575
+ <div className="stat-row">
576
+ <span className="stat-label">Windows:</span>
577
+ <span className="stat-value">{{stats.windowCount}}</span>
578
+ </div>
579
+ <div className="stat-row">
580
+ <span className="stat-label">Comparisons:</span>
581
+ <span className="stat-value">{{points.length}}</span>
582
+ </div>
583
+ </div>
584
+
585
+ {{/* Top Contributors */}}
586
+ <div className="card">
587
+ <h3 className="card-title">
588
+ {{topWords.length > 0
589
+ ? `Top Contributors at Spike (Window ${{stats.maxLocation}})`
590
+ : 'Top Contributors at Spike'}}
591
+ </h3>
592
+ {{topWords.length > 0 ? (
593
+ topWords.map((w, i) => (
594
+ <div key={{i}} className="word-bar">
595
+ <span className="word-label">{{w.word}}</span>
596
+ <div className="word-bar-bg">
597
+ <div
598
+ className="word-bar-fill"
599
+ style={{{{ width: `${{(w.contribution / maxContribution) * 100}}%` }}}}
600
+ />
601
+ </div>
602
+ <span style={{{{ marginLeft: 8, fontSize: 11, color: '#6b7280', fontFamily: 'ui-monospace' }}}}>
603
+ {{w.contribution.toFixed(1)}}
604
+ </span>
605
+ </div>
606
+ ))
607
+ ) : (
608
+ <p style={{{{ fontSize: 13, color: '#9ca3af' }}}}>
609
+ {{stats.maxLocation !== null ? 'No word data available' : 'No spike detected'}}
610
+ </p>
611
+ )}}
612
+ </div>
613
+ </div>
614
+
615
+ {{/* Chunk panels - full width below grid, only when selected */}}
616
+ {{hasChunks && selectedPoint && selectedPoint.chunkA && (
617
+ <div style={{{{ display: 'flex', gap: 12, marginTop: 8 }}}}>
618
+ <div className="card" style={{{{ flex: 1, margin: 0, padding: '12px' }}}}>
619
+ <div style={{{{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}}}>
620
+ <h3 className="card-title" style={{{{ margin: 0, fontSize: 13 }}}}>Chunk {{selectedPoint.index}}</h3>
621
+ <button
622
+ onClick={{() => setSelectedIndex(null)}}
623
+ style={{{{
624
+ background: 'none',
625
+ border: 'none',
626
+ cursor: 'pointer',
627
+ fontSize: 14,
628
+ color: '#9ca3af',
629
+ padding: '0 4px',
630
+ }}}}
631
+ title="Close"
632
+ >✕</button>
633
+ </div>
634
+ <div style={{{{
635
+ padding: 10,
636
+ background: '#f8fafc',
637
+ borderRadius: 4,
638
+ fontSize: 11,
639
+ lineHeight: 1.5,
640
+ maxHeight: 220,
641
+ overflowY: 'auto',
642
+ whiteSpace: 'pre-wrap',
643
+ wordBreak: 'break-word',
644
+ border: '1px solid #e2e8f0',
645
+ }}}}>
646
+ {{selectedPoint.chunkA}}
647
+ </div>
648
+ </div>
649
+ {{selectedPoint.chunkB && (
650
+ <div className="card" style={{{{ flex: 1, margin: 0, padding: '12px' }}}}>
651
+ <h3 className="card-title" style={{{{ marginBottom: 6, fontSize: 13 }}}}>Chunk {{selectedPoint.index + 1}}</h3>
652
+ <div style={{{{
653
+ padding: 10,
654
+ background: '#f8fafc',
655
+ borderRadius: 4,
656
+ fontSize: 11,
657
+ lineHeight: 1.5,
658
+ maxHeight: 220,
659
+ overflowY: 'auto',
660
+ whiteSpace: 'pre-wrap',
661
+ wordBreak: 'break-word',
662
+ border: '1px solid #e2e8f0',
663
+ }}}}>
664
+ {{selectedPoint.chunkB}}
665
+ </div>
666
+ </div>
667
+ )}}
668
+ </div>
669
+ )}}
670
+
671
+ <p style={{{{ marginTop: 20, fontSize: 11, color: '#9ca3af', textAlign: 'center' }}}}>
672
+ Generated by pystylometry
673
+ </p>
674
+ </div>
675
+ );
676
+ }}
677
+ """