pystylometry 1.0.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pystylometry/README.md +42 -0
- pystylometry/__init__.py +45 -3
- pystylometry/_types.py +1017 -259
- pystylometry/authorship/README.md +21 -0
- pystylometry/authorship/__init__.py +28 -4
- pystylometry/authorship/additional_methods.py +260 -40
- pystylometry/authorship/compression.py +175 -0
- pystylometry/authorship/kilgarriff.py +354 -0
- pystylometry/character/README.md +17 -0
- pystylometry/character/character_metrics.py +267 -179
- pystylometry/cli.py +427 -0
- pystylometry/consistency/README.md +27 -0
- pystylometry/consistency/__init__.py +57 -0
- pystylometry/consistency/_thresholds.py +162 -0
- pystylometry/consistency/drift.py +549 -0
- pystylometry/dialect/README.md +26 -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/README.md +23 -0
- pystylometry/lexical/advanced_diversity.py +61 -22
- pystylometry/lexical/function_words.py +255 -56
- pystylometry/lexical/hapax.py +182 -52
- pystylometry/lexical/mtld.py +108 -26
- pystylometry/lexical/ttr.py +76 -10
- pystylometry/lexical/word_frequency_sophistication.py +1522 -298
- pystylometry/lexical/yule.py +136 -50
- pystylometry/ngrams/README.md +18 -0
- pystylometry/ngrams/entropy.py +150 -49
- pystylometry/ngrams/extended_ngrams.py +314 -69
- pystylometry/prosody/README.md +17 -0
- pystylometry/prosody/rhythm_prosody.py +773 -11
- pystylometry/readability/README.md +23 -0
- pystylometry/readability/additional_formulas.py +1887 -762
- pystylometry/readability/ari.py +144 -82
- pystylometry/readability/coleman_liau.py +136 -109
- pystylometry/readability/flesch.py +177 -73
- pystylometry/readability/gunning_fog.py +165 -161
- pystylometry/readability/smog.py +123 -42
- pystylometry/stylistic/README.md +20 -0
- pystylometry/stylistic/cohesion_coherence.py +669 -13
- pystylometry/stylistic/genre_register.py +1560 -17
- pystylometry/stylistic/markers.py +611 -17
- pystylometry/stylistic/vocabulary_overlap.py +354 -13
- pystylometry/syntactic/README.md +20 -0
- pystylometry/syntactic/advanced_syntactic.py +76 -14
- pystylometry/syntactic/pos_ratios.py +70 -6
- pystylometry/syntactic/sentence_stats.py +55 -12
- pystylometry/syntactic/sentence_types.py +71 -15
- pystylometry/viz/README.md +27 -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-1.3.0.dist-info/METADATA +136 -0
- pystylometry-1.3.0.dist-info/RECORD +76 -0
- {pystylometry-1.0.0.dist-info → pystylometry-1.3.0.dist-info}/WHEEL +1 -1
- pystylometry-1.3.0.dist-info/entry_points.txt +4 -0
- pystylometry-1.0.0.dist-info/METADATA +0 -275
- pystylometry-1.0.0.dist-info/RECORD +0 -46
|
@@ -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
|
+
"""
|