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,1032 @@
1
+ """Standalone interactive viewer for Kilgarriff drift detection.
2
+
3
+ Creates a self-contained HTML file that:
4
+ - Accepts text file uploads
5
+ - Performs Kilgarriff chi-squared analysis client-side
6
+ - Displays interactive timeline visualization
7
+ - Can be shared and used without Python installed
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from ._base import write_html_file
15
+
16
+
17
+ def export_drift_viewer(
18
+ output_file: str | Path,
19
+ title: str = "Stylistic Drift Analyzer",
20
+ ) -> Path:
21
+ """
22
+ Export a standalone drift analysis viewer as HTML.
23
+
24
+ Creates a self-contained HTML file that users can open in any browser
25
+ to analyze their own text files. No Python or server required.
26
+
27
+ Features:
28
+ - Drag-and-drop or click to upload text files
29
+ - Configurable analysis parameters
30
+ - Interactive timeline visualization
31
+ - Client-side Kilgarriff chi-squared implementation
32
+
33
+ Args:
34
+ output_file: Path to write the HTML file
35
+ title: Page title
36
+
37
+ Returns:
38
+ Path to the generated HTML file
39
+
40
+ Example:
41
+ >>> export_drift_viewer("drift_analyzer.html")
42
+ # Share drift_analyzer.html with anyone - they can analyze their own texts
43
+ """
44
+ html_content = _generate_viewer_html(title)
45
+ return write_html_file(output_file, html_content)
46
+
47
+
48
+ def _generate_viewer_html(title: str) -> str:
49
+ """Generate the complete HTML for the standalone viewer."""
50
+ return f"""<!DOCTYPE html>
51
+ <html lang="en">
52
+ <head>
53
+ <meta charset="UTF-8">
54
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
55
+ <title>{title}</title>
56
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
57
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
58
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
59
+ <style>
60
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
61
+ body {{
62
+ font-family: system-ui, -apple-system, sans-serif;
63
+ background: #f8fafc;
64
+ min-height: 100vh;
65
+ padding: 20px;
66
+ }}
67
+ .card {{
68
+ background: white;
69
+ border-radius: 8px;
70
+ padding: 16px;
71
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
72
+ margin-bottom: 12px;
73
+ }}
74
+ .card-title {{
75
+ font-size: 14px;
76
+ font-weight: 600;
77
+ color: #1f2937;
78
+ margin-bottom: 12px;
79
+ }}
80
+ .stat-row {{
81
+ display: flex;
82
+ justify-content: space-between;
83
+ padding: 4px 0;
84
+ font-size: 12px;
85
+ }}
86
+ .stat-label {{ color: #6b7280; }}
87
+ .stat-value {{ font-weight: 500; color: #1f2937; font-family: monospace; }}
88
+ .dropzone {{
89
+ border: 2px dashed #cbd5e1;
90
+ border-radius: 12px;
91
+ padding: 60px 40px;
92
+ text-align: center;
93
+ cursor: pointer;
94
+ transition: all 0.2s;
95
+ background: white;
96
+ }}
97
+ .dropzone:hover, .dropzone.dragover {{
98
+ border-color: #2563eb;
99
+ background: #eff6ff;
100
+ }}
101
+ .dropzone-icon {{
102
+ font-size: 48px;
103
+ margin-bottom: 16px;
104
+ }}
105
+ .dropzone-text {{
106
+ font-size: 16px;
107
+ color: #4b5563;
108
+ margin-bottom: 8px;
109
+ }}
110
+ .dropzone-subtext {{
111
+ font-size: 13px;
112
+ color: #9ca3af;
113
+ }}
114
+ .param-group {{
115
+ display: flex;
116
+ gap: 16px;
117
+ margin-bottom: 16px;
118
+ flex-wrap: wrap;
119
+ }}
120
+ .param-item {{
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 4px;
124
+ }}
125
+ .param-label {{
126
+ font-size: 11px;
127
+ color: #6b7280;
128
+ font-weight: 500;
129
+ }}
130
+ .param-input {{
131
+ padding: 6px 10px;
132
+ border: 1px solid #e2e8f0;
133
+ border-radius: 4px;
134
+ font-size: 13px;
135
+ width: 120px;
136
+ }}
137
+ .param-input:focus {{
138
+ outline: none;
139
+ border-color: #2563eb;
140
+ }}
141
+ .btn {{
142
+ padding: 8px 16px;
143
+ border: none;
144
+ border-radius: 6px;
145
+ font-size: 13px;
146
+ font-weight: 500;
147
+ cursor: pointer;
148
+ transition: all 0.15s;
149
+ }}
150
+ .btn-primary {{
151
+ background: #2563eb;
152
+ color: white;
153
+ }}
154
+ .btn-primary:hover {{
155
+ background: #1d4ed8;
156
+ }}
157
+ .btn-secondary {{
158
+ background: #f1f5f9;
159
+ color: #475569;
160
+ }}
161
+ .btn-secondary:hover {{
162
+ background: #e2e8f0;
163
+ }}
164
+ .header {{
165
+ display: flex;
166
+ justify-content: space-between;
167
+ align-items: center;
168
+ margin-bottom: 20px;
169
+ }}
170
+ .header h1 {{
171
+ font-size: 20px;
172
+ font-weight: 600;
173
+ color: #1f2937;
174
+ }}
175
+ .file-info {{
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 12px;
179
+ padding: 12px 16px;
180
+ background: #f0fdf4;
181
+ border-radius: 8px;
182
+ margin-bottom: 16px;
183
+ }}
184
+ .file-info-icon {{
185
+ font-size: 24px;
186
+ }}
187
+ .file-info-details {{
188
+ flex: 1;
189
+ }}
190
+ .file-info-name {{
191
+ font-weight: 500;
192
+ color: #166534;
193
+ }}
194
+ .file-info-stats {{
195
+ font-size: 12px;
196
+ color: #4ade80;
197
+ }}
198
+ .processing {{
199
+ display: flex;
200
+ align-items: center;
201
+ justify-content: center;
202
+ gap: 12px;
203
+ padding: 40px;
204
+ color: #6b7280;
205
+ }}
206
+ .spinner {{
207
+ width: 24px;
208
+ height: 24px;
209
+ border: 3px solid #e2e8f0;
210
+ border-top-color: #2563eb;
211
+ border-radius: 50%;
212
+ animation: spin 1s linear infinite;
213
+ }}
214
+ @keyframes spin {{
215
+ to {{ transform: rotate(360deg); }}
216
+ }}
217
+ </style>
218
+ </head>
219
+ <body>
220
+ <div id="root"></div>
221
+ <script type="text/babel">
222
+ {_get_viewer_component()}
223
+ </script>
224
+ </body>
225
+ </html>"""
226
+
227
+
228
+ def _get_viewer_component() -> str:
229
+ """Return the React component for the viewer."""
230
+ return """
231
+ // Color interpolation for data points
232
+ function getPointColor(distance) {
233
+ const green = [34, 197, 94];
234
+ const yellow = [234, 179, 8];
235
+ const red = [239, 68, 68];
236
+
237
+ let r, g, b;
238
+ if (distance < 0.5) {
239
+ const t = distance * 2;
240
+ r = Math.round(green[0] + (yellow[0] - green[0]) * t);
241
+ g = Math.round(green[1] + (yellow[1] - green[1]) * t);
242
+ b = Math.round(green[2] + (yellow[2] - green[2]) * t);
243
+ } else {
244
+ const t = (distance - 0.5) * 2;
245
+ r = Math.round(yellow[0] + (red[0] - yellow[0]) * t);
246
+ g = Math.round(yellow[1] + (red[1] - yellow[1]) * t);
247
+ b = Math.round(yellow[2] + (red[2] - yellow[2]) * t);
248
+ }
249
+ return `rgb(${r}, ${g}, ${b})`;
250
+ }
251
+
252
+ // Kilgarriff chi-squared implementation
253
+ function computeKilgarriffDrift(text, windowSize, stride, nWords) {
254
+ // Tokenize: split on whitespace, keep only alphabetic tokens
255
+ const tokens = text.split(/\\s+/).filter(t => /^[a-zA-Z]+$/.test(t)).map(t => t.toLowerCase());
256
+
257
+ if (tokens.length < windowSize * 2) {
258
+ return { error: `Text too short. Need at least ${windowSize * 2} words, got ${tokens.length}.` };
259
+ }
260
+
261
+ // Create windows
262
+ const windows = [];
263
+ let start = 0;
264
+ while (start + windowSize <= tokens.length) {
265
+ windows.push(tokens.slice(start, start + windowSize));
266
+ start += stride;
267
+ }
268
+
269
+ if (windows.length < 3) {
270
+ return { error: `Not enough windows. Got ${windows.length}, need at least 3.` };
271
+ }
272
+
273
+ // Get word frequencies for each window
274
+ function getFrequencies(window) {
275
+ const freq = {};
276
+ for (const word of window) {
277
+ freq[word] = (freq[word] || 0) + 1;
278
+ }
279
+ return freq;
280
+ }
281
+
282
+ // Get top N words from combined corpus
283
+ const allFreq = {};
284
+ for (const w of windows) {
285
+ for (const word of w) {
286
+ allFreq[word] = (allFreq[word] || 0) + 1;
287
+ }
288
+ }
289
+ const sortedWords = Object.entries(allFreq)
290
+ .sort((a, b) => b[1] - a[1])
291
+ .slice(0, nWords)
292
+ .map(e => e[0]);
293
+
294
+ // Compute chi-squared between consecutive windows
295
+ function chiSquared(freq1, freq2, topWords) {
296
+ let chi = 0;
297
+ const total1 = Object.values(freq1).reduce((a, b) => a + b, 0);
298
+ const total2 = Object.values(freq2).reduce((a, b) => a + b, 0);
299
+
300
+ for (const word of topWords) {
301
+ const o1 = freq1[word] || 0;
302
+ const o2 = freq2[word] || 0;
303
+ const total = o1 + o2;
304
+ if (total === 0) continue;
305
+
306
+ const e1 = total * (total1 / (total1 + total2));
307
+ const e2 = total * (total2 / (total1 + total2));
308
+
309
+ if (e1 > 0) chi += Math.pow(o1 - e1, 2) / e1;
310
+ if (e2 > 0) chi += Math.pow(o2 - e2, 2) / e2;
311
+ }
312
+ return chi;
313
+ }
314
+
315
+ // Compute pairwise scores
316
+ const windowFreqs = windows.map(getFrequencies);
317
+ const pairwiseScores = [];
318
+ for (let i = 0; i < windows.length - 1; i++) {
319
+ const chi = chiSquared(windowFreqs[i], windowFreqs[i + 1], sortedWords);
320
+ pairwiseScores.push({ index: i, chi_squared: chi });
321
+ }
322
+
323
+ // Compute statistics
324
+ const chiValues = pairwiseScores.map(p => p.chi_squared);
325
+ const meanChi = chiValues.reduce((a, b) => a + b, 0) / chiValues.length;
326
+ const variance = chiValues.reduce((a, b) => a + Math.pow(b - meanChi, 2), 0) / chiValues.length;
327
+ const stdChi = Math.sqrt(variance);
328
+ const minChi = Math.min(...chiValues);
329
+ const maxChi = Math.max(...chiValues);
330
+ const maxLocation = chiValues.indexOf(maxChi);
331
+
332
+ // Compute trend (linear regression slope)
333
+ const n = chiValues.length;
334
+ const sumX = (n * (n - 1)) / 2;
335
+ const sumY = chiValues.reduce((a, b) => a + b, 0);
336
+ const sumXY = chiValues.reduce((a, y, i) => a + i * y, 0);
337
+ const sumX2 = (n * (n - 1) * (2 * n - 1)) / 6;
338
+ const trend = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
339
+
340
+ // Detect pattern
341
+ const cv = meanChi > 0 ? stdChi / meanChi : 0;
342
+ const spikeThreshold = meanChi + 2 * stdChi;
343
+ const hasSpike = maxChi > spikeThreshold && maxChi > meanChi * 2;
344
+
345
+ let pattern, confidence;
346
+ if (cv < 0.15 && meanChi < 50) {
347
+ pattern = "suspiciously_uniform";
348
+ confidence = 1 - cv / 0.15;
349
+ } else if (hasSpike) {
350
+ pattern = "sudden_spike";
351
+ confidence = Math.min(1, (maxChi - spikeThreshold) / spikeThreshold);
352
+ } else if (Math.abs(trend) > stdChi * 0.1) {
353
+ pattern = "gradual_drift";
354
+ confidence = Math.min(1, Math.abs(trend) / (stdChi * 0.2));
355
+ } else {
356
+ pattern = "consistent";
357
+ confidence = 1 - cv;
358
+ }
359
+
360
+ // Build chunk texts
361
+ const chunks = windows.map(w => w.join(" "));
362
+
363
+ return {
364
+ pairwiseScores,
365
+ chunks,
366
+ stats: {
367
+ pattern,
368
+ confidence: Math.max(0, Math.min(1, confidence)),
369
+ meanChi,
370
+ stdChi,
371
+ minChi,
372
+ maxChi,
373
+ cv,
374
+ maxLocation,
375
+ windowCount: windows.length,
376
+ windowSize,
377
+ stride,
378
+ overlapRatio: (windowSize - stride) / windowSize,
379
+ trend,
380
+ comparisons: pairwiseScores.length,
381
+ },
382
+ thresholds: {
383
+ mean: meanChi,
384
+ spike: spikeThreshold,
385
+ ai: 50,
386
+ }
387
+ };
388
+ }
389
+
390
+ function DriftViewer() {
391
+ const [file, setFile] = React.useState(null);
392
+ const [text, setText] = React.useState("");
393
+ const [params, setParams] = React.useState({
394
+ windowSize: 1000,
395
+ stride: 500,
396
+ nWords: 500,
397
+ });
398
+ const [result, setResult] = React.useState(null);
399
+ const [error, setError] = React.useState(null);
400
+ const [processing, setProcessing] = React.useState(false);
401
+ const [dragOver, setDragOver] = React.useState(false);
402
+
403
+ const fileInputRef = React.useRef(null);
404
+
405
+ const handleFile = (f) => {
406
+ if (!f) return;
407
+ setFile(f);
408
+ setError(null);
409
+ setResult(null);
410
+
411
+ const reader = new FileReader();
412
+ reader.onload = (e) => {
413
+ setText(e.target.result);
414
+ };
415
+ reader.onerror = () => {
416
+ setError("Failed to read file");
417
+ };
418
+ reader.readAsText(f);
419
+ };
420
+
421
+ const handleDrop = (e) => {
422
+ e.preventDefault();
423
+ setDragOver(false);
424
+ const f = e.dataTransfer.files[0];
425
+ if (f && f.type === "text/plain") {
426
+ handleFile(f);
427
+ } else {
428
+ setError("Please drop a .txt file");
429
+ }
430
+ };
431
+
432
+ const runAnalysis = () => {
433
+ if (!text) return;
434
+ setProcessing(true);
435
+ setError(null);
436
+
437
+ // Use setTimeout to allow UI to update
438
+ setTimeout(() => {
439
+ const r = computeKilgarriffDrift(text, params.windowSize, params.stride, params.nWords);
440
+ setProcessing(false);
441
+ if (r.error) {
442
+ setError(r.error);
443
+ } else {
444
+ setResult(r);
445
+ }
446
+ }, 50);
447
+ };
448
+
449
+ const reset = () => {
450
+ setFile(null);
451
+ setText("");
452
+ setResult(null);
453
+ setError(null);
454
+ };
455
+
456
+ // Auto-run analysis when text is loaded
457
+ React.useEffect(() => {
458
+ if (text && text.length > 0) {
459
+ runAnalysis();
460
+ }
461
+ }, [text]);
462
+
463
+ if (processing) {
464
+ return (
465
+ <div style={{ maxWidth: 800, margin: "0 auto" }}>
466
+ <div className="header">
467
+ <h1>Stylistic Drift Analyzer</h1>
468
+ </div>
469
+ <div className="card">
470
+ <div className="processing">
471
+ <div className="spinner"></div>
472
+ <span>Analyzing text...</span>
473
+ </div>
474
+ </div>
475
+ </div>
476
+ );
477
+ }
478
+
479
+ if (!file) {
480
+ return (
481
+ <div style={{ maxWidth: 600, margin: "0 auto" }}>
482
+ <div className="header">
483
+ <h1>Stylistic Drift Analyzer</h1>
484
+ </div>
485
+
486
+ <div className="card" style={{ marginBottom: 16 }}>
487
+ <div className="param-group">
488
+ <div className="param-item">
489
+ <label className="param-label">Window Size (tokens)</label>
490
+ <input
491
+ type="number"
492
+ className="param-input"
493
+ value={params.windowSize}
494
+ onChange={(e) => setParams({...params, windowSize: parseInt(e.target.value) || 1000})}
495
+ min={100}
496
+ max={5000}
497
+ />
498
+ </div>
499
+ <div className="param-item">
500
+ <label className="param-label">Stride (tokens)</label>
501
+ <input
502
+ type="number"
503
+ className="param-input"
504
+ value={params.stride}
505
+ onChange={(e) => setParams({...params, stride: parseInt(e.target.value) || 500})}
506
+ min={50}
507
+ max={2500}
508
+ />
509
+ </div>
510
+ <div className="param-item">
511
+ <label className="param-label">Top N Words</label>
512
+ <input
513
+ type="number"
514
+ className="param-input"
515
+ value={params.nWords}
516
+ onChange={(e) => setParams({...params, nWords: parseInt(e.target.value) || 500})}
517
+ min={50}
518
+ max={1000}
519
+ />
520
+ </div>
521
+ </div>
522
+ <div style={{ fontSize: 11, color: "#9ca3af" }}>
523
+ Overlap: {((params.windowSize - params.stride) / params.windowSize * 100).toFixed(0)}%
524
+ </div>
525
+ </div>
526
+
527
+ <div
528
+ className={`dropzone ${dragOver ? "dragover" : ""}`}
529
+ onClick={() => fileInputRef.current?.click()}
530
+ onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
531
+ onDragLeave={() => setDragOver(false)}
532
+ onDrop={handleDrop}
533
+ >
534
+ <div className="dropzone-icon">📄</div>
535
+ <div className="dropzone-text">Drop a text file here or click to browse</div>
536
+ <div className="dropzone-subtext">Supports .txt files</div>
537
+ <input
538
+ ref={fileInputRef}
539
+ type="file"
540
+ accept=".txt,text/plain"
541
+ style={{ display: "none" }}
542
+ onChange={(e) => handleFile(e.target.files?.[0])}
543
+ />
544
+ </div>
545
+
546
+ {error && (
547
+ <div style={{ marginTop: 16, padding: 12, background: "#fef2f2", borderRadius: 8, color: "#dc2626", fontSize: 13 }}>
548
+ {error}
549
+ </div>
550
+ )}
551
+
552
+ <div style={{ marginTop: 24, textAlign: "center", fontSize: 11, color: "#9ca3af" }}>
553
+ Powered by Kilgarriff Chi-Squared Analysis • Generated by pystylometry
554
+ </div>
555
+ </div>
556
+ );
557
+ }
558
+
559
+ if (!result) {
560
+ return (
561
+ <div style={{ maxWidth: 600, margin: "0 auto" }}>
562
+ <div className="header">
563
+ <h1>Stylistic Drift Analyzer</h1>
564
+ <button className="btn btn-secondary" onClick={reset}>← New File</button>
565
+ </div>
566
+
567
+ <div className="file-info">
568
+ <div className="file-info-icon">📄</div>
569
+ <div className="file-info-details">
570
+ <div className="file-info-name">{file.name}</div>
571
+ <div className="file-info-stats">{text.split(/\\s+/).length.toLocaleString()} words</div>
572
+ </div>
573
+ </div>
574
+
575
+ {error && (
576
+ <div style={{ padding: 12, background: "#fef2f2", borderRadius: 8, color: "#dc2626", fontSize: 13 }}>
577
+ {error}
578
+ </div>
579
+ )}
580
+ </div>
581
+ );
582
+ }
583
+
584
+ return <ResultsView file={file} result={result} params={params} onReset={reset} />;
585
+ }
586
+
587
+ function ResultsView({ file, result, params, onReset }) {
588
+ const [hoveredIndex, setHoveredIndex] = React.useState(null);
589
+ const [selectedIndex, setSelectedIndex] = React.useState(null);
590
+
591
+ const { pairwiseScores, chunks, stats, thresholds } = result;
592
+
593
+ // Build points data
594
+ const points = pairwiseScores.map((p, i) => {
595
+ const zScore = stats.stdChi > 0 ? Math.abs(p.chi_squared - stats.meanChi) / stats.stdChi : 0;
596
+ const distance = Math.min(1, zScore / 3);
597
+ return {
598
+ index: i,
599
+ chi: p.chi_squared,
600
+ distance,
601
+ window_pair: `${i} → ${i + 1}`,
602
+ chunkA: chunks[i],
603
+ chunkB: chunks[i + 1],
604
+ };
605
+ });
606
+
607
+ // SVG dimensions
608
+ const width = 750;
609
+ const height = 400;
610
+ const padding = { top: 40, right: 30, bottom: 50, left: 70 };
611
+ const plotWidth = width - padding.left - padding.right;
612
+ const plotHeight = height - padding.top - padding.bottom;
613
+
614
+ // Bounds
615
+ const yMin = 0;
616
+ const yMax = Math.max(...points.map(p => p.chi)) * 1.15;
617
+ const xMax = points.length - 1;
618
+
619
+ // Scale functions
620
+ const scaleX = (idx) => padding.left + (idx / xMax) * plotWidth;
621
+ const scaleY = (chi) => padding.top + plotHeight - ((chi - yMin) / (yMax - yMin)) * plotHeight;
622
+
623
+ // Active state
624
+ const activeIndex = selectedIndex !== null ? selectedIndex : hoveredIndex;
625
+ const activePoint = activeIndex !== null ? points[activeIndex] : null;
626
+ const isSelected = selectedIndex !== null;
627
+ const selectedPoint = selectedIndex !== null ? points[selectedIndex] : null;
628
+
629
+ const handlePointClick = (i) => {
630
+ setSelectedIndex(selectedIndex === i ? null : i);
631
+ };
632
+
633
+ // Build paths
634
+ const linePath = points.map((p, i) =>
635
+ `${i === 0 ? "M" : "L"} ${scaleX(p.index)} ${scaleY(p.chi)}`
636
+ ).join(" ");
637
+
638
+ const fillPath = linePath +
639
+ ` L ${scaleX(points[points.length - 1].index)} ${scaleY(0)}` +
640
+ ` L ${scaleX(0)} ${scaleY(0)} Z`;
641
+
642
+ // Classification helper
643
+ const getClassification = (chi) => {
644
+ if (chi < thresholds.ai) return { label: "AI-like", color: "#f59e0b", desc: "Below AI baseline - unusually uniform" };
645
+ if (chi < thresholds.mean * 0.7) return { label: "Low variance", color: "#10b981", desc: "Below mean - consistent style" };
646
+ if (chi < thresholds.mean * 1.3) return { label: "Typical", color: "#6b7280", desc: "Near mean - normal variation" };
647
+ if (chi < thresholds.spike) return { label: "Elevated", color: "#f97316", desc: "Above mean - notable variation" };
648
+ return { label: "Spike", color: "#dc2626", desc: "Above spike threshold - significant change" };
649
+ };
650
+
651
+ return (
652
+ <div style={{ maxWidth: width + 310, margin: "0 auto" }}>
653
+ <div className="header">
654
+ <h1>Stylistic Drift Analyzer</h1>
655
+ <button className="btn btn-secondary" onClick={onReset}>← New File</button>
656
+ </div>
657
+
658
+ <div className="file-info">
659
+ <div className="file-info-icon">📄</div>
660
+ <div className="file-info-details">
661
+ <div className="file-info-name">{file.name}</div>
662
+ <div className="file-info-stats">
663
+ {stats.windowCount} windows • {stats.comparisons} comparisons
664
+ </div>
665
+ </div>
666
+ </div>
667
+
668
+ {/* Top row: Chart + Stats */}
669
+ <div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
670
+ {/* Chart */}
671
+ <svg width={width} height={height} style={{ background: "white", borderRadius: 8, boxShadow: "0 1px 3px rgba(0,0,0,0.1)" }}>
672
+ {/* Title */}
673
+ <text x={width / 2} y={20} textAnchor="middle" fontSize={14} fontWeight="500">
674
+ Drift Timeline: {file.name}
675
+ </text>
676
+
677
+ {/* Fill under curve */}
678
+ <path d={fillPath} fill="#dbeafe" opacity={0.4} />
679
+
680
+ {/* Mean line */}
681
+ <line
682
+ x1={padding.left}
683
+ y1={scaleY(thresholds.mean)}
684
+ x2={width - padding.right}
685
+ y2={scaleY(thresholds.mean)}
686
+ stroke="#6b7280"
687
+ strokeWidth={1}
688
+ opacity={0.6}
689
+ />
690
+ <text
691
+ x={width - padding.right + 5}
692
+ y={scaleY(thresholds.mean)}
693
+ fontSize={10}
694
+ fill="#6b7280"
695
+ dominantBaseline="middle"
696
+ >μ</text>
697
+
698
+ {/* AI threshold */}
699
+ {thresholds.ai < yMax && (
700
+ <>
701
+ <line
702
+ x1={padding.left}
703
+ y1={scaleY(thresholds.ai)}
704
+ x2={width - padding.right}
705
+ y2={scaleY(thresholds.ai)}
706
+ stroke="#f59e0b"
707
+ strokeWidth={1}
708
+ strokeDasharray="4,4"
709
+ opacity={0.6}
710
+ />
711
+ <text
712
+ x={padding.left - 5}
713
+ y={scaleY(thresholds.ai)}
714
+ fontSize={10}
715
+ fill="#f59e0b"
716
+ textAnchor="end"
717
+ dominantBaseline="middle"
718
+ >AI</text>
719
+ </>
720
+ )}
721
+
722
+ {/* Spike threshold */}
723
+ {thresholds.spike < yMax && (
724
+ <>
725
+ <line
726
+ x1={padding.left}
727
+ y1={scaleY(thresholds.spike)}
728
+ x2={width - padding.right}
729
+ y2={scaleY(thresholds.spike)}
730
+ stroke="#10b981"
731
+ strokeWidth={1}
732
+ strokeDasharray="4,4"
733
+ opacity={0.6}
734
+ />
735
+ <text
736
+ x={padding.left - 5}
737
+ y={scaleY(thresholds.spike)}
738
+ fontSize={10}
739
+ fill="#10b981"
740
+ textAnchor="end"
741
+ dominantBaseline="middle"
742
+ >μ+2σ</text>
743
+ </>
744
+ )}
745
+
746
+ {/* Max location line */}
747
+ {stats.maxLocation !== null && (
748
+ <line
749
+ x1={scaleX(stats.maxLocation)}
750
+ y1={padding.top}
751
+ x2={scaleX(stats.maxLocation)}
752
+ y2={height - padding.bottom}
753
+ stroke="#dc2626"
754
+ strokeWidth={2}
755
+ strokeDasharray="6,4"
756
+ opacity={0.5}
757
+ />
758
+ )}
759
+
760
+ {/* Main line */}
761
+ <path
762
+ d={linePath}
763
+ fill="none"
764
+ stroke="#2563eb"
765
+ strokeWidth={2}
766
+ strokeLinejoin="round"
767
+ />
768
+
769
+ {/* Data points */}
770
+ {points.map((point, i) => {
771
+ const isActive = activeIndex === i;
772
+ const isPinned = selectedIndex === i;
773
+ return (
774
+ <circle
775
+ key={i}
776
+ cx={scaleX(point.index)}
777
+ cy={scaleY(point.chi)}
778
+ r={isActive ? 7 : 5}
779
+ fill={getPointColor(point.distance)}
780
+ stroke={isPinned ? "#dc2626" : isActive ? "#1f2937" : "white"}
781
+ strokeWidth={isPinned ? 3 : isActive ? 2 : 1.5}
782
+ style={{ cursor: "pointer", transition: "all 0.15s ease" }}
783
+ onMouseEnter={() => setHoveredIndex(i)}
784
+ onMouseLeave={() => setHoveredIndex(null)}
785
+ onClick={() => handlePointClick(i)}
786
+ />
787
+ );
788
+ })}
789
+
790
+ {/* X-axis */}
791
+ <line
792
+ x1={padding.left}
793
+ y1={height - padding.bottom}
794
+ x2={width - padding.right}
795
+ y2={height - padding.bottom}
796
+ stroke="#374151"
797
+ />
798
+ <text x={width / 2} y={height - 10} textAnchor="middle" fontSize={12}>
799
+ Window Pair Index
800
+ </text>
801
+
802
+ {/* Y-axis */}
803
+ <line
804
+ x1={padding.left}
805
+ y1={padding.top}
806
+ x2={padding.left}
807
+ y2={height - padding.bottom}
808
+ stroke="#374151"
809
+ />
810
+ <text
811
+ x={20}
812
+ y={height / 2}
813
+ textAnchor="middle"
814
+ fontSize={12}
815
+ transform={`rotate(-90, 20, ${height / 2})`}
816
+ >
817
+ Chi-squared (χ²)
818
+ </text>
819
+
820
+ {/* Y-axis ticks */}
821
+ {[0, 0.25, 0.5, 0.75, 1].map((t) => {
822
+ const val = yMin + t * (yMax - yMin);
823
+ return (
824
+ <text
825
+ key={t}
826
+ x={padding.left - 8}
827
+ y={scaleY(val)}
828
+ textAnchor="end"
829
+ fontSize={10}
830
+ fill="#6b7280"
831
+ dominantBaseline="middle"
832
+ >
833
+ {val.toFixed(0)}
834
+ </text>
835
+ );
836
+ })}
837
+ </svg>
838
+
839
+ {/* Stats panel */}
840
+ <div className="card" style={{ width: 280, height: height, overflowY: "auto" }}>
841
+ <h3 className="card-title">Analysis Results</h3>
842
+ <div className="stat-row">
843
+ <span className="stat-label">Pattern:</span>
844
+ <span className="stat-value">{stats.pattern.replace("_", " ")}</span>
845
+ </div>
846
+ <div className="stat-row">
847
+ <span className="stat-label">Confidence:</span>
848
+ <span className="stat-value">{(stats.confidence * 100).toFixed(1)}%</span>
849
+ </div>
850
+ <hr style={{ margin: "10px 0", border: "none", borderTop: "1px solid #e2e8f0" }} />
851
+ <div style={{ fontSize: 11, fontWeight: 500, color: "#6b7280", marginBottom: 6 }}>CHI-SQUARED STATISTICS</div>
852
+ <div className="stat-row">
853
+ <span className="stat-label">Mean χ²:</span>
854
+ <span className="stat-value">{stats.meanChi.toFixed(2)}</span>
855
+ </div>
856
+ <div className="stat-row">
857
+ <span className="stat-label">Std χ²:</span>
858
+ <span className="stat-value">{stats.stdChi.toFixed(2)}</span>
859
+ </div>
860
+ <div className="stat-row">
861
+ <span className="stat-label">CV:</span>
862
+ <span className="stat-value">{stats.cv.toFixed(4)}</span>
863
+ </div>
864
+ <div className="stat-row">
865
+ <span className="stat-label">Range:</span>
866
+ <span className="stat-value">{stats.minChi.toFixed(1)} – {stats.maxChi.toFixed(1)}</span>
867
+ </div>
868
+ <div className="stat-row">
869
+ <span className="stat-label">Trend:</span>
870
+ <span className="stat-value">{stats.trend >= 0 ? "+" : ""}{stats.trend.toFixed(4)}</span>
871
+ </div>
872
+ {stats.maxLocation !== null && (
873
+ <div className="stat-row">
874
+ <span className="stat-label">Max at:</span>
875
+ <span className="stat-value">Window {stats.maxLocation}</span>
876
+ </div>
877
+ )}
878
+ <hr style={{ margin: "10px 0", border: "none", borderTop: "1px solid #e2e8f0" }} />
879
+ <div style={{ fontSize: 11, fontWeight: 500, color: "#6b7280", marginBottom: 6 }}>PARAMETERS</div>
880
+ <div className="stat-row">
881
+ <span className="stat-label">Window size:</span>
882
+ <span className="stat-value">{stats.windowSize} tokens</span>
883
+ </div>
884
+ <div className="stat-row">
885
+ <span className="stat-label">Stride:</span>
886
+ <span className="stat-value">{stats.stride} tokens</span>
887
+ </div>
888
+ <div className="stat-row">
889
+ <span className="stat-label">Overlap:</span>
890
+ <span className="stat-value">{(stats.overlapRatio * 100).toFixed(0)}%</span>
891
+ </div>
892
+ <div className="stat-row">
893
+ <span className="stat-label">Windows:</span>
894
+ <span className="stat-value">{stats.windowCount}</span>
895
+ </div>
896
+ <div className="stat-row">
897
+ <span className="stat-label">Comparisons:</span>
898
+ <span className="stat-value">{stats.comparisons}</span>
899
+ </div>
900
+ <hr style={{ margin: "10px 0", border: "none", borderTop: "1px solid #e2e8f0" }} />
901
+ <div style={{ fontSize: 11, fontWeight: 500, color: "#6b7280", marginBottom: 6 }}>THRESHOLDS</div>
902
+ <div className="stat-row">
903
+ <span className="stat-label">AI baseline:</span>
904
+ <span className="stat-value">{thresholds.ai}</span>
905
+ </div>
906
+ <div className="stat-row">
907
+ <span className="stat-label">Mean (μ):</span>
908
+ <span className="stat-value">{thresholds.mean.toFixed(2)}</span>
909
+ </div>
910
+ <div className="stat-row">
911
+ <span className="stat-label">Spike (μ+2σ):</span>
912
+ <span className="stat-value">{thresholds.spike.toFixed(2)}</span>
913
+ </div>
914
+
915
+ {!selectedPoint && (
916
+ <p style={{ marginTop: 10, fontSize: 11, color: "#9ca3af" }}>
917
+ Click a point to view comparison details
918
+ </p>
919
+ )}
920
+ </div>
921
+ </div>
922
+
923
+ {/* Bottom row: 3 panels when point selected */}
924
+ {selectedPoint && (() => {
925
+ const classification = getClassification(selectedPoint.chi);
926
+ const deviation = (selectedPoint.distance * 3).toFixed(2);
927
+ const percentile = Math.round((points.filter(p => p.chi <= selectedPoint.chi).length / points.length) * 100);
928
+
929
+ return (
930
+ <div style={{ display: "flex", gap: 12, marginTop: 12 }}>
931
+ {/* Chunk A */}
932
+ <div className="card" style={{ flex: 1, margin: 0, padding: "12px" }}>
933
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
934
+ <h3 className="card-title" style={{ margin: 0, fontSize: 13 }}>Chunk {selectedPoint.index}</h3>
935
+ <button
936
+ onClick={() => setSelectedIndex(null)}
937
+ style={{
938
+ background: "none",
939
+ border: "none",
940
+ cursor: "pointer",
941
+ fontSize: 14,
942
+ color: "#9ca3af",
943
+ padding: "0 4px",
944
+ }}
945
+ title="Close"
946
+ >✕</button>
947
+ </div>
948
+ <div style={{
949
+ padding: 10,
950
+ background: "#f8fafc",
951
+ borderRadius: 4,
952
+ fontSize: 11,
953
+ lineHeight: 1.5,
954
+ maxHeight: 200,
955
+ overflowY: "auto",
956
+ whiteSpace: "pre-wrap",
957
+ wordBreak: "break-word",
958
+ border: "1px solid #e2e8f0",
959
+ }}>
960
+ {selectedPoint.chunkA}
961
+ </div>
962
+ </div>
963
+
964
+ {/* Comparison Analysis */}
965
+ <div className="card" style={{ width: 220, margin: 0, padding: "12px" }}>
966
+ <h3 className="card-title" style={{ margin: 0, marginBottom: 10, fontSize: 13 }}>Comparison Analysis</h3>
967
+ <div className="stat-row">
968
+ <span className="stat-label">Window pair:</span>
969
+ <span className="stat-value">{selectedPoint.window_pair}</span>
970
+ </div>
971
+ <div className="stat-row">
972
+ <span className="stat-label">Chi-squared:</span>
973
+ <span className="stat-value">{selectedPoint.chi.toFixed(2)}</span>
974
+ </div>
975
+ <div className="stat-row">
976
+ <span className="stat-label">Deviation:</span>
977
+ <span className="stat-value">{deviation}σ</span>
978
+ </div>
979
+ <div className="stat-row">
980
+ <span className="stat-label">Percentile:</span>
981
+ <span className="stat-value">{percentile}%</span>
982
+ </div>
983
+ <hr style={{ margin: "8px 0", border: "none", borderTop: "1px solid #e2e8f0" }} />
984
+ <div style={{
985
+ padding: "8px 10px",
986
+ background: classification.color + "15",
987
+ borderRadius: 4,
988
+ borderLeft: `3px solid ${classification.color}`
989
+ }}>
990
+ <div style={{ fontWeight: 600, fontSize: 12, color: classification.color, marginBottom: 4 }}>
991
+ {classification.label}
992
+ </div>
993
+ <div style={{ fontSize: 10, color: "#6b7280", lineHeight: 1.4 }}>
994
+ {classification.desc}
995
+ </div>
996
+ </div>
997
+ </div>
998
+
999
+ {/* Chunk B */}
1000
+ {selectedPoint.chunkB && (
1001
+ <div className="card" style={{ flex: 1, margin: 0, padding: "12px" }}>
1002
+ <h3 className="card-title" style={{ marginBottom: 6, fontSize: 13 }}>Chunk {selectedPoint.index + 1}</h3>
1003
+ <div style={{
1004
+ padding: 10,
1005
+ background: "#f8fafc",
1006
+ borderRadius: 4,
1007
+ fontSize: 11,
1008
+ lineHeight: 1.5,
1009
+ maxHeight: 200,
1010
+ overflowY: "auto",
1011
+ whiteSpace: "pre-wrap",
1012
+ wordBreak: "break-word",
1013
+ border: "1px solid #e2e8f0",
1014
+ }}>
1015
+ {selectedPoint.chunkB}
1016
+ </div>
1017
+ </div>
1018
+ )}
1019
+ </div>
1020
+ );
1021
+ })()}
1022
+
1023
+ <p style={{ marginTop: 16, fontSize: 11, color: "#9ca3af", textAlign: "center" }}>
1024
+ Powered by Kilgarriff Chi-Squared Analysis • Generated by pystylometry
1025
+ </p>
1026
+ </div>
1027
+ );
1028
+ }
1029
+
1030
+ const root = ReactDOM.createRoot(document.getElementById("root"));
1031
+ root.render(<DriftViewer />);
1032
+ """