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.
- pystylometry/__init__.py +30 -5
- pystylometry/_normalize.py +277 -0
- pystylometry/_types.py +1954 -28
- pystylometry/_utils.py +4 -0
- pystylometry/authorship/__init__.py +26 -1
- pystylometry/authorship/additional_methods.py +75 -0
- pystylometry/authorship/kilgarriff.py +347 -0
- pystylometry/character/__init__.py +15 -0
- pystylometry/character/character_metrics.py +389 -0
- pystylometry/cli.py +427 -0
- pystylometry/consistency/__init__.py +57 -0
- pystylometry/consistency/_thresholds.py +162 -0
- pystylometry/consistency/drift.py +549 -0
- pystylometry/dialect/__init__.py +65 -0
- pystylometry/dialect/_data/dialect_markers.json +1134 -0
- pystylometry/dialect/_loader.py +360 -0
- pystylometry/dialect/detector.py +533 -0
- pystylometry/lexical/__init__.py +13 -6
- pystylometry/lexical/advanced_diversity.py +680 -0
- pystylometry/lexical/function_words.py +590 -0
- pystylometry/lexical/hapax.py +310 -33
- pystylometry/lexical/mtld.py +180 -22
- pystylometry/lexical/ttr.py +149 -0
- pystylometry/lexical/word_frequency_sophistication.py +1805 -0
- pystylometry/lexical/yule.py +142 -29
- pystylometry/ngrams/__init__.py +2 -0
- pystylometry/ngrams/entropy.py +150 -49
- pystylometry/ngrams/extended_ngrams.py +235 -0
- pystylometry/prosody/__init__.py +12 -0
- pystylometry/prosody/rhythm_prosody.py +53 -0
- pystylometry/readability/__init__.py +12 -0
- pystylometry/readability/additional_formulas.py +2110 -0
- pystylometry/readability/ari.py +173 -35
- pystylometry/readability/coleman_liau.py +150 -30
- pystylometry/readability/complex_words.py +531 -0
- pystylometry/readability/flesch.py +181 -32
- pystylometry/readability/gunning_fog.py +208 -35
- pystylometry/readability/smog.py +126 -28
- pystylometry/readability/syllables.py +137 -30
- pystylometry/stylistic/__init__.py +20 -0
- pystylometry/stylistic/cohesion_coherence.py +45 -0
- pystylometry/stylistic/genre_register.py +45 -0
- pystylometry/stylistic/markers.py +131 -0
- pystylometry/stylistic/vocabulary_overlap.py +47 -0
- pystylometry/syntactic/__init__.py +4 -0
- pystylometry/syntactic/advanced_syntactic.py +494 -0
- pystylometry/syntactic/pos_ratios.py +172 -17
- pystylometry/syntactic/sentence_stats.py +105 -18
- pystylometry/syntactic/sentence_types.py +526 -0
- pystylometry/viz/__init__.py +71 -0
- pystylometry/viz/drift.py +589 -0
- pystylometry/viz/jsx/__init__.py +31 -0
- pystylometry/viz/jsx/_base.py +144 -0
- pystylometry/viz/jsx/report.py +677 -0
- pystylometry/viz/jsx/timeline.py +716 -0
- pystylometry/viz/jsx/viewer.py +1032 -0
- {pystylometry-0.1.0.dist-info → pystylometry-1.1.0.dist-info}/METADATA +49 -9
- pystylometry-1.1.0.dist-info/RECORD +63 -0
- pystylometry-1.1.0.dist-info/entry_points.txt +4 -0
- pystylometry-0.1.0.dist-info/RECORD +0 -26
- {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
|
+
"""
|