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