pystylometry 0.1.0__py3-none-any.whl → 1.1.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 (61) hide show
  1. pystylometry/__init__.py +30 -5
  2. pystylometry/_normalize.py +277 -0
  3. pystylometry/_types.py +1954 -28
  4. pystylometry/_utils.py +4 -0
  5. pystylometry/authorship/__init__.py +26 -1
  6. pystylometry/authorship/additional_methods.py +75 -0
  7. pystylometry/authorship/kilgarriff.py +347 -0
  8. pystylometry/character/__init__.py +15 -0
  9. pystylometry/character/character_metrics.py +389 -0
  10. pystylometry/cli.py +427 -0
  11. pystylometry/consistency/__init__.py +57 -0
  12. pystylometry/consistency/_thresholds.py +162 -0
  13. pystylometry/consistency/drift.py +549 -0
  14. pystylometry/dialect/__init__.py +65 -0
  15. pystylometry/dialect/_data/dialect_markers.json +1134 -0
  16. pystylometry/dialect/_loader.py +360 -0
  17. pystylometry/dialect/detector.py +533 -0
  18. pystylometry/lexical/__init__.py +13 -6
  19. pystylometry/lexical/advanced_diversity.py +680 -0
  20. pystylometry/lexical/function_words.py +590 -0
  21. pystylometry/lexical/hapax.py +310 -33
  22. pystylometry/lexical/mtld.py +180 -22
  23. pystylometry/lexical/ttr.py +149 -0
  24. pystylometry/lexical/word_frequency_sophistication.py +1805 -0
  25. pystylometry/lexical/yule.py +142 -29
  26. pystylometry/ngrams/__init__.py +2 -0
  27. pystylometry/ngrams/entropy.py +150 -49
  28. pystylometry/ngrams/extended_ngrams.py +235 -0
  29. pystylometry/prosody/__init__.py +12 -0
  30. pystylometry/prosody/rhythm_prosody.py +53 -0
  31. pystylometry/readability/__init__.py +12 -0
  32. pystylometry/readability/additional_formulas.py +2110 -0
  33. pystylometry/readability/ari.py +173 -35
  34. pystylometry/readability/coleman_liau.py +150 -30
  35. pystylometry/readability/complex_words.py +531 -0
  36. pystylometry/readability/flesch.py +181 -32
  37. pystylometry/readability/gunning_fog.py +208 -35
  38. pystylometry/readability/smog.py +126 -28
  39. pystylometry/readability/syllables.py +137 -30
  40. pystylometry/stylistic/__init__.py +20 -0
  41. pystylometry/stylistic/cohesion_coherence.py +45 -0
  42. pystylometry/stylistic/genre_register.py +45 -0
  43. pystylometry/stylistic/markers.py +131 -0
  44. pystylometry/stylistic/vocabulary_overlap.py +47 -0
  45. pystylometry/syntactic/__init__.py +4 -0
  46. pystylometry/syntactic/advanced_syntactic.py +494 -0
  47. pystylometry/syntactic/pos_ratios.py +172 -17
  48. pystylometry/syntactic/sentence_stats.py +105 -18
  49. pystylometry/syntactic/sentence_types.py +526 -0
  50. pystylometry/viz/__init__.py +71 -0
  51. pystylometry/viz/drift.py +589 -0
  52. pystylometry/viz/jsx/__init__.py +31 -0
  53. pystylometry/viz/jsx/_base.py +144 -0
  54. pystylometry/viz/jsx/report.py +677 -0
  55. pystylometry/viz/jsx/timeline.py +716 -0
  56. pystylometry/viz/jsx/viewer.py +1032 -0
  57. {pystylometry-0.1.0.dist-info → pystylometry-1.1.0.dist-info}/METADATA +49 -9
  58. pystylometry-1.1.0.dist-info/RECORD +63 -0
  59. pystylometry-1.1.0.dist-info/entry_points.txt +4 -0
  60. pystylometry-0.1.0.dist-info/RECORD +0 -26
  61. {pystylometry-0.1.0.dist-info → pystylometry-1.1.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,716 @@
1
+ """Interactive timeline visualization for Kilgarriff drift detection.
2
+
3
+ Creates a line chart with:
4
+ - X-axis: Window pair index (temporal position in document)
5
+ - Y-axis: Chi-squared value
6
+ - Hover interactions showing point details
7
+ - Reference threshold lines
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
+
26
+ def export_drift_timeline_jsx(
27
+ result: "KilgarriffDriftResult",
28
+ output_file: str | Path,
29
+ title: str = "Stylistic Drift Timeline",
30
+ chunks: list[str] | None = None,
31
+ ) -> Path:
32
+ """
33
+ Export an interactive timeline visualization as a standalone HTML file.
34
+
35
+ Creates a self-contained HTML file with React via CDN:
36
+ - Line chart showing chi-squared values over document
37
+ - Hover over points to see detailed values
38
+ - Reference lines for mean, AI threshold, and spike threshold
39
+ - Opens directly in any browser (no build step required)
40
+
41
+ Args:
42
+ result: KilgarriffDriftResult from compute_kilgarriff_drift()
43
+ output_file: Path to write the HTML file (e.g., "timeline.html")
44
+ title: Chart title
45
+ chunks: Optional list of chunk text content for hover display
46
+
47
+ Returns:
48
+ Path to the generated HTML file
49
+
50
+ Example:
51
+ >>> result = compute_kilgarriff_drift(text)
52
+ >>> export_drift_timeline_jsx(result, "timeline.html")
53
+ """
54
+ chi_values = [s["chi_squared"] for s in result.pairwise_scores]
55
+
56
+ if len(chi_values) < 2:
57
+ raise ValueError("Need at least 2 window comparisons for timeline")
58
+
59
+ # Build data points with distance from mean for coloring
60
+ mean_chi = result.mean_chi_squared
61
+ std_chi = result.std_chi_squared
62
+ spike_threshold = mean_chi + 2 * std_chi if mean_chi > 0 else 100
63
+
64
+ points_data = []
65
+ for i, chi in enumerate(chi_values):
66
+ # Distance: how many std devs from mean
67
+ if std_chi > 0:
68
+ z_score = abs(chi - mean_chi) / std_chi
69
+ distance = min(1, z_score / 3) # Normalize: 3 std devs = max distance
70
+ else:
71
+ distance = 0 if chi == mean_chi else 1
72
+
73
+ point = {
74
+ "index": i,
75
+ "chi": round(chi, 2),
76
+ "distance": round(distance, 3),
77
+ "window_pair": f"{i} → {i + 1}",
78
+ }
79
+
80
+ # Add chunk text if available
81
+ if chunks:
82
+ if i < len(chunks):
83
+ point["chunkA"] = chunks[i]
84
+ if i + 1 < len(chunks):
85
+ point["chunkB"] = chunks[i + 1]
86
+
87
+ points_data.append(point)
88
+
89
+ # Axis bounds
90
+ y_min = 0
91
+ y_max = max(chi_values) * 1.15
92
+
93
+ # Get top words at spike if available
94
+ top_words = []
95
+ if result.max_location is not None and result.max_location < len(result.pairwise_scores):
96
+ spike_data = result.pairwise_scores[result.max_location]
97
+ if "top_words" in spike_data and spike_data["top_words"]:
98
+ top_words = [
99
+ {"word": w[0], "contribution": round(w[1], 2)} for w in spike_data["top_words"][:8]
100
+ ]
101
+
102
+ config = {
103
+ "title": title,
104
+ "points": points_data,
105
+ "hasChunks": chunks is not None and len(chunks) > 0,
106
+ "topWords": top_words,
107
+ "bounds": {
108
+ "xMax": len(chi_values) - 1,
109
+ "yMin": y_min,
110
+ "yMax": round(y_max, 2),
111
+ },
112
+ "thresholds": {
113
+ "mean": round(mean_chi, 2),
114
+ "spike": round(spike_threshold, 2),
115
+ "ai": 50, # AI baseline threshold
116
+ },
117
+ "stats": {
118
+ "pattern": result.pattern,
119
+ "confidence": round(result.pattern_confidence, 3),
120
+ "meanChi": round(mean_chi, 2),
121
+ "stdChi": round(std_chi, 2),
122
+ "minChi": round(result.min_chi_squared, 2),
123
+ "maxChi": round(result.max_chi_squared, 2),
124
+ "cv": round(std_chi / mean_chi, 4) if mean_chi > 0 else 0,
125
+ "maxLocation": result.max_location,
126
+ "windowCount": result.window_count,
127
+ "windowSize": result.window_size,
128
+ "stride": result.stride,
129
+ "overlapRatio": round(result.overlap_ratio, 2),
130
+ "trend": round(result.trend, 4),
131
+ "comparisons": len(result.pairwise_scores),
132
+ },
133
+ }
134
+
135
+ component = _get_timeline_component()
136
+ html_content = generate_html_document(
137
+ title=f"{title} - Drift Timeline",
138
+ config=config,
139
+ react_component=component,
140
+ component_name="DriftTimeline",
141
+ extra_styles=CARD_STYLES,
142
+ )
143
+
144
+ return write_html_file(output_file, html_content)
145
+
146
+
147
+ def _get_timeline_component() -> str:
148
+ """Return the React component code for the timeline visualization."""
149
+ return f"""
150
+ {COLOR_INTERPOLATION_JS}
151
+
152
+ function DriftTimeline() {{
153
+ const [hoveredIndex, setHoveredIndex] = React.useState(null);
154
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
155
+ const [middlePanelView, setMiddlePanelView] = React.useState(0); // 0=Analysis, 1=Parameters, 2=Top Contributors
156
+
157
+ const {{ title, points, bounds, thresholds, stats, hasChunks, topWords }} = CONFIG;
158
+
159
+ // Keyboard navigation for chart points
160
+ React.useEffect(() => {{
161
+ const handleKeyDown = (e) => {{
162
+ if (e.key === 'ArrowLeft') {{
163
+ if (selectedIndex === null) {{
164
+ setSelectedIndex(points.length - 1); // Start at end
165
+ }} else if (selectedIndex > 0) {{
166
+ setSelectedIndex(selectedIndex - 1);
167
+ }}
168
+ e.preventDefault();
169
+ }} else if (e.key === 'ArrowRight') {{
170
+ if (selectedIndex === null) {{
171
+ setSelectedIndex(0); // Start at beginning
172
+ }} else if (selectedIndex < points.length - 1) {{
173
+ setSelectedIndex(selectedIndex + 1);
174
+ }}
175
+ e.preventDefault();
176
+ }} else if (e.key === 'Escape') {{
177
+ setSelectedIndex(null);
178
+ e.preventDefault();
179
+ }}
180
+ }};
181
+ window.addEventListener('keydown', handleKeyDown);
182
+ return () => window.removeEventListener('keydown', handleKeyDown);
183
+ }}, [selectedIndex, points.length]);
184
+
185
+ // Max word contribution for scaling
186
+ const maxContribution = topWords && topWords.length > 0 ? Math.max(...topWords.map(w => w.contribution)) : 1;
187
+ const {{ xMax, yMin, yMax }} = bounds;
188
+
189
+ // SVG dimensions
190
+ const width = 750;
191
+ const height = 400;
192
+ const padding = {{ top: 40, right: 30, bottom: 50, left: 70 }};
193
+ const plotWidth = width - padding.left - padding.right;
194
+ const plotHeight = height - padding.top - padding.bottom;
195
+
196
+ // Scale functions
197
+ const scaleX = (idx) => padding.left + (idx / xMax) * plotWidth;
198
+ const scaleY = (chi) => padding.top + plotHeight - ((chi - yMin) / (yMax - yMin)) * plotHeight;
199
+
200
+ // Active index: selected takes priority, then hovered
201
+ const activeIndex = selectedIndex !== null ? selectedIndex : hoveredIndex;
202
+ const activePoint = activeIndex !== null ? points[activeIndex] : null;
203
+ const isSelected = selectedIndex !== null;
204
+ const selectedPoint = selectedIndex !== null ? points[selectedIndex] : null;
205
+
206
+ // Handle click to select/deselect
207
+ const handlePointClick = (i) => {{
208
+ if (selectedIndex === i) {{
209
+ setSelectedIndex(null);
210
+ }} else {{
211
+ setSelectedIndex(i);
212
+ }}
213
+ }};
214
+
215
+ // Build path for line
216
+ const linePath = points.map((p, i) =>
217
+ `${{i === 0 ? 'M' : 'L'}} ${{scaleX(p.index)}} ${{scaleY(p.chi)}}`
218
+ ).join(' ');
219
+
220
+ // Build path for fill under curve
221
+ const fillPath = linePath +
222
+ ` L ${{scaleX(points[points.length - 1].index)}} ${{scaleY(0)}}` +
223
+ ` L ${{scaleX(0)}} ${{scaleY(0)}} Z`;
224
+
225
+ // Classify comparison based on thresholds
226
+ const getClassification = (chi) => {{
227
+ if (chi < thresholds.ai) return {{ label: 'AI-like', color: '#f59e0b', desc: 'Below AI baseline - unusually uniform' }};
228
+ if (chi < thresholds.mean * 0.7) return {{ label: 'Low variance', color: '#10b981', desc: 'Below mean - consistent style' }};
229
+ if (chi < thresholds.mean * 1.3) return {{ label: 'Typical', color: '#6b7280', desc: 'Near mean - normal variation' }};
230
+ if (chi < thresholds.spike) return {{ label: 'Elevated', color: '#f97316', desc: 'Above mean - notable variation' }};
231
+ return {{ label: 'Spike', color: '#dc2626', desc: 'Above spike threshold - significant change' }};
232
+ }};
233
+
234
+ return (
235
+ <div style={{{{ fontFamily: 'system-ui, sans-serif', maxWidth: width + 310 }}}}>
236
+ {{/* Top row: Chart + Stats */}}
237
+ <div style={{{{ display: 'flex', gap: 20, alignItems: 'flex-start' }}}}>
238
+ {{/* Chart */}}
239
+ <svg width={{width}} height={{height}} style={{{{ background: 'white', borderRadius: 8, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}}}>
240
+ {{/* Title */}}
241
+ <text x={{width / 2}} y={{20}} textAnchor="middle" fontSize={{14}} fontWeight="500">
242
+ {{title}}
243
+ </text>
244
+
245
+ {{/* Fill under curve */}}
246
+ <path d={{fillPath}} fill="#dbeafe" opacity={{0.4}} />
247
+
248
+ {{/* Reference lines */}}
249
+ {{/* Mean line */}}
250
+ <line
251
+ x1={{padding.left}}
252
+ y1={{scaleY(thresholds.mean)}}
253
+ x2={{width - padding.right}}
254
+ y2={{scaleY(thresholds.mean)}}
255
+ stroke="#6b7280"
256
+ strokeWidth={{1}}
257
+ opacity={{0.6}}
258
+ />
259
+ <text
260
+ x={{width - padding.right + 5}}
261
+ y={{scaleY(thresholds.mean)}}
262
+ fontSize={{10}}
263
+ fill="#6b7280"
264
+ dominantBaseline="middle"
265
+ >
266
+ μ
267
+ </text>
268
+
269
+ {{/* AI threshold line */}}
270
+ {{thresholds.ai < yMax && (
271
+ <>
272
+ <line
273
+ x1={{padding.left}}
274
+ y1={{scaleY(thresholds.ai)}}
275
+ x2={{width - padding.right}}
276
+ y2={{scaleY(thresholds.ai)}}
277
+ stroke="#f59e0b"
278
+ strokeWidth={{1}}
279
+ strokeDasharray="4,4"
280
+ opacity={{0.6}}
281
+ />
282
+ <text
283
+ x={{padding.left - 5}}
284
+ y={{scaleY(thresholds.ai)}}
285
+ fontSize={{10}}
286
+ fill="#f59e0b"
287
+ textAnchor="end"
288
+ dominantBaseline="middle"
289
+ >
290
+ AI
291
+ </text>
292
+ </>
293
+ )}}
294
+
295
+ {{/* Spike threshold line */}}
296
+ {{thresholds.spike < yMax && (
297
+ <>
298
+ <line
299
+ x1={{padding.left}}
300
+ y1={{scaleY(thresholds.spike)}}
301
+ x2={{width - padding.right}}
302
+ y2={{scaleY(thresholds.spike)}}
303
+ stroke="#10b981"
304
+ strokeWidth={{1}}
305
+ strokeDasharray="4,4"
306
+ opacity={{0.6}}
307
+ />
308
+ <text
309
+ x={{padding.left - 5}}
310
+ y={{scaleY(thresholds.spike)}}
311
+ fontSize={{10}}
312
+ fill="#10b981"
313
+ textAnchor="end"
314
+ dominantBaseline="middle"
315
+ >
316
+ μ+2σ
317
+ </text>
318
+ </>
319
+ )}}
320
+
321
+ {{/* Max location vertical line */}}
322
+ {{stats.maxLocation !== null && (
323
+ <line
324
+ x1={{scaleX(stats.maxLocation)}}
325
+ y1={{padding.top}}
326
+ x2={{scaleX(stats.maxLocation)}}
327
+ y2={{height - padding.bottom}}
328
+ stroke="#dc2626"
329
+ strokeWidth={{2}}
330
+ strokeDasharray="6,4"
331
+ opacity={{0.5}}
332
+ />
333
+ )}}
334
+
335
+ {{/* Main line */}}
336
+ <path
337
+ d={{linePath}}
338
+ fill="none"
339
+ stroke="#2563eb"
340
+ strokeWidth={{2}}
341
+ strokeLinejoin="round"
342
+ />
343
+
344
+ {{/* Data points */}}
345
+ {{points.map((point, i) => {{
346
+ const isActive = activeIndex === i;
347
+ const isPinned = selectedIndex === i;
348
+ return (
349
+ <circle
350
+ key={{i}}
351
+ cx={{scaleX(point.index)}}
352
+ cy={{scaleY(point.chi)}}
353
+ r={{isActive ? 7 : 5}}
354
+ fill={{getPointColor(point.distance)}}
355
+ stroke={{isPinned ? '#dc2626' : isActive ? '#1f2937' : 'white'}}
356
+ strokeWidth={{isPinned ? 3 : isActive ? 2 : 1.5}}
357
+ style={{{{ cursor: 'pointer', transition: 'all 0.15s ease' }}}}
358
+ onMouseEnter={{() => setHoveredIndex(i)}}
359
+ onMouseLeave={{() => setHoveredIndex(null)}}
360
+ onClick={{() => handlePointClick(i)}}
361
+ />
362
+ );
363
+ }})}}
364
+
365
+ {{/* X-axis */}}
366
+ <line
367
+ x1={{padding.left}}
368
+ y1={{height - padding.bottom}}
369
+ x2={{width - padding.right}}
370
+ y2={{height - padding.bottom}}
371
+ stroke="#374151"
372
+ />
373
+ <text x={{width / 2}} y={{height - 10}} textAnchor="middle" fontSize={{12}}>
374
+ Window Pair Index
375
+ </text>
376
+
377
+ {{/* Y-axis */}}
378
+ <line
379
+ x1={{padding.left}}
380
+ y1={{padding.top}}
381
+ x2={{padding.left}}
382
+ y2={{height - padding.bottom}}
383
+ stroke="#374151"
384
+ />
385
+ <text
386
+ x={{20}}
387
+ y={{height / 2}}
388
+ textAnchor="middle"
389
+ fontSize={{12}}
390
+ transform={{`rotate(-90, 20, ${{height / 2}})`}}
391
+ >
392
+ Chi-squared (χ²)
393
+ </text>
394
+
395
+ {{/* Y-axis tick labels */}}
396
+ {{[0, 0.25, 0.5, 0.75, 1].map((t) => {{
397
+ const val = yMin + t * (yMax - yMin);
398
+ return (
399
+ <text
400
+ key={{t}}
401
+ x={{padding.left - 8}}
402
+ y={{scaleY(val)}}
403
+ textAnchor="end"
404
+ fontSize={{10}}
405
+ fill="#6b7280"
406
+ dominantBaseline="middle"
407
+ >
408
+ {{val.toFixed(0)}}
409
+ </text>
410
+ );
411
+ }})}}
412
+ </svg>
413
+
414
+ {{/* Stats panel - matches chart height */}}
415
+ <div className="card" style={{{{ width: 280, height: height, overflowY: 'auto' }}}}>
416
+ <h3 className="card-title">Analysis Results</h3>
417
+ <div className="stat-row">
418
+ <span className="stat-label">Pattern:</span>
419
+ <span className="stat-value">{{stats.pattern.replace('_', ' ')}}</span>
420
+ </div>
421
+ <div className="stat-row">
422
+ <span className="stat-label">Confidence:</span>
423
+ <span className="stat-value">{{(stats.confidence * 100).toFixed(1)}}%</span>
424
+ </div>
425
+ <hr style={{{{ margin: '10px 0', border: 'none', borderTop: '1px solid #e2e8f0' }}}} />
426
+ <div style={{{{ fontSize: 11, fontWeight: 500, color: '#6b7280', marginBottom: 6 }}}}>CHI-SQUARED STATISTICS</div>
427
+ <div className="stat-row">
428
+ <span className="stat-label">Mean χ²:</span>
429
+ <span className="stat-value">{{stats.meanChi.toFixed(2)}}</span>
430
+ </div>
431
+ <div className="stat-row">
432
+ <span className="stat-label">Std χ²:</span>
433
+ <span className="stat-value">{{stats.stdChi.toFixed(2)}}</span>
434
+ </div>
435
+ <div className="stat-row">
436
+ <span className="stat-label">CV:</span>
437
+ <span className="stat-value">{{stats.cv.toFixed(4)}}</span>
438
+ </div>
439
+ <div className="stat-row">
440
+ <span className="stat-label">Range:</span>
441
+ <span className="stat-value">{{stats.minChi.toFixed(1)}} – {{stats.maxChi.toFixed(1)}}</span>
442
+ </div>
443
+ <div className="stat-row">
444
+ <span className="stat-label">Trend:</span>
445
+ <span className="stat-value">{{stats.trend >= 0 ? '+' : ''}}{{stats.trend.toFixed(4)}}</span>
446
+ </div>
447
+ {{stats.maxLocation !== null && (
448
+ <div className="stat-row">
449
+ <span className="stat-label">Max at:</span>
450
+ <span className="stat-value">Window {{stats.maxLocation}}</span>
451
+ </div>
452
+ )}}
453
+ <hr style={{{{ margin: '10px 0', border: 'none', borderTop: '1px solid #e2e8f0' }}}} />
454
+ <div style={{{{ fontSize: 11, fontWeight: 500, color: '#6b7280', marginBottom: 6 }}}}>PARAMETERS</div>
455
+ <div className="stat-row">
456
+ <span className="stat-label">Window size:</span>
457
+ <span className="stat-value">{{stats.windowSize}} tokens</span>
458
+ </div>
459
+ <div className="stat-row">
460
+ <span className="stat-label">Stride:</span>
461
+ <span className="stat-value">{{stats.stride}} tokens</span>
462
+ </div>
463
+ <div className="stat-row">
464
+ <span className="stat-label">Overlap:</span>
465
+ <span className="stat-value">{{(stats.overlapRatio * 100).toFixed(0)}}%</span>
466
+ </div>
467
+ <div className="stat-row">
468
+ <span className="stat-label">Windows:</span>
469
+ <span className="stat-value">{{stats.windowCount}}</span>
470
+ </div>
471
+ <div className="stat-row">
472
+ <span className="stat-label">Comparisons:</span>
473
+ <span className="stat-value">{{stats.comparisons}}</span>
474
+ </div>
475
+ <hr style={{{{ margin: '10px 0', border: 'none', borderTop: '1px solid #e2e8f0' }}}} />
476
+ <div style={{{{ fontSize: 11, fontWeight: 500, color: '#6b7280', marginBottom: 6 }}}}>THRESHOLDS</div>
477
+ <div className="stat-row">
478
+ <span className="stat-label">AI baseline:</span>
479
+ <span className="stat-value">{{thresholds.ai}}</span>
480
+ </div>
481
+ <div className="stat-row">
482
+ <span className="stat-label">Mean (μ):</span>
483
+ <span className="stat-value">{{thresholds.mean.toFixed(2)}}</span>
484
+ </div>
485
+ <div className="stat-row">
486
+ <span className="stat-label">Spike (μ+2σ):</span>
487
+ <span className="stat-value">{{thresholds.spike.toFixed(2)}}</span>
488
+ </div>
489
+
490
+ {{!selectedPoint && (
491
+ <p style={{{{ marginTop: 10, fontSize: 11, color: '#9ca3af' }}}}>
492
+ {{hasChunks ? 'Click a point to view comparison details' : 'Hover over points for details'}}
493
+ </p>
494
+ )}}
495
+ </div>
496
+ </div>
497
+
498
+ {{/* Bottom row: 3 panels when point selected */}}
499
+ {{hasChunks && selectedPoint && selectedPoint.chunkA && (() => {{
500
+ const classification = getClassification(selectedPoint.chi);
501
+ const deviation = (selectedPoint.distance * 3).toFixed(2);
502
+ const percentile = Math.round((points.filter(p => p.chi <= selectedPoint.chi).length / points.length) * 100);
503
+
504
+ return (
505
+ <div style={{{{ display: 'flex', gap: 12, marginTop: 12 }}}}>
506
+ {{/* Chunk A */}}
507
+ <div className="card" style={{{{ flex: 1, margin: 0, padding: '12px' }}}}>
508
+ <div style={{{{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}}}>
509
+ <h3 className="card-title" style={{{{ margin: 0, fontSize: 13 }}}}>Chunk {{selectedPoint.index}}</h3>
510
+ <button
511
+ onClick={{() => setSelectedIndex(null)}}
512
+ style={{{{
513
+ background: 'none',
514
+ border: 'none',
515
+ cursor: 'pointer',
516
+ fontSize: 14,
517
+ color: '#9ca3af',
518
+ padding: '0 4px',
519
+ }}}}
520
+ title="Close"
521
+ >✕</button>
522
+ </div>
523
+ <div style={{{{
524
+ padding: 10,
525
+ background: '#f8fafc',
526
+ borderRadius: 4,
527
+ fontSize: 11,
528
+ lineHeight: 1.5,
529
+ maxHeight: 260,
530
+ overflowY: 'auto',
531
+ whiteSpace: 'pre-wrap',
532
+ wordBreak: 'break-word',
533
+ border: '1px solid #e2e8f0',
534
+ }}}}>
535
+ {{selectedPoint.chunkA}}
536
+ </div>
537
+ </div>
538
+
539
+ {{/* Middle panel with carousel navigation */}}
540
+ <div className="card" style={{{{ width: 260, height: 320, margin: 0, padding: '12px', display: 'flex', flexDirection: 'column', position: 'relative' }}}}>
541
+ {{/* Header with arrows */}}
542
+ <div style={{{{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}}}>
543
+ <button
544
+ onClick={{() => setMiddlePanelView(v => v === 0 ? 2 : v - 1)}}
545
+ style={{{{
546
+ background: '#f1f5f9',
547
+ border: '1px solid #e2e8f0',
548
+ borderRadius: 4,
549
+ cursor: 'pointer',
550
+ fontSize: 14,
551
+ padding: '4px 8px',
552
+ color: '#64748b',
553
+ }}}}
554
+ title="Previous view"
555
+ >←</button>
556
+ <h3 className="card-title" style={{{{ margin: 0, fontSize: 13 }}}}>
557
+ {{middlePanelView === 0 ? 'Comparison Analysis' : middlePanelView === 1 ? 'Parameters' : 'Top Contributors'}}
558
+ </h3>
559
+ <button
560
+ onClick={{() => setMiddlePanelView(v => v === 2 ? 0 : v + 1)}}
561
+ style={{{{
562
+ background: '#f1f5f9',
563
+ border: '1px solid #e2e8f0',
564
+ borderRadius: 4,
565
+ cursor: 'pointer',
566
+ fontSize: 14,
567
+ padding: '4px 8px',
568
+ color: '#64748b',
569
+ }}}}
570
+ title="Next view"
571
+ >→</button>
572
+ </div>
573
+
574
+ {{/* Content area - flex grow */}}
575
+ <div style={{{{ flex: 1 }}}}>
576
+ {{/* View 0: Comparison Analysis */}}
577
+ {{middlePanelView === 0 && (
578
+ <>
579
+ <div className="stat-row">
580
+ <span className="stat-label">Window pair:</span>
581
+ <span className="stat-value">{{selectedPoint.window_pair}}</span>
582
+ </div>
583
+ <div className="stat-row">
584
+ <span className="stat-label">Chi-squared:</span>
585
+ <span className="stat-value">{{selectedPoint.chi.toFixed(2)}}</span>
586
+ </div>
587
+ <div className="stat-row">
588
+ <span className="stat-label">Deviation:</span>
589
+ <span className="stat-value">{{deviation}}σ</span>
590
+ </div>
591
+ <div className="stat-row">
592
+ <span className="stat-label">Percentile:</span>
593
+ <span className="stat-value">{{percentile}}%</span>
594
+ </div>
595
+ <hr style={{{{ margin: '8px 0', border: 'none', borderTop: '1px solid #e2e8f0' }}}} />
596
+ <div style={{{{
597
+ padding: '8px 10px',
598
+ background: classification.color + '15',
599
+ borderRadius: 4,
600
+ borderLeft: `3px solid ${{classification.color}}`
601
+ }}}}>
602
+ <div style={{{{ fontWeight: 600, fontSize: 12, color: classification.color, marginBottom: 4 }}}}>
603
+ {{classification.label}}
604
+ </div>
605
+ <div style={{{{ fontSize: 10, color: '#6b7280', lineHeight: 1.4 }}}}>
606
+ {{classification.desc}}
607
+ </div>
608
+ </div>
609
+ </>
610
+ )}}
611
+
612
+ {{/* View 1: Parameters */}}
613
+ {{middlePanelView === 1 && (
614
+ <>
615
+ <div className="stat-row">
616
+ <span className="stat-label">Window size:</span>
617
+ <span className="stat-value">{{stats.windowSize}} tokens</span>
618
+ </div>
619
+ <div className="stat-row">
620
+ <span className="stat-label">Stride:</span>
621
+ <span className="stat-value">{{stats.stride}} tokens</span>
622
+ </div>
623
+ <div className="stat-row">
624
+ <span className="stat-label">Overlap:</span>
625
+ <span className="stat-value">{{(stats.overlapRatio * 100).toFixed(0)}}%</span>
626
+ </div>
627
+ <div className="stat-row">
628
+ <span className="stat-label">Windows:</span>
629
+ <span className="stat-value">{{stats.windowCount}}</span>
630
+ </div>
631
+ <div className="stat-row">
632
+ <span className="stat-label">Comparisons:</span>
633
+ <span className="stat-value">{{stats.comparisons}}</span>
634
+ </div>
635
+ </>
636
+ )}}
637
+
638
+ {{/* View 2: Top Contributors */}}
639
+ {{middlePanelView === 2 && (
640
+ <>
641
+ {{topWords && topWords.length > 0 ? (
642
+ topWords.map((w, i) => (
643
+ <div key={{i}} style={{{{ display: 'flex', alignItems: 'center', marginBottom: 4, fontSize: 11 }}}}>
644
+ <span style={{{{ width: 60, fontFamily: 'ui-monospace, monospace', overflow: 'hidden', textOverflow: 'ellipsis' }}}}>{{w.word}}</span>
645
+ <div style={{{{ flex: 1, height: 14, background: '#e2e8f0', borderRadius: 2, marginLeft: 6, overflow: 'hidden' }}}}>
646
+ <div style={{{{ height: '100%', width: `${{(w.contribution / maxContribution) * 100}}%`, background: '#2563eb', borderRadius: 2 }}}} />
647
+ </div>
648
+ <span style={{{{ marginLeft: 6, fontSize: 10, color: '#6b7280', fontFamily: 'ui-monospace', minWidth: 32, textAlign: 'right' }}}}>
649
+ {{w.contribution.toFixed(1)}}
650
+ </span>
651
+ </div>
652
+ ))
653
+ ) : (
654
+ <p style={{{{ fontSize: 12, color: '#9ca3af', textAlign: 'center', marginTop: 20 }}}}>
655
+ {{stats.maxLocation !== null ? 'No word data available' : 'No spike detected'}}
656
+ </p>
657
+ )}}
658
+ {{topWords && topWords.length > 0 && (
659
+ <div style={{{{ fontSize: 10, color: '#9ca3af', marginTop: 8, textAlign: 'center' }}}}>
660
+ Spike at Window {{stats.maxLocation}}
661
+ </div>
662
+ )}}
663
+ </>
664
+ )}}
665
+ </div>
666
+
667
+ {{/* Panel indicator dots - fixed at bottom */}}
668
+ <div style={{{{ display: 'flex', justifyContent: 'center', gap: 6, paddingTop: 10 }}}}>
669
+ {{[0, 1, 2].map(i => (
670
+ <div
671
+ key={{i}}
672
+ onClick={{() => setMiddlePanelView(i)}}
673
+ style={{{{
674
+ width: 8,
675
+ height: 8,
676
+ borderRadius: '50%',
677
+ background: middlePanelView === i ? '#2563eb' : '#e2e8f0',
678
+ cursor: 'pointer',
679
+ }}}}
680
+ title={{i === 0 ? 'Comparison Analysis' : i === 1 ? 'Parameters' : 'Top Contributors'}}
681
+ />
682
+ ))}}
683
+ </div>
684
+ </div>
685
+
686
+ {{/* Chunk B */}}
687
+ {{selectedPoint.chunkB && (
688
+ <div className="card" style={{{{ flex: 1, margin: 0, padding: '12px' }}}}>
689
+ <h3 className="card-title" style={{{{ marginBottom: 6, fontSize: 13 }}}}>Chunk {{selectedPoint.index + 1}}</h3>
690
+ <div style={{{{
691
+ padding: 10,
692
+ background: '#f8fafc',
693
+ borderRadius: 4,
694
+ fontSize: 11,
695
+ lineHeight: 1.5,
696
+ maxHeight: 260,
697
+ overflowY: 'auto',
698
+ whiteSpace: 'pre-wrap',
699
+ wordBreak: 'break-word',
700
+ border: '1px solid #e2e8f0',
701
+ }}}}>
702
+ {{selectedPoint.chunkB}}
703
+ </div>
704
+ </div>
705
+ )}}
706
+ </div>
707
+ );
708
+ }})()}}
709
+
710
+ <p style={{{{ marginTop: 16, fontSize: 11, color: '#9ca3af', textAlign: 'center' }}}}>
711
+ Generated by pystylometry
712
+ </p>
713
+ </div>
714
+ );
715
+ }}
716
+ """