parse-bench 1.0.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.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,2066 @@
|
|
|
1
|
+
"""HTML report generator for pipeline comparisons.
|
|
2
|
+
|
|
3
|
+
Generates a rich, interactive comparison report matching the dashboard's
|
|
4
|
+
warm editorial design system (Newsreader / Plus Jakarta Sans / JetBrains Mono).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import html
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from parse_bench.analysis.metric_definitions import (
|
|
14
|
+
TOOLTIP_CSS,
|
|
15
|
+
TOOLTIP_JS,
|
|
16
|
+
display_name_dict,
|
|
17
|
+
tooltip_dict,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_file_data_url(file_path: Path) -> str | None:
|
|
22
|
+
"""Convert a file to a data URL for embedding in HTML."""
|
|
23
|
+
if not file_path.exists():
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
# Determine MIME type
|
|
28
|
+
suffix = file_path.suffix.lower()
|
|
29
|
+
mime_types = {
|
|
30
|
+
".pdf": "application/pdf",
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".jpg": "image/jpeg",
|
|
33
|
+
".jpeg": "image/jpeg",
|
|
34
|
+
".gif": "image/gif",
|
|
35
|
+
}
|
|
36
|
+
mime_type = mime_types.get(suffix, "application/octet-stream")
|
|
37
|
+
|
|
38
|
+
# Read file and encode
|
|
39
|
+
with open(file_path, "rb") as f:
|
|
40
|
+
file_data = f.read()
|
|
41
|
+
encoded = base64.b64encode(file_data).decode("utf-8")
|
|
42
|
+
return f"data:{mime_type};base64,{encoded}"
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
METRIC_DISPLAY_NAMES: dict[str, str] = display_name_dict()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_pct(value: float | None, decimals: int = 1) -> str:
|
|
51
|
+
if value is None:
|
|
52
|
+
return "N/A"
|
|
53
|
+
try:
|
|
54
|
+
return f"{float(value) * 100:.{decimals}f}%"
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return "N/A"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _embed_input_files(
|
|
60
|
+
matched_results: list[dict[str, Any]],
|
|
61
|
+
test_cases_dir: Path | None,
|
|
62
|
+
original_base_path: str,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Embed image input files as base64 data URLs and compute relative paths (in-place)."""
|
|
65
|
+
import os
|
|
66
|
+
|
|
67
|
+
# Use original_base_path as the base for relative path computation
|
|
68
|
+
# (may differ from test_cases_dir which requires local existence)
|
|
69
|
+
base_for_rel = original_base_path or (str(test_cases_dir) if test_cases_dir else "")
|
|
70
|
+
|
|
71
|
+
for result in matched_results:
|
|
72
|
+
input_file = result.get("input_file")
|
|
73
|
+
if not input_file:
|
|
74
|
+
continue
|
|
75
|
+
fp = Path(input_file)
|
|
76
|
+
if not fp.exists() and test_cases_dir:
|
|
77
|
+
rel = fp.name
|
|
78
|
+
candidate = test_cases_dir / rel
|
|
79
|
+
if candidate.exists():
|
|
80
|
+
fp = candidate
|
|
81
|
+
|
|
82
|
+
# Compute relative path for PDF.js URL resolution
|
|
83
|
+
# Try file-system-based relpath first, fall back to string manipulation
|
|
84
|
+
if test_cases_dir and fp.exists():
|
|
85
|
+
try:
|
|
86
|
+
result["input_file_rel"] = os.path.relpath(str(fp.resolve()), str(test_cases_dir.resolve()))
|
|
87
|
+
except ValueError:
|
|
88
|
+
result["input_file_rel"] = str(fp)
|
|
89
|
+
elif base_for_rel and input_file.startswith(base_for_rel):
|
|
90
|
+
# String-based relative path (for CI paths that don't exist locally)
|
|
91
|
+
rel_path = input_file[len(base_for_rel) :]
|
|
92
|
+
if rel_path.startswith("/"):
|
|
93
|
+
rel_path = rel_path[1:]
|
|
94
|
+
result["input_file_rel"] = rel_path
|
|
95
|
+
|
|
96
|
+
# Embed images as base64 (not PDFs — they're too large, use PDF.js instead)
|
|
97
|
+
if fp.exists() and fp.suffix.lower() in (".png", ".jpg", ".jpeg", ".gif"):
|
|
98
|
+
data_url = _get_file_data_url(fp)
|
|
99
|
+
if data_url:
|
|
100
|
+
result["input_data_url"] = data_url
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def generate_comparison_html(comparison_data: dict[str, Any], output_path: Path | None = None) -> str | Path:
|
|
104
|
+
"""
|
|
105
|
+
Generate an interactive HTML report for pipeline comparison.
|
|
106
|
+
|
|
107
|
+
:param comparison_data: Comparison data from compare_pipelines()
|
|
108
|
+
:param output_path: Optional path to save the HTML report. If None, returns HTML string.
|
|
109
|
+
:return: HTML string if output_path is None, otherwise Path to the generated file
|
|
110
|
+
"""
|
|
111
|
+
matched_results = comparison_data["matched_results"]
|
|
112
|
+
stats = comparison_data["stats"]
|
|
113
|
+
product_type = comparison_data.get("product_type", "extract")
|
|
114
|
+
comparison_metric = comparison_data.get("comparison_metric", "accuracy")
|
|
115
|
+
original_base_path = comparison_data.get("original_base_path", "")
|
|
116
|
+
pdf_base_url = comparison_data.get("pdf_base_url", "")
|
|
117
|
+
|
|
118
|
+
metric_display_name = METRIC_DISPLAY_NAMES.get(comparison_metric, comparison_metric)
|
|
119
|
+
|
|
120
|
+
# Embed input images as data URLs and compute relative paths
|
|
121
|
+
test_cases_dir_str = comparison_data.get("original_base_path", "")
|
|
122
|
+
test_cases_dir = Path(test_cases_dir_str) if test_cases_dir_str else None
|
|
123
|
+
_embed_input_files(matched_results, test_cases_dir, original_base_path)
|
|
124
|
+
|
|
125
|
+
pipeline_a_name = html.escape(stats["pipeline_a_name"])
|
|
126
|
+
pipeline_b_name = html.escape(stats["pipeline_b_name"])
|
|
127
|
+
|
|
128
|
+
# Sort matched results by test_id
|
|
129
|
+
matched_results_sorted = sorted(matched_results, key=lambda r: r["test_id"])
|
|
130
|
+
|
|
131
|
+
html_content = _build_html(
|
|
132
|
+
matched_results_sorted,
|
|
133
|
+
stats,
|
|
134
|
+
product_type,
|
|
135
|
+
comparison_metric,
|
|
136
|
+
metric_display_name,
|
|
137
|
+
pipeline_a_name,
|
|
138
|
+
pipeline_b_name,
|
|
139
|
+
original_base_path,
|
|
140
|
+
pdf_base_url,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if output_path is None:
|
|
144
|
+
return html_content
|
|
145
|
+
|
|
146
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
with open(output_path, "w") as f:
|
|
148
|
+
f.write(html_content)
|
|
149
|
+
return output_path
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_html(
|
|
153
|
+
matched_results: list[dict[str, Any]],
|
|
154
|
+
stats: dict[str, Any],
|
|
155
|
+
product_type: str,
|
|
156
|
+
comparison_metric: str,
|
|
157
|
+
metric_display_name: str,
|
|
158
|
+
pipeline_a_name: str,
|
|
159
|
+
pipeline_b_name: str,
|
|
160
|
+
original_base_path: str,
|
|
161
|
+
pdf_base_url: str = "",
|
|
162
|
+
) -> str:
|
|
163
|
+
"""Build the full HTML document."""
|
|
164
|
+
|
|
165
|
+
# Pre-compute result rows HTML
|
|
166
|
+
rows_html = []
|
|
167
|
+
for i, result in enumerate(matched_results):
|
|
168
|
+
rows_html.append(_build_result_row(result, stats, i))
|
|
169
|
+
|
|
170
|
+
title = f"Pipeline Comparison: {pipeline_a_name} vs {pipeline_b_name}"
|
|
171
|
+
|
|
172
|
+
return f"""<!DOCTYPE html>
|
|
173
|
+
<html lang="en">
|
|
174
|
+
<head>
|
|
175
|
+
<meta charset="UTF-8">
|
|
176
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
177
|
+
<title>{title}</title>
|
|
178
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
179
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
180
|
+
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
181
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
182
|
+
{_css()}
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<header class="page-header">
|
|
186
|
+
<div class="header-inner">
|
|
187
|
+
<h1>Pipeline Comparison</h1>
|
|
188
|
+
<p class="subtitle">{pipeline_a_name} <span class="vs">vs</span> {pipeline_b_name}</p>
|
|
189
|
+
<div class="metric-selector">
|
|
190
|
+
<label class="metric-selector-label" for="metricSelect">Primary metric:</label>
|
|
191
|
+
<select id="metricSelect" onchange="switchMetric(this.value)"></select>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</header>
|
|
195
|
+
|
|
196
|
+
<main class="container">
|
|
197
|
+
{_build_stats_section(stats, pipeline_a_name, pipeline_b_name)}
|
|
198
|
+
|
|
199
|
+
<div class="path-config">
|
|
200
|
+
<div class="path-config-header">
|
|
201
|
+
<span class="path-config-title">Data Path Configuration</span>
|
|
202
|
+
<button class="path-config-toggle" onclick="togglePathConfig()">Configure</button>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="path-config-body" id="pathConfigBody">
|
|
205
|
+
<p class="hint">If you received this report from someone else, set your local path to the test data folder:</p>
|
|
206
|
+
<input type="text" id="dataBasePath" placeholder="e.g., /Users/yourname/data/financial_tables" onchange="updateBasePath()" />
|
|
207
|
+
<p class="current-path">Current: <span id="currentBasePath">(using original paths)</span></p>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{_build_filter_bar(stats, pipeline_a_name, pipeline_b_name)}
|
|
212
|
+
|
|
213
|
+
<div class="results-list" id="resultsList">
|
|
214
|
+
<div class="results-table-header">
|
|
215
|
+
<div class="col-id">Test ID</div>
|
|
216
|
+
<div class="col-metric">{pipeline_a_name}</div>
|
|
217
|
+
<div class="col-metric">{pipeline_b_name}</div>
|
|
218
|
+
<div class="col-delta">Delta</div>
|
|
219
|
+
<div class="col-category">Category</div>
|
|
220
|
+
</div>
|
|
221
|
+
{"".join(rows_html)}
|
|
222
|
+
</div>
|
|
223
|
+
</main>
|
|
224
|
+
|
|
225
|
+
<script>
|
|
226
|
+
const comparisonData = {json.dumps(matched_results)};
|
|
227
|
+
const pipelineAName = {json.dumps(stats["pipeline_a_name"])};
|
|
228
|
+
const pipelineBName = {json.dumps(stats["pipeline_b_name"])};
|
|
229
|
+
const productType = {json.dumps(product_type)};
|
|
230
|
+
let comparisonMetric = {json.dumps(comparison_metric)};
|
|
231
|
+
let metricDisplayName = {json.dumps(METRIC_DISPLAY_NAMES.get(comparison_metric, comparison_metric))};
|
|
232
|
+
const originalBasePath = {json.dumps(original_base_path)};
|
|
233
|
+
const pdfBaseUrl = {json.dumps(pdf_base_url)};
|
|
234
|
+
const metricTooltips = {json.dumps(tooltip_dict())};
|
|
235
|
+
</script>
|
|
236
|
+
{_javascript()}
|
|
237
|
+
</body>
|
|
238
|
+
</html>"""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _build_result_row(result: dict[str, Any], stats: dict[str, Any], index: int) -> str:
|
|
242
|
+
"""Build a single result row with expandable detail area."""
|
|
243
|
+
test_id = html.escape(result["test_id"])
|
|
244
|
+
metric_a = result["pipeline_a"].get("metric_value")
|
|
245
|
+
metric_b = result["pipeline_b"].get("metric_value")
|
|
246
|
+
category = result["category"]
|
|
247
|
+
|
|
248
|
+
def fmt(val: float | None) -> str:
|
|
249
|
+
if val is None:
|
|
250
|
+
return '<span class="na">N/A</span>'
|
|
251
|
+
pct = val * 100
|
|
252
|
+
css = _metric_color_class(val)
|
|
253
|
+
return f'<span class="metric-val {css}">{pct:.1f}%</span>'
|
|
254
|
+
|
|
255
|
+
def delta_str(a: float | None, b: float | None) -> str:
|
|
256
|
+
if a is None or b is None:
|
|
257
|
+
return '<span class="na">—</span>'
|
|
258
|
+
d = (a - b) * 100
|
|
259
|
+
sign = "+" if d > 0 else ""
|
|
260
|
+
css = "delta-pos" if d > 0 else ("delta-neg" if d < 0 else "delta-zero")
|
|
261
|
+
return f'<span class="{css}">{sign}{d:.1f}pp</span>'
|
|
262
|
+
|
|
263
|
+
category_labels = {
|
|
264
|
+
"a_better": f"{stats['pipeline_a_name']} Better",
|
|
265
|
+
"b_better": f"{stats['pipeline_b_name']} Better",
|
|
266
|
+
"both_bad": "Both Bad",
|
|
267
|
+
"tie": "Tie",
|
|
268
|
+
}
|
|
269
|
+
cat_label = html.escape(category_labels.get(category, category))
|
|
270
|
+
|
|
271
|
+
return f"""
|
|
272
|
+
<div class="result-row" data-category="{category}" data-index="{index}">
|
|
273
|
+
<div class="row-summary" onclick="toggleRow({index})">
|
|
274
|
+
<div class="col-id"><span class="expand-icon" id="icon-{index}">▶</span> {test_id}</div>
|
|
275
|
+
<div class="col-metric">{fmt(metric_a)}</div>
|
|
276
|
+
<div class="col-metric">{fmt(metric_b)}</div>
|
|
277
|
+
<div class="col-delta">{delta_str(metric_a, metric_b)}</div>
|
|
278
|
+
<div class="col-category"><span class="badge badge-{category.replace("_", "-")}">{cat_label}</span></div>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="row-detail" id="detail-{index}"></div>
|
|
281
|
+
</div>"""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _build_stats_section(stats: dict[str, Any], a_name: str, b_name: str) -> str:
|
|
285
|
+
return f"""
|
|
286
|
+
<section class="stats-grid">
|
|
287
|
+
<div class="stat-card" data-filter="all" onclick="filterFromCard(this)">
|
|
288
|
+
<div class="stat-value">{stats["total_matched"]}</div>
|
|
289
|
+
<div class="stat-label">Total</div>
|
|
290
|
+
</div>
|
|
291
|
+
<div class="stat-card stat-a-better" data-filter="a_better" onclick="filterFromCard(this)">
|
|
292
|
+
<div class="stat-value">{stats["a_better"]}</div>
|
|
293
|
+
<div class="stat-label">{a_name} Better</div>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="stat-card stat-b-better" data-filter="b_better" onclick="filterFromCard(this)">
|
|
296
|
+
<div class="stat-value">{stats["b_better"]}</div>
|
|
297
|
+
<div class="stat-label">{b_name} Better</div>
|
|
298
|
+
</div>
|
|
299
|
+
<div class="stat-card stat-tie" data-filter="tie" onclick="filterFromCard(this)">
|
|
300
|
+
<div class="stat-value">{stats["tie"]}</div>
|
|
301
|
+
<div class="stat-label">Tie</div>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="stat-card stat-bad" data-filter="both_bad" onclick="filterFromCard(this)">
|
|
304
|
+
<div class="stat-value">{stats["both_bad"]}</div>
|
|
305
|
+
<div class="stat-label">Both Bad</div>
|
|
306
|
+
</div>
|
|
307
|
+
</section>"""
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _build_filter_bar(stats: dict[str, Any], a_name: str, b_name: str) -> str:
|
|
311
|
+
return f"""
|
|
312
|
+
<div class="filter-bar">
|
|
313
|
+
<button class="filter-btn active" data-filter="all">All ({stats["total_matched"]})</button>
|
|
314
|
+
<button class="filter-btn" data-filter="a_better">{a_name} Better ({stats["a_better"]})</button>
|
|
315
|
+
<button class="filter-btn" data-filter="b_better">{b_name} Better ({stats["b_better"]})</button>
|
|
316
|
+
<button class="filter-btn" data-filter="tie">Tie ({stats["tie"]})</button>
|
|
317
|
+
<button class="filter-btn" data-filter="both_bad">Both Bad ({stats["both_bad"]})</button>
|
|
318
|
+
</div>"""
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _metric_color_class(val: float | None) -> str:
|
|
322
|
+
if val is None:
|
|
323
|
+
return "metric-na"
|
|
324
|
+
if val >= 0.9:
|
|
325
|
+
return "metric-high"
|
|
326
|
+
if val >= 0.7:
|
|
327
|
+
return "metric-mid"
|
|
328
|
+
if val >= 0.5:
|
|
329
|
+
return "metric-low"
|
|
330
|
+
return "metric-bad"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# CSS
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _css() -> str:
|
|
339
|
+
return (
|
|
340
|
+
"""<style>
|
|
341
|
+
:root {
|
|
342
|
+
--bg: #f8f7f4;
|
|
343
|
+
--fg: #1c1917;
|
|
344
|
+
--card: #ffffff;
|
|
345
|
+
--border: #e7e5e4;
|
|
346
|
+
--border-light: #f0efed;
|
|
347
|
+
--muted: #78716c;
|
|
348
|
+
--muted-light: #a8a29e;
|
|
349
|
+
--cream: #faf9f6;
|
|
350
|
+
|
|
351
|
+
--emerald: #059669;
|
|
352
|
+
--amber: #d97706;
|
|
353
|
+
--yellow: #ca8a04;
|
|
354
|
+
--red: #dc2626;
|
|
355
|
+
|
|
356
|
+
--font-heading: 'Newsreader', Georgia, serif;
|
|
357
|
+
--font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
358
|
+
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
|
359
|
+
}
|
|
360
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
361
|
+
body {
|
|
362
|
+
font-family: var(--font-body);
|
|
363
|
+
background: var(--bg);
|
|
364
|
+
color: var(--fg);
|
|
365
|
+
line-height: 1.6;
|
|
366
|
+
-webkit-font-smoothing: antialiased;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* Header */
|
|
370
|
+
.page-header {
|
|
371
|
+
background: var(--cream);
|
|
372
|
+
border-bottom: 1px solid var(--border);
|
|
373
|
+
padding: 2.5rem 2rem 2rem;
|
|
374
|
+
}
|
|
375
|
+
.header-inner {
|
|
376
|
+
max-width: 1400px;
|
|
377
|
+
margin: 0 auto;
|
|
378
|
+
}
|
|
379
|
+
.page-header h1 {
|
|
380
|
+
font-family: var(--font-heading);
|
|
381
|
+
font-size: 2.25rem;
|
|
382
|
+
font-weight: 600;
|
|
383
|
+
letter-spacing: -0.02em;
|
|
384
|
+
color: var(--fg);
|
|
385
|
+
}
|
|
386
|
+
.page-header .subtitle {
|
|
387
|
+
font-size: 1.1rem;
|
|
388
|
+
color: var(--muted);
|
|
389
|
+
margin-top: 0.25rem;
|
|
390
|
+
}
|
|
391
|
+
.page-header .vs {
|
|
392
|
+
color: var(--muted-light);
|
|
393
|
+
font-style: italic;
|
|
394
|
+
font-family: var(--font-heading);
|
|
395
|
+
}
|
|
396
|
+
.page-header .metric-label {
|
|
397
|
+
font-size: 0.85rem;
|
|
398
|
+
color: var(--muted-light);
|
|
399
|
+
margin-top: 0.35rem;
|
|
400
|
+
font-family: var(--font-mono);
|
|
401
|
+
}
|
|
402
|
+
.metric-selector {
|
|
403
|
+
display: flex;
|
|
404
|
+
align-items: center;
|
|
405
|
+
gap: 0.5rem;
|
|
406
|
+
margin-top: 0.35rem;
|
|
407
|
+
}
|
|
408
|
+
.metric-selector-label {
|
|
409
|
+
font-size: 0.85rem;
|
|
410
|
+
color: var(--muted-light);
|
|
411
|
+
font-family: var(--font-mono);
|
|
412
|
+
}
|
|
413
|
+
.metric-selector select {
|
|
414
|
+
font-family: var(--font-mono);
|
|
415
|
+
font-size: 0.85rem;
|
|
416
|
+
padding: 0.25rem 0.5rem;
|
|
417
|
+
border: 1px solid var(--border);
|
|
418
|
+
border-radius: 6px;
|
|
419
|
+
background: var(--card);
|
|
420
|
+
color: var(--fg);
|
|
421
|
+
cursor: pointer;
|
|
422
|
+
}
|
|
423
|
+
.metric-selector select:hover {
|
|
424
|
+
border-color: var(--muted);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* Container */
|
|
428
|
+
.container {
|
|
429
|
+
max-width: 1400px;
|
|
430
|
+
margin: 0 auto;
|
|
431
|
+
padding: 1.5rem 2rem 3rem;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* Stats */
|
|
435
|
+
.stats-grid {
|
|
436
|
+
display: grid;
|
|
437
|
+
grid-template-columns: repeat(5, 1fr);
|
|
438
|
+
gap: 1rem;
|
|
439
|
+
margin-bottom: 1.5rem;
|
|
440
|
+
}
|
|
441
|
+
.stat-card {
|
|
442
|
+
background: var(--card);
|
|
443
|
+
border: 1px solid var(--border);
|
|
444
|
+
border-radius: 10px;
|
|
445
|
+
padding: 1.25rem 1rem;
|
|
446
|
+
text-align: center;
|
|
447
|
+
}
|
|
448
|
+
.stat-value {
|
|
449
|
+
font-family: var(--font-mono);
|
|
450
|
+
font-size: 1.75rem;
|
|
451
|
+
font-weight: 600;
|
|
452
|
+
color: var(--fg);
|
|
453
|
+
}
|
|
454
|
+
.stat-label {
|
|
455
|
+
font-size: 0.8rem;
|
|
456
|
+
color: var(--muted);
|
|
457
|
+
margin-top: 0.25rem;
|
|
458
|
+
}
|
|
459
|
+
.stat-card { cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; }
|
|
460
|
+
.stat-card:hover { border-color: var(--muted-light); }
|
|
461
|
+
.stat-card.active { border-color: var(--fg); box-shadow: 0 0 0 1px var(--fg); }
|
|
462
|
+
.stat-a-better .stat-value { color: var(--emerald); }
|
|
463
|
+
.stat-b-better .stat-value { color: #2563eb; }
|
|
464
|
+
.stat-tie .stat-value { color: var(--muted); }
|
|
465
|
+
.stat-bad .stat-value { color: var(--red); }
|
|
466
|
+
|
|
467
|
+
/* Path config */
|
|
468
|
+
.path-config {
|
|
469
|
+
background: var(--cream);
|
|
470
|
+
border: 1px solid var(--border);
|
|
471
|
+
border-radius: 10px;
|
|
472
|
+
padding: 0.75rem 1rem;
|
|
473
|
+
margin-bottom: 1.5rem;
|
|
474
|
+
font-size: 0.85rem;
|
|
475
|
+
}
|
|
476
|
+
.path-config-header {
|
|
477
|
+
display: flex;
|
|
478
|
+
justify-content: space-between;
|
|
479
|
+
align-items: center;
|
|
480
|
+
}
|
|
481
|
+
.path-config-title { color: var(--muted); font-weight: 500; }
|
|
482
|
+
.path-config-toggle {
|
|
483
|
+
background: none;
|
|
484
|
+
border: none;
|
|
485
|
+
color: var(--muted);
|
|
486
|
+
cursor: pointer;
|
|
487
|
+
font-size: 0.8rem;
|
|
488
|
+
text-decoration: underline;
|
|
489
|
+
font-family: var(--font-body);
|
|
490
|
+
}
|
|
491
|
+
.path-config-body { display: none; margin-top: 0.75rem; }
|
|
492
|
+
.path-config-body.expanded { display: block; }
|
|
493
|
+
.path-config input {
|
|
494
|
+
width: 100%;
|
|
495
|
+
padding: 0.5rem;
|
|
496
|
+
border: 1px solid var(--border);
|
|
497
|
+
border-radius: 6px;
|
|
498
|
+
font-family: var(--font-mono);
|
|
499
|
+
font-size: 0.8rem;
|
|
500
|
+
margin-top: 0.5rem;
|
|
501
|
+
background: var(--card);
|
|
502
|
+
}
|
|
503
|
+
.path-config .hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
|
|
504
|
+
.path-config .current-path { color: var(--muted-light); font-size: 0.75rem; font-family: var(--font-mono); margin-top: 0.25rem; }
|
|
505
|
+
|
|
506
|
+
/* Filters */
|
|
507
|
+
.filter-bar {
|
|
508
|
+
display: flex;
|
|
509
|
+
gap: 0.5rem;
|
|
510
|
+
margin-bottom: 1.25rem;
|
|
511
|
+
flex-wrap: wrap;
|
|
512
|
+
}
|
|
513
|
+
.filter-btn {
|
|
514
|
+
padding: 0.5rem 1rem;
|
|
515
|
+
border: 1px solid var(--border);
|
|
516
|
+
border-radius: 8px;
|
|
517
|
+
cursor: pointer;
|
|
518
|
+
font-size: 0.85rem;
|
|
519
|
+
font-weight: 500;
|
|
520
|
+
font-family: var(--font-body);
|
|
521
|
+
background: var(--card);
|
|
522
|
+
color: var(--muted);
|
|
523
|
+
transition: all 0.15s;
|
|
524
|
+
}
|
|
525
|
+
.filter-btn:hover {
|
|
526
|
+
border-color: var(--muted);
|
|
527
|
+
color: var(--fg);
|
|
528
|
+
}
|
|
529
|
+
.filter-btn.active {
|
|
530
|
+
background: var(--fg);
|
|
531
|
+
color: var(--card);
|
|
532
|
+
border-color: var(--fg);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* Results table */
|
|
536
|
+
.results-list {
|
|
537
|
+
background: var(--card);
|
|
538
|
+
border: 1px solid var(--border);
|
|
539
|
+
border-radius: 10px;
|
|
540
|
+
overflow: hidden;
|
|
541
|
+
}
|
|
542
|
+
.results-table-header {
|
|
543
|
+
display: grid;
|
|
544
|
+
grid-template-columns: 2fr 0.8fr 0.8fr 0.8fr 1.2fr;
|
|
545
|
+
gap: 0.5rem;
|
|
546
|
+
padding: 0.75rem 1rem;
|
|
547
|
+
background: var(--cream);
|
|
548
|
+
border-bottom: 1px solid var(--border);
|
|
549
|
+
font-size: 0.75rem;
|
|
550
|
+
font-weight: 600;
|
|
551
|
+
color: var(--muted);
|
|
552
|
+
text-transform: uppercase;
|
|
553
|
+
letter-spacing: 0.04em;
|
|
554
|
+
}
|
|
555
|
+
.col-metric, .col-delta, .col-category { text-align: center; }
|
|
556
|
+
|
|
557
|
+
.result-row {
|
|
558
|
+
border-bottom: 1px solid var(--border-light);
|
|
559
|
+
}
|
|
560
|
+
.result-row:last-child { border-bottom: none; }
|
|
561
|
+
.row-summary {
|
|
562
|
+
display: grid;
|
|
563
|
+
grid-template-columns: 2fr 0.8fr 0.8fr 0.8fr 1.2fr;
|
|
564
|
+
gap: 0.5rem;
|
|
565
|
+
padding: 0.75rem 1rem;
|
|
566
|
+
cursor: pointer;
|
|
567
|
+
transition: background 0.1s;
|
|
568
|
+
align-items: center;
|
|
569
|
+
}
|
|
570
|
+
.row-summary:hover { background: var(--cream); }
|
|
571
|
+
.col-id {
|
|
572
|
+
font-weight: 500;
|
|
573
|
+
font-size: 0.9rem;
|
|
574
|
+
display: flex;
|
|
575
|
+
align-items: center;
|
|
576
|
+
gap: 0.5rem;
|
|
577
|
+
overflow: hidden;
|
|
578
|
+
text-overflow: ellipsis;
|
|
579
|
+
}
|
|
580
|
+
.expand-icon {
|
|
581
|
+
font-size: 0.6rem;
|
|
582
|
+
color: var(--muted-light);
|
|
583
|
+
transition: transform 0.15s;
|
|
584
|
+
display: inline-block;
|
|
585
|
+
flex-shrink: 0;
|
|
586
|
+
}
|
|
587
|
+
.expand-icon.expanded { transform: rotate(90deg); }
|
|
588
|
+
|
|
589
|
+
/* Metric values */
|
|
590
|
+
.metric-val {
|
|
591
|
+
font-family: var(--font-mono);
|
|
592
|
+
font-size: 0.85rem;
|
|
593
|
+
font-weight: 500;
|
|
594
|
+
}
|
|
595
|
+
.metric-high { color: var(--emerald); }
|
|
596
|
+
.metric-mid { color: var(--amber); }
|
|
597
|
+
.metric-low { color: var(--yellow); }
|
|
598
|
+
.metric-bad { color: var(--red); }
|
|
599
|
+
.metric-na { color: var(--muted-light); }
|
|
600
|
+
.na { color: var(--muted-light); font-size: 0.8rem; }
|
|
601
|
+
|
|
602
|
+
.delta-pos { color: var(--emerald); font-family: var(--font-mono); font-size: 0.8rem; font-weight: 500; }
|
|
603
|
+
.delta-neg { color: var(--red); font-family: var(--font-mono); font-size: 0.8rem; font-weight: 500; }
|
|
604
|
+
.delta-zero { color: var(--muted); font-family: var(--font-mono); font-size: 0.8rem; }
|
|
605
|
+
|
|
606
|
+
/* Badge */
|
|
607
|
+
.badge {
|
|
608
|
+
display: inline-block;
|
|
609
|
+
padding: 0.2rem 0.6rem;
|
|
610
|
+
border-radius: 6px;
|
|
611
|
+
font-size: 0.75rem;
|
|
612
|
+
font-weight: 500;
|
|
613
|
+
}
|
|
614
|
+
.badge-a-better { background: #ecfdf5; color: var(--emerald); }
|
|
615
|
+
.badge-b-better { background: #eff6ff; color: #2563eb; }
|
|
616
|
+
.badge-tie { background: #f5f5f4; color: var(--muted); }
|
|
617
|
+
.badge-both-bad { background: #fef2f2; color: var(--red); }
|
|
618
|
+
|
|
619
|
+
/* Detail pane */
|
|
620
|
+
.row-detail {
|
|
621
|
+
display: none;
|
|
622
|
+
padding: 0 1rem 1rem;
|
|
623
|
+
background: var(--cream);
|
|
624
|
+
border-top: 1px solid var(--border-light);
|
|
625
|
+
}
|
|
626
|
+
.row-detail.open { display: block; }
|
|
627
|
+
|
|
628
|
+
/* Two-column detail layout */
|
|
629
|
+
.detail-two-col {
|
|
630
|
+
display: grid;
|
|
631
|
+
grid-template-columns: 35% 1fr;
|
|
632
|
+
gap: 1rem;
|
|
633
|
+
margin-top: 0.75rem;
|
|
634
|
+
}
|
|
635
|
+
.detail-left {
|
|
636
|
+
position: sticky;
|
|
637
|
+
top: 0;
|
|
638
|
+
align-self: start;
|
|
639
|
+
}
|
|
640
|
+
.detail-right {
|
|
641
|
+
min-width: 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/* Metric pills */
|
|
645
|
+
.metric-pills {
|
|
646
|
+
display: flex;
|
|
647
|
+
flex-wrap: wrap;
|
|
648
|
+
gap: 0.4rem;
|
|
649
|
+
margin-top: 0.75rem;
|
|
650
|
+
}
|
|
651
|
+
.pill {
|
|
652
|
+
display: flex;
|
|
653
|
+
flex-direction: column;
|
|
654
|
+
padding: 0.35rem 0.6rem;
|
|
655
|
+
border: 1px solid var(--border);
|
|
656
|
+
border-radius: 8px;
|
|
657
|
+
background: var(--card);
|
|
658
|
+
font-size: 0.75rem;
|
|
659
|
+
min-width: 0;
|
|
660
|
+
}
|
|
661
|
+
.pill-primary {
|
|
662
|
+
border-color: var(--fg);
|
|
663
|
+
background: var(--fg);
|
|
664
|
+
}
|
|
665
|
+
.pill-primary .pill-label {
|
|
666
|
+
color: var(--muted-light);
|
|
667
|
+
}
|
|
668
|
+
.pill-primary .pill-values {
|
|
669
|
+
color: #fff;
|
|
670
|
+
}
|
|
671
|
+
.pill-label {
|
|
672
|
+
font-size: 0.65rem;
|
|
673
|
+
color: var(--muted);
|
|
674
|
+
font-weight: 500;
|
|
675
|
+
text-transform: uppercase;
|
|
676
|
+
letter-spacing: 0.03em;
|
|
677
|
+
margin-bottom: 1px;
|
|
678
|
+
white-space: nowrap;
|
|
679
|
+
overflow: hidden;
|
|
680
|
+
text-overflow: ellipsis;
|
|
681
|
+
}
|
|
682
|
+
.pill-values {
|
|
683
|
+
font-family: var(--font-mono);
|
|
684
|
+
font-size: 0.75rem;
|
|
685
|
+
font-weight: 500;
|
|
686
|
+
white-space: nowrap;
|
|
687
|
+
}
|
|
688
|
+
.pill-delta {
|
|
689
|
+
font-size: 0.7rem;
|
|
690
|
+
margin-left: 4px;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/* Collapsible sections */
|
|
694
|
+
.collapsible-section {
|
|
695
|
+
border: 1px solid var(--border);
|
|
696
|
+
border-radius: 8px;
|
|
697
|
+
margin-top: 0.75rem;
|
|
698
|
+
background: var(--card);
|
|
699
|
+
overflow: hidden;
|
|
700
|
+
}
|
|
701
|
+
.collapsible-section > summary {
|
|
702
|
+
padding: 0.6rem 0.75rem;
|
|
703
|
+
cursor: pointer;
|
|
704
|
+
font-size: 0.85rem;
|
|
705
|
+
font-weight: 600;
|
|
706
|
+
color: var(--muted);
|
|
707
|
+
list-style: none;
|
|
708
|
+
display: flex;
|
|
709
|
+
align-items: center;
|
|
710
|
+
gap: 0.5rem;
|
|
711
|
+
user-select: none;
|
|
712
|
+
}
|
|
713
|
+
.collapsible-section > summary::-webkit-details-marker { display: none; }
|
|
714
|
+
.collapsible-section > summary::before {
|
|
715
|
+
content: '\\25B6';
|
|
716
|
+
font-size: 0.6rem;
|
|
717
|
+
transition: transform 0.15s;
|
|
718
|
+
color: var(--muted-light);
|
|
719
|
+
}
|
|
720
|
+
.collapsible-section[open] > summary::before {
|
|
721
|
+
transform: rotate(90deg);
|
|
722
|
+
}
|
|
723
|
+
.collapsible-section > summary:hover {
|
|
724
|
+
color: var(--fg);
|
|
725
|
+
}
|
|
726
|
+
.collapsible-section > :not(summary) {
|
|
727
|
+
padding: 0 0.75rem 0.75rem;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
@media (max-width: 1100px) {
|
|
731
|
+
.detail-two-col {
|
|
732
|
+
grid-template-columns: 1fr;
|
|
733
|
+
}
|
|
734
|
+
.detail-left {
|
|
735
|
+
position: static;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/* Tabs */
|
|
740
|
+
.tab-bar {
|
|
741
|
+
display: flex;
|
|
742
|
+
gap: 0;
|
|
743
|
+
border-bottom: 1px solid var(--border);
|
|
744
|
+
margin-bottom: 1rem;
|
|
745
|
+
margin-top: 0.75rem;
|
|
746
|
+
}
|
|
747
|
+
.tab-btn {
|
|
748
|
+
padding: 0.6rem 1.25rem;
|
|
749
|
+
border: none;
|
|
750
|
+
background: none;
|
|
751
|
+
cursor: pointer;
|
|
752
|
+
font-family: var(--font-body);
|
|
753
|
+
font-size: 0.85rem;
|
|
754
|
+
font-weight: 500;
|
|
755
|
+
color: var(--muted);
|
|
756
|
+
border-bottom: 2px solid transparent;
|
|
757
|
+
transition: all 0.15s;
|
|
758
|
+
}
|
|
759
|
+
.tab-btn:hover { color: var(--fg); }
|
|
760
|
+
.tab-btn.active {
|
|
761
|
+
color: var(--fg);
|
|
762
|
+
border-bottom-color: var(--fg);
|
|
763
|
+
}
|
|
764
|
+
.tab-panel { display: none; }
|
|
765
|
+
.tab-panel.active { display: block; }
|
|
766
|
+
|
|
767
|
+
/* Metrics comparison table */
|
|
768
|
+
.metrics-table {
|
|
769
|
+
width: 100%;
|
|
770
|
+
border-collapse: collapse;
|
|
771
|
+
font-size: 0.85rem;
|
|
772
|
+
margin-bottom: 1rem;
|
|
773
|
+
}
|
|
774
|
+
.metrics-table th {
|
|
775
|
+
background: var(--cream);
|
|
776
|
+
padding: 0.6rem 0.75rem;
|
|
777
|
+
text-align: left;
|
|
778
|
+
font-weight: 600;
|
|
779
|
+
font-size: 0.75rem;
|
|
780
|
+
color: var(--muted);
|
|
781
|
+
text-transform: uppercase;
|
|
782
|
+
letter-spacing: 0.04em;
|
|
783
|
+
border-bottom: 1px solid var(--border);
|
|
784
|
+
}
|
|
785
|
+
.metrics-table td {
|
|
786
|
+
padding: 0.6rem 0.75rem;
|
|
787
|
+
border-bottom: 1px solid var(--border-light);
|
|
788
|
+
}
|
|
789
|
+
.metrics-table tr:last-child td { border-bottom: none; }
|
|
790
|
+
.metrics-table .metric-name { font-weight: 500; display: flex; align-items: center; overflow: visible; }
|
|
791
|
+
.metrics-table .val-cell {
|
|
792
|
+
font-family: var(--font-mono);
|
|
793
|
+
font-size: 0.85rem;
|
|
794
|
+
text-align: center;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/* Rule diff table */
|
|
798
|
+
.rule-table {
|
|
799
|
+
width: 100%;
|
|
800
|
+
border-collapse: collapse;
|
|
801
|
+
font-size: 0.8rem;
|
|
802
|
+
margin-bottom: 1rem;
|
|
803
|
+
}
|
|
804
|
+
.rule-table th {
|
|
805
|
+
background: var(--cream);
|
|
806
|
+
padding: 0.5rem 0.6rem;
|
|
807
|
+
text-align: left;
|
|
808
|
+
font-weight: 600;
|
|
809
|
+
font-size: 0.7rem;
|
|
810
|
+
color: var(--muted);
|
|
811
|
+
text-transform: uppercase;
|
|
812
|
+
letter-spacing: 0.04em;
|
|
813
|
+
border-bottom: 1px solid var(--border);
|
|
814
|
+
}
|
|
815
|
+
.rule-table td {
|
|
816
|
+
padding: 0.5rem 0.6rem;
|
|
817
|
+
border-bottom: 1px solid var(--border-light);
|
|
818
|
+
vertical-align: top;
|
|
819
|
+
}
|
|
820
|
+
.rule-table tr:last-child td { border-bottom: none; }
|
|
821
|
+
.rule-pass { color: var(--emerald); font-weight: 600; }
|
|
822
|
+
.rule-fail { color: var(--red); font-weight: 600; }
|
|
823
|
+
.rule-diff-row { background: #fffbeb; }
|
|
824
|
+
.rule-same-pass { background: #f0fdf4; }
|
|
825
|
+
.rule-same-fail { background: #fef2f2; }
|
|
826
|
+
.rule-explanation {
|
|
827
|
+
font-size: 0.75rem;
|
|
828
|
+
color: var(--muted);
|
|
829
|
+
margin-top: 0.25rem;
|
|
830
|
+
cursor: pointer;
|
|
831
|
+
max-height: 0;
|
|
832
|
+
overflow: hidden;
|
|
833
|
+
transition: max-height 0.2s;
|
|
834
|
+
}
|
|
835
|
+
.rule-explanation.open { max-height: 200px; overflow-y: auto; }
|
|
836
|
+
.rule-expander {
|
|
837
|
+
font-size: 0.7rem;
|
|
838
|
+
color: var(--muted-light);
|
|
839
|
+
cursor: pointer;
|
|
840
|
+
user-select: none;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/* Output comparison */
|
|
844
|
+
.output-grid {
|
|
845
|
+
display: grid;
|
|
846
|
+
grid-template-columns: 1fr 1fr;
|
|
847
|
+
gap: 1rem;
|
|
848
|
+
}
|
|
849
|
+
.output-panel {
|
|
850
|
+
border: 1px solid var(--border);
|
|
851
|
+
border-radius: 8px;
|
|
852
|
+
overflow: hidden;
|
|
853
|
+
background: var(--card);
|
|
854
|
+
}
|
|
855
|
+
.output-panel-header {
|
|
856
|
+
padding: 0.5rem 0.75rem;
|
|
857
|
+
background: var(--cream);
|
|
858
|
+
border-bottom: 1px solid var(--border);
|
|
859
|
+
font-size: 0.8rem;
|
|
860
|
+
font-weight: 600;
|
|
861
|
+
color: var(--muted);
|
|
862
|
+
display: flex;
|
|
863
|
+
justify-content: space-between;
|
|
864
|
+
align-items: center;
|
|
865
|
+
}
|
|
866
|
+
.output-panel-body {
|
|
867
|
+
padding: 0.75rem;
|
|
868
|
+
max-height: 500px;
|
|
869
|
+
overflow-y: auto;
|
|
870
|
+
font-size: 0.85rem;
|
|
871
|
+
}
|
|
872
|
+
.output-panel-body.json-view {
|
|
873
|
+
font-family: var(--font-mono);
|
|
874
|
+
font-size: 0.8rem;
|
|
875
|
+
white-space: pre-wrap;
|
|
876
|
+
word-break: break-word;
|
|
877
|
+
}
|
|
878
|
+
.output-panel-body.md-view h1 { font-size: 1.3rem; margin: 0.75rem 0 0.4rem; }
|
|
879
|
+
.output-panel-body.md-view h2 { font-size: 1.1rem; margin: 0.6rem 0 0.3rem; }
|
|
880
|
+
.output-panel-body.md-view h3 { font-size: 1rem; margin: 0.5rem 0 0.25rem; }
|
|
881
|
+
.output-panel-body.md-view p { margin-bottom: 0.4rem; }
|
|
882
|
+
.output-panel-body.md-view table {
|
|
883
|
+
border-collapse: collapse;
|
|
884
|
+
width: 100%;
|
|
885
|
+
font-size: 0.8rem;
|
|
886
|
+
margin: 0.5rem 0;
|
|
887
|
+
}
|
|
888
|
+
.output-panel-body.md-view th, .output-panel-body.md-view td {
|
|
889
|
+
border: 1px solid var(--border);
|
|
890
|
+
padding: 4px 8px;
|
|
891
|
+
text-align: left;
|
|
892
|
+
}
|
|
893
|
+
.output-panel-body.md-view th { background: var(--cream); font-weight: 600; }
|
|
894
|
+
.output-panel-body.md-view code {
|
|
895
|
+
background: #f5f5f4;
|
|
896
|
+
padding: 1px 4px;
|
|
897
|
+
border-radius: 3px;
|
|
898
|
+
font-family: var(--font-mono);
|
|
899
|
+
font-size: 0.85em;
|
|
900
|
+
}
|
|
901
|
+
.output-panel-body.md-view pre {
|
|
902
|
+
background: #f5f5f4;
|
|
903
|
+
padding: 0.75rem;
|
|
904
|
+
border-radius: 6px;
|
|
905
|
+
overflow-x: auto;
|
|
906
|
+
}
|
|
907
|
+
.output-panel-body.md-view pre code { background: none; padding: 0; }
|
|
908
|
+
|
|
909
|
+
.view-toggle {
|
|
910
|
+
display: flex;
|
|
911
|
+
gap: 2px;
|
|
912
|
+
}
|
|
913
|
+
.view-toggle button {
|
|
914
|
+
padding: 2px 8px;
|
|
915
|
+
border: 1px solid var(--border);
|
|
916
|
+
background: var(--card);
|
|
917
|
+
cursor: pointer;
|
|
918
|
+
font-family: var(--font-body);
|
|
919
|
+
font-size: 0.7rem;
|
|
920
|
+
color: var(--muted);
|
|
921
|
+
}
|
|
922
|
+
.view-toggle button:first-child { border-radius: 4px 0 0 4px; }
|
|
923
|
+
.view-toggle button:last-child { border-radius: 0 4px 4px 0; }
|
|
924
|
+
.view-toggle button.active { background: var(--fg); color: var(--card); border-color: var(--fg); }
|
|
925
|
+
|
|
926
|
+
/* Diff view */
|
|
927
|
+
.diff-container {
|
|
928
|
+
border: 1px solid var(--border);
|
|
929
|
+
border-radius: 8px;
|
|
930
|
+
overflow: hidden;
|
|
931
|
+
background: var(--card);
|
|
932
|
+
margin-top: 1rem;
|
|
933
|
+
}
|
|
934
|
+
.diff-header {
|
|
935
|
+
padding: 0.5rem 0.75rem;
|
|
936
|
+
background: var(--cream);
|
|
937
|
+
border-bottom: 1px solid var(--border);
|
|
938
|
+
font-size: 0.8rem;
|
|
939
|
+
font-weight: 600;
|
|
940
|
+
color: var(--muted);
|
|
941
|
+
}
|
|
942
|
+
.diff-body {
|
|
943
|
+
max-height: 500px;
|
|
944
|
+
overflow-y: auto;
|
|
945
|
+
font-family: var(--font-mono);
|
|
946
|
+
font-size: 0.8rem;
|
|
947
|
+
line-height: 1.5;
|
|
948
|
+
}
|
|
949
|
+
.diff-line { padding: 1px 12px; white-space: pre-wrap; word-break: break-all; }
|
|
950
|
+
.diff-add { background: #ecfdf5; color: #065f46; }
|
|
951
|
+
.diff-del { background: #fef2f2; color: #991b1b; }
|
|
952
|
+
.diff-ctx { color: var(--muted); }
|
|
953
|
+
.diff-hunk { background: #f5f3ff; color: #6d28d9; font-weight: 500; padding: 4px 12px; }
|
|
954
|
+
|
|
955
|
+
/* Input preview */
|
|
956
|
+
.input-preview {
|
|
957
|
+
margin-top: 0.5rem;
|
|
958
|
+
}
|
|
959
|
+
.input-preview img {
|
|
960
|
+
max-width: 100%;
|
|
961
|
+
max-height: 600px;
|
|
962
|
+
border: 1px solid var(--border);
|
|
963
|
+
border-radius: 8px;
|
|
964
|
+
}
|
|
965
|
+
.input-preview .file-path {
|
|
966
|
+
font-family: var(--font-mono);
|
|
967
|
+
font-size: 0.75rem;
|
|
968
|
+
color: var(--muted);
|
|
969
|
+
margin-bottom: 0.5rem;
|
|
970
|
+
word-break: break-all;
|
|
971
|
+
}
|
|
972
|
+
.input-preview iframe {
|
|
973
|
+
width: 100%;
|
|
974
|
+
height: 600px;
|
|
975
|
+
border: 1px solid var(--border);
|
|
976
|
+
border-radius: 8px;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/* Layout detection styles */
|
|
980
|
+
.layoutdet-grid {
|
|
981
|
+
display: grid;
|
|
982
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
983
|
+
gap: 1rem;
|
|
984
|
+
}
|
|
985
|
+
.layoutdet-panel {
|
|
986
|
+
border: 1px solid var(--border);
|
|
987
|
+
border-radius: 8px;
|
|
988
|
+
overflow: hidden;
|
|
989
|
+
background: var(--card);
|
|
990
|
+
}
|
|
991
|
+
.layoutdet-panel-header {
|
|
992
|
+
padding: 0.5rem 0.75rem;
|
|
993
|
+
background: var(--cream);
|
|
994
|
+
border-bottom: 1px solid var(--border);
|
|
995
|
+
font-size: 0.8rem;
|
|
996
|
+
font-weight: 600;
|
|
997
|
+
color: var(--muted);
|
|
998
|
+
display: flex;
|
|
999
|
+
justify-content: space-between;
|
|
1000
|
+
align-items: center;
|
|
1001
|
+
}
|
|
1002
|
+
.layoutdet-panel-body {
|
|
1003
|
+
position: relative;
|
|
1004
|
+
display: inline-block;
|
|
1005
|
+
width: 100%;
|
|
1006
|
+
}
|
|
1007
|
+
.layoutdet-panel-body img {
|
|
1008
|
+
max-width: 100%;
|
|
1009
|
+
height: auto;
|
|
1010
|
+
display: block;
|
|
1011
|
+
}
|
|
1012
|
+
.layoutdet-canvas {
|
|
1013
|
+
position: absolute;
|
|
1014
|
+
top: 0;
|
|
1015
|
+
left: 0;
|
|
1016
|
+
pointer-events: none;
|
|
1017
|
+
}
|
|
1018
|
+
.bbox-btn {
|
|
1019
|
+
padding: 2px 8px;
|
|
1020
|
+
border: 1px solid var(--border);
|
|
1021
|
+
border-radius: 4px;
|
|
1022
|
+
background: var(--card);
|
|
1023
|
+
cursor: pointer;
|
|
1024
|
+
font-family: var(--font-body);
|
|
1025
|
+
font-size: 0.7rem;
|
|
1026
|
+
color: var(--muted);
|
|
1027
|
+
}
|
|
1028
|
+
.bbox-btn.active {
|
|
1029
|
+
background: var(--fg);
|
|
1030
|
+
color: var(--card);
|
|
1031
|
+
border-color: var(--fg);
|
|
1032
|
+
}
|
|
1033
|
+
.comparison-legend {
|
|
1034
|
+
display: flex;
|
|
1035
|
+
gap: 16px;
|
|
1036
|
+
margin-top: 0.75rem;
|
|
1037
|
+
padding: 0.5rem 0.75rem;
|
|
1038
|
+
background: var(--cream);
|
|
1039
|
+
border: 1px solid var(--border);
|
|
1040
|
+
border-radius: 8px;
|
|
1041
|
+
font-size: 0.75rem;
|
|
1042
|
+
color: var(--muted);
|
|
1043
|
+
flex-wrap: wrap;
|
|
1044
|
+
}
|
|
1045
|
+
.legend-item { display: flex; align-items: center; gap: 4px; }
|
|
1046
|
+
.legend-swatch {
|
|
1047
|
+
width: 16px;
|
|
1048
|
+
height: 10px;
|
|
1049
|
+
border-radius: 2px;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/* Responsive */
|
|
1053
|
+
@media (max-width: 900px) {
|
|
1054
|
+
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
|
1055
|
+
.output-grid { grid-template-columns: 1fr; }
|
|
1056
|
+
.layoutdet-grid { grid-template-columns: 1fr; }
|
|
1057
|
+
.results-table-header, .row-summary {
|
|
1058
|
+
grid-template-columns: 2fr 0.6fr 0.6fr 0.6fr 1fr;
|
|
1059
|
+
font-size: 0.8rem;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
"""
|
|
1063
|
+
+ TOOLTIP_CSS
|
|
1064
|
+
+ """</style>"""
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
# ---------------------------------------------------------------------------
|
|
1069
|
+
# JavaScript
|
|
1070
|
+
# ---------------------------------------------------------------------------
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _javascript() -> str:
|
|
1074
|
+
return (
|
|
1075
|
+
"""<script>
|
|
1076
|
+
// ---- Path configuration ----
|
|
1077
|
+
let customBasePath = localStorage.getItem('comparisonDataBasePath') || '';
|
|
1078
|
+
|
|
1079
|
+
function togglePathConfig() {
|
|
1080
|
+
const body = document.getElementById('pathConfigBody');
|
|
1081
|
+
body.classList.toggle('expanded');
|
|
1082
|
+
}
|
|
1083
|
+
function updateBasePath() {
|
|
1084
|
+
customBasePath = document.getElementById('dataBasePath').value.trim();
|
|
1085
|
+
localStorage.setItem('comparisonDataBasePath', customBasePath);
|
|
1086
|
+
document.getElementById('currentBasePath').textContent = customBasePath || '(using original paths)';
|
|
1087
|
+
}
|
|
1088
|
+
function resolveFilePath(originalPath) {
|
|
1089
|
+
if (!originalPath) return '';
|
|
1090
|
+
if (!customBasePath || !originalBasePath) return originalPath;
|
|
1091
|
+
if (originalPath.startsWith(originalBasePath)) {
|
|
1092
|
+
return customBasePath + originalPath.slice(originalBasePath.length);
|
|
1093
|
+
}
|
|
1094
|
+
return originalPath;
|
|
1095
|
+
}
|
|
1096
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1097
|
+
if (customBasePath) {
|
|
1098
|
+
document.getElementById('dataBasePath').value = customBasePath;
|
|
1099
|
+
document.getElementById('currentBasePath').textContent = customBasePath;
|
|
1100
|
+
}
|
|
1101
|
+
populateMetricSelector();
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// ---- Metric selector ----
|
|
1105
|
+
const metricDisplayNames = """
|
|
1106
|
+
+ json.dumps(METRIC_DISPLAY_NAMES)
|
|
1107
|
+
+ """;
|
|
1108
|
+
let currentFilter = 'all';
|
|
1109
|
+
|
|
1110
|
+
function populateMetricSelector() {
|
|
1111
|
+
const select = document.getElementById('metricSelect');
|
|
1112
|
+
if (!select) return;
|
|
1113
|
+
// Discover all metric names across all results
|
|
1114
|
+
const names = new Set();
|
|
1115
|
+
comparisonData.forEach(r => {
|
|
1116
|
+
(r.pipeline_a.all_metrics || []).forEach(m => names.add(m.metric_name));
|
|
1117
|
+
(r.pipeline_b.all_metrics || []).forEach(m => names.add(m.metric_name));
|
|
1118
|
+
});
|
|
1119
|
+
// Sort with current primary first, then alphabetical
|
|
1120
|
+
const sorted = [...names].sort((a, b) => {
|
|
1121
|
+
if (a === comparisonMetric) return -1;
|
|
1122
|
+
if (b === comparisonMetric) return 1;
|
|
1123
|
+
return a.localeCompare(b);
|
|
1124
|
+
});
|
|
1125
|
+
select.innerHTML = '';
|
|
1126
|
+
sorted.forEach(name => {
|
|
1127
|
+
const opt = document.createElement('option');
|
|
1128
|
+
opt.value = name;
|
|
1129
|
+
opt.textContent = metricDisplayNames[name] || name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
|
1130
|
+
if (name === comparisonMetric) opt.selected = true;
|
|
1131
|
+
select.appendChild(opt);
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function getMetricValue(allMetrics, metricName) {
|
|
1136
|
+
if (!allMetrics) return null;
|
|
1137
|
+
const m = allMetrics.find(m => m.metric_name === metricName);
|
|
1138
|
+
return m ? m.value : null;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function switchMetric(newMetric) {
|
|
1142
|
+
comparisonMetric = newMetric;
|
|
1143
|
+
metricDisplayName = metricDisplayNames[newMetric] || newMetric;
|
|
1144
|
+
|
|
1145
|
+
// Recompute categories and metric values for each row
|
|
1146
|
+
const counts = { total: comparisonData.length, a_better: 0, b_better: 0, tie: 0, both_bad: 0 };
|
|
1147
|
+
|
|
1148
|
+
comparisonData.forEach((r, i) => {
|
|
1149
|
+
const vA = getMetricValue(r.pipeline_a.all_metrics, newMetric);
|
|
1150
|
+
const vB = getMetricValue(r.pipeline_b.all_metrics, newMetric);
|
|
1151
|
+
|
|
1152
|
+
// Update metric_value on the data so detail panes use it
|
|
1153
|
+
r.pipeline_a.metric_value = vA;
|
|
1154
|
+
r.pipeline_b.metric_value = vB;
|
|
1155
|
+
|
|
1156
|
+
// Recompute category
|
|
1157
|
+
let cat;
|
|
1158
|
+
if (vA != null && vB != null) {
|
|
1159
|
+
cat = vA > vB ? 'a_better' : vB > vA ? 'b_better' : 'tie';
|
|
1160
|
+
} else if (vA == null && vB == null) {
|
|
1161
|
+
cat = 'both_bad';
|
|
1162
|
+
} else if (vA == null) {
|
|
1163
|
+
cat = 'b_better';
|
|
1164
|
+
} else {
|
|
1165
|
+
cat = 'a_better';
|
|
1166
|
+
}
|
|
1167
|
+
r.category = cat;
|
|
1168
|
+
counts[cat] = (counts[cat] || 0) + 1;
|
|
1169
|
+
|
|
1170
|
+
// Update DOM for this row
|
|
1171
|
+
const row = document.querySelector(`.result-row[data-index="${i}"]`);
|
|
1172
|
+
if (!row) return;
|
|
1173
|
+
row.dataset.category = cat;
|
|
1174
|
+
|
|
1175
|
+
const cols = row.querySelector('.row-summary');
|
|
1176
|
+
if (!cols) return;
|
|
1177
|
+
const metricCols = cols.querySelectorAll('.col-metric');
|
|
1178
|
+
const deltaCol = cols.querySelector('.col-delta');
|
|
1179
|
+
const catCol = cols.querySelector('.col-category');
|
|
1180
|
+
|
|
1181
|
+
if (metricCols[0]) metricCols[0].innerHTML = fmtMetric(vA);
|
|
1182
|
+
if (metricCols[1]) metricCols[1].innerHTML = fmtMetric(vB);
|
|
1183
|
+
if (deltaCol) deltaCol.innerHTML = fmtDelta(vA, vB);
|
|
1184
|
+
if (catCol) {
|
|
1185
|
+
const catLabels = {
|
|
1186
|
+
a_better: pipelineAName + ' Better',
|
|
1187
|
+
b_better: pipelineBName + ' Better',
|
|
1188
|
+
tie: 'Tie',
|
|
1189
|
+
both_bad: 'Both Bad',
|
|
1190
|
+
};
|
|
1191
|
+
catCol.innerHTML = `<span class="badge badge-${cat.replace('_', '-')}">${esc(catLabels[cat] || cat)}</span>`;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// If detail pane is open, rebuild it
|
|
1195
|
+
const detail = document.getElementById('detail-' + i);
|
|
1196
|
+
if (detail && detail.classList.contains('open')) {
|
|
1197
|
+
detail.innerHTML = buildDetailContent(i);
|
|
1198
|
+
initDetailInteractions(i);
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// Update stat cards
|
|
1203
|
+
const statCards = document.querySelectorAll('.stat-card');
|
|
1204
|
+
statCards.forEach(card => {
|
|
1205
|
+
const filter = card.dataset.filter;
|
|
1206
|
+
const valueEl = card.querySelector('.stat-value');
|
|
1207
|
+
if (!valueEl) return;
|
|
1208
|
+
if (filter === 'all') valueEl.textContent = counts.total;
|
|
1209
|
+
else if (counts[filter] !== undefined) valueEl.textContent = counts[filter];
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
// Update filter bar
|
|
1213
|
+
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
1214
|
+
const filter = btn.dataset.filter;
|
|
1215
|
+
if (filter === 'all') btn.textContent = `All (${counts.total})`;
|
|
1216
|
+
else if (filter === 'a_better') btn.textContent = `${pipelineAName} Better (${counts.a_better})`;
|
|
1217
|
+
else if (filter === 'b_better') btn.textContent = `${pipelineBName} Better (${counts.b_better})`;
|
|
1218
|
+
else if (filter === 'tie') btn.textContent = `Tie (${counts.tie})`;
|
|
1219
|
+
else if (filter === 'both_bad') btn.textContent = `Both Bad (${counts.both_bad})`;
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// Re-apply current filter
|
|
1223
|
+
applyFilter(currentFilter);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function fmtMetric(val) {
|
|
1227
|
+
if (val === null || val === undefined) return '<span class="na">N/A</span>';
|
|
1228
|
+
const pct = val * 100;
|
|
1229
|
+
return `<span class="metric-val ${metricColorClass(val)}">${pct.toFixed(1)}%</span>`;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function fmtDelta(a, b) {
|
|
1233
|
+
if (a === null || a === undefined || b === null || b === undefined) return '<span class="na">—</span>';
|
|
1234
|
+
const d = (a - b) * 100;
|
|
1235
|
+
const sign = d > 0 ? '+' : '';
|
|
1236
|
+
const cls = d > 0 ? 'delta-pos' : d < 0 ? 'delta-neg' : 'delta-zero';
|
|
1237
|
+
return `<span class="${cls}">${sign}${d.toFixed(1)}pp</span>`;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// ---- Filter ----
|
|
1241
|
+
function applyFilter(filter) {
|
|
1242
|
+
currentFilter = filter;
|
|
1243
|
+
// Update filter buttons
|
|
1244
|
+
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
1245
|
+
const matchBtn = document.querySelector(`.filter-btn[data-filter="${filter}"]`);
|
|
1246
|
+
if (matchBtn) matchBtn.classList.add('active');
|
|
1247
|
+
// Update stat cards
|
|
1248
|
+
document.querySelectorAll('.stat-card').forEach(c => c.classList.remove('active'));
|
|
1249
|
+
const matchCard = document.querySelector(`.stat-card[data-filter="${filter}"]`);
|
|
1250
|
+
if (matchCard) matchCard.classList.add('active');
|
|
1251
|
+
// Filter rows
|
|
1252
|
+
document.querySelectorAll('.result-row').forEach(row => {
|
|
1253
|
+
row.style.display = (filter === 'all' || row.dataset.category === filter) ? '' : 'none';
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
1258
|
+
btn.addEventListener('click', () => applyFilter(btn.dataset.filter));
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
function filterFromCard(card) {
|
|
1262
|
+
applyFilter(card.dataset.filter);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ---- Expand / collapse rows ----
|
|
1266
|
+
const openRows = new Set();
|
|
1267
|
+
|
|
1268
|
+
function toggleRow(index) {
|
|
1269
|
+
const detail = document.getElementById('detail-' + index);
|
|
1270
|
+
const icon = document.getElementById('icon-' + index);
|
|
1271
|
+
if (openRows.has(index)) {
|
|
1272
|
+
detail.classList.remove('open');
|
|
1273
|
+
icon.classList.remove('expanded');
|
|
1274
|
+
openRows.delete(index);
|
|
1275
|
+
} else {
|
|
1276
|
+
// Build detail content if empty
|
|
1277
|
+
if (!detail.innerHTML.trim()) {
|
|
1278
|
+
detail.innerHTML = buildDetailContent(index);
|
|
1279
|
+
initDetailInteractions(index);
|
|
1280
|
+
}
|
|
1281
|
+
detail.classList.add('open');
|
|
1282
|
+
icon.classList.add('expanded');
|
|
1283
|
+
openRows.add(index);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// ---- Build detail content ----
|
|
1288
|
+
function buildDetailContent(index) {
|
|
1289
|
+
const r = comparisonData[index];
|
|
1290
|
+
if (!r) return '<p>No data</p>';
|
|
1291
|
+
|
|
1292
|
+
if (productType === 'layout_detection') {
|
|
1293
|
+
return buildLayoutDetectionDetail(r, index);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
let html = '<div class="detail-two-col">';
|
|
1297
|
+
|
|
1298
|
+
// --- Left column: input preview (sticky) ---
|
|
1299
|
+
html += '<div class="detail-left">';
|
|
1300
|
+
html += buildInputPanel(r);
|
|
1301
|
+
html += buildMetricPills(r);
|
|
1302
|
+
html += '</div>';
|
|
1303
|
+
|
|
1304
|
+
// --- Right column: output + metrics + rules ---
|
|
1305
|
+
html += '<div class="detail-right">';
|
|
1306
|
+
html += buildOutputTab(r, index);
|
|
1307
|
+
|
|
1308
|
+
// Collapsible full metrics
|
|
1309
|
+
const fullMetrics = buildFullMetricsSection(r);
|
|
1310
|
+
if (fullMetrics) {
|
|
1311
|
+
html += `<details class="collapsible-section"><summary>All Metrics</summary>${fullMetrics}</details>`;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Collapsible rules
|
|
1315
|
+
const rulesHtml = buildRulesSection(r);
|
|
1316
|
+
if (rulesHtml) {
|
|
1317
|
+
html += `<details class="collapsible-section"><summary>${rulesHtml.summary}</summary>${rulesHtml.body}</details>`;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
html += '</div>';
|
|
1321
|
+
html += '</div>';
|
|
1322
|
+
|
|
1323
|
+
return html;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Compact metric pills for the left column
|
|
1327
|
+
function buildMetricPills(r) {
|
|
1328
|
+
const metricsA = r.pipeline_a.all_metrics || [];
|
|
1329
|
+
const metricsB = r.pipeline_b.all_metrics || [];
|
|
1330
|
+
const statsA = r.pipeline_a.all_stats || [];
|
|
1331
|
+
const statsB = r.pipeline_b.all_stats || [];
|
|
1332
|
+
if (metricsA.length === 0 && metricsB.length === 0 && statsA.length === 0 && statsB.length === 0) return '';
|
|
1333
|
+
|
|
1334
|
+
const metricDisplayNames = """
|
|
1335
|
+
+ json.dumps(METRIC_DISPLAY_NAMES)
|
|
1336
|
+
+ """;
|
|
1337
|
+
|
|
1338
|
+
// Collect all metric names, primary first
|
|
1339
|
+
const names = new Set();
|
|
1340
|
+
metricsA.forEach(m => names.add(m.metric_name));
|
|
1341
|
+
metricsB.forEach(m => names.add(m.metric_name));
|
|
1342
|
+
const sorted = [...names].sort((a, b) => {
|
|
1343
|
+
if (a === comparisonMetric) return -1;
|
|
1344
|
+
if (b === comparisonMetric) return 1;
|
|
1345
|
+
return a.localeCompare(b);
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
let html = '<div class="metric-pills">';
|
|
1349
|
+
sorted.forEach(name => {
|
|
1350
|
+
const mA = metricsA.find(m => m.metric_name === name);
|
|
1351
|
+
const mB = metricsB.find(m => m.metric_name === name);
|
|
1352
|
+
const vA = mA ? mA.value : null;
|
|
1353
|
+
const vB = mB ? mB.value : null;
|
|
1354
|
+
const displayName = metricDisplayNames[name] || name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
|
1355
|
+
const isPrimary = name === comparisonMetric;
|
|
1356
|
+
|
|
1357
|
+
const fmtVal = v => v === null || v === undefined ? 'N/A' : (v * 100).toFixed(1) + '%';
|
|
1358
|
+
let deltaHtml = '';
|
|
1359
|
+
if (vA != null && vB != null) {
|
|
1360
|
+
const d = (vA - vB) * 100;
|
|
1361
|
+
const sign = d > 0 ? '+' : '';
|
|
1362
|
+
const cls = d > 0 ? 'delta-pos' : d < 0 ? 'delta-neg' : 'delta-zero';
|
|
1363
|
+
deltaHtml = `<span class="pill-delta ${cls}">${sign}${d.toFixed(1)}</span>`;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
html += `<div class="pill${isPrimary ? ' pill-primary' : ''}">`;
|
|
1367
|
+
html += `<span class="pill-label">${esc(displayName)}${tooltipIcon(name)}</span>`;
|
|
1368
|
+
html += `<span class="pill-values"><span class="${metricColorClass(vA)}">${fmtVal(vA)}</span> / <span class="${metricColorClass(vB)}">${fmtVal(vB)}</span>${deltaHtml}</span>`;
|
|
1369
|
+
html += '</div>';
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
// Stats pills (raw value + unit, not percentage)
|
|
1373
|
+
const statNames = new Set();
|
|
1374
|
+
statsA.forEach(s => statNames.add(s.name));
|
|
1375
|
+
statsB.forEach(s => statNames.add(s.name));
|
|
1376
|
+
[...statNames].sort().forEach(name => {
|
|
1377
|
+
const sA = statsA.find(s => s.name === name);
|
|
1378
|
+
const sB = statsB.find(s => s.name === name);
|
|
1379
|
+
const vA = sA ? sA.value : null;
|
|
1380
|
+
const vB = sB ? sB.value : null;
|
|
1381
|
+
const unit = (sA || sB || {}).unit || '';
|
|
1382
|
+
const displayName = name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
|
1383
|
+
const fmtStat = v => v === null || v === undefined ? 'N/A' : v.toFixed(0) + unit;
|
|
1384
|
+
let deltaHtml = '';
|
|
1385
|
+
if (vA != null && vB != null) {
|
|
1386
|
+
const d = vA - vB;
|
|
1387
|
+
const sign = d > 0 ? '+' : '';
|
|
1388
|
+
const cls = d > 0 ? 'delta-neg' : d < 0 ? 'delta-pos' : 'delta-zero';
|
|
1389
|
+
deltaHtml = `<span class="pill-delta ${cls}">${sign}${d.toFixed(0)}${unit}</span>`;
|
|
1390
|
+
}
|
|
1391
|
+
html += `<div class="pill">`;
|
|
1392
|
+
html += `<span class="pill-label">${esc(displayName)}${tooltipIcon(name)}</span>`;
|
|
1393
|
+
html += `<span class="pill-values">${fmtStat(vA)} / ${fmtStat(vB)}${deltaHtml}</span>`;
|
|
1394
|
+
html += '</div>';
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
html += '</div>';
|
|
1398
|
+
return html;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Input panel for left column
|
|
1402
|
+
function buildInputPanel(r) {
|
|
1403
|
+
return buildInputTab(r);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Full metrics table (for collapsible section)
|
|
1407
|
+
function buildFullMetricsSection(r) {
|
|
1408
|
+
const metricsA = r.pipeline_a.all_metrics || [];
|
|
1409
|
+
const metricsB = r.pipeline_b.all_metrics || [];
|
|
1410
|
+
const statsA = r.pipeline_a.all_stats || [];
|
|
1411
|
+
const statsB = r.pipeline_b.all_stats || [];
|
|
1412
|
+
const metricNames = new Set();
|
|
1413
|
+
metricsA.forEach(m => metricNames.add(m.metric_name));
|
|
1414
|
+
metricsB.forEach(m => metricNames.add(m.metric_name));
|
|
1415
|
+
const statNames = new Set();
|
|
1416
|
+
statsA.forEach(s => statNames.add(s.name));
|
|
1417
|
+
statsB.forEach(s => statNames.add(s.name));
|
|
1418
|
+
if (metricNames.size === 0 && statNames.size === 0) return '';
|
|
1419
|
+
|
|
1420
|
+
const metricDisplayNames = """
|
|
1421
|
+
+ json.dumps(METRIC_DISPLAY_NAMES)
|
|
1422
|
+
+ """;
|
|
1423
|
+
const sorted = [...metricNames].sort((a, b) => {
|
|
1424
|
+
if (a === comparisonMetric) return -1;
|
|
1425
|
+
if (b === comparisonMetric) return 1;
|
|
1426
|
+
return a.localeCompare(b);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
let html = `<table class="metrics-table"><thead><tr><th>Metric</th><th style="text-align:center">${esc(pipelineAName)}</th><th style="text-align:center">${esc(pipelineBName)}</th><th style="text-align:center">Delta</th></tr></thead><tbody>`;
|
|
1430
|
+
|
|
1431
|
+
sorted.forEach(name => {
|
|
1432
|
+
const mA = metricsA.find(m => m.metric_name === name);
|
|
1433
|
+
const mB = metricsB.find(m => m.metric_name === name);
|
|
1434
|
+
const vA = mA ? mA.value : null;
|
|
1435
|
+
const vB = mB ? mB.value : null;
|
|
1436
|
+
const displayName = metricDisplayNames[name] || name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
|
1437
|
+
const fmtVal = v => v === null || v === undefined ? '<span class="na">N/A</span>' : `<span class="${metricColorClass(v)}">${(v * 100).toFixed(1)}%</span>`;
|
|
1438
|
+
const delta = (vA != null && vB != null)
|
|
1439
|
+
? (() => { const d = (vA - vB) * 100; const sign = d > 0 ? '+' : ''; const cls = d > 0 ? 'delta-pos' : d < 0 ? 'delta-neg' : 'delta-zero'; return `<span class="${cls}">${sign}${d.toFixed(1)}pp</span>`; })()
|
|
1440
|
+
: '<span class="na">—</span>';
|
|
1441
|
+
const isPrimary = name === comparisonMetric ? ' style="font-weight:600;"' : '';
|
|
1442
|
+
html += `<tr${isPrimary}><td class="metric-name">${esc(displayName)}${tooltipIcon(name)}</td><td class="val-cell">${fmtVal(vA)}</td><td class="val-cell">${fmtVal(vB)}</td><td class="val-cell">${delta}</td></tr>`;
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
html += '</tbody></table>';
|
|
1446
|
+
|
|
1447
|
+
// Stats table (raw value + unit)
|
|
1448
|
+
if (statNames.size > 0) {
|
|
1449
|
+
html += `<table class="metrics-table" style="margin-top:10px"><thead><tr><th>Stat</th><th style="text-align:center">${esc(pipelineAName)}</th><th style="text-align:center">${esc(pipelineBName)}</th><th style="text-align:center">Delta</th></tr></thead><tbody>`;
|
|
1450
|
+
[...statNames].sort().forEach(name => {
|
|
1451
|
+
const sA = statsA.find(s => s.name === name);
|
|
1452
|
+
const sB = statsB.find(s => s.name === name);
|
|
1453
|
+
const vA = sA ? sA.value : null;
|
|
1454
|
+
const vB = sB ? sB.value : null;
|
|
1455
|
+
const unit = (sA || sB || {}).unit || '';
|
|
1456
|
+
const displayName = name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
|
1457
|
+
const fmtStat = v => v === null || v === undefined ? '<span class="na">N/A</span>' : v.toFixed(0) + unit;
|
|
1458
|
+
const delta = (vA != null && vB != null)
|
|
1459
|
+
? (() => { const d = vA - vB; const sign = d > 0 ? '+' : ''; const cls = d > 0 ? 'delta-neg' : d < 0 ? 'delta-pos' : 'delta-zero'; return `<span class="${cls}">${sign}${d.toFixed(0)}${unit}</span>`; })()
|
|
1460
|
+
: '<span class="na">—</span>';
|
|
1461
|
+
html += `<tr><td class="metric-name">${esc(displayName)}</td><td class="val-cell">${fmtStat(vA)}</td><td class="val-cell">${fmtStat(vB)}</td><td class="val-cell">${delta}</td></tr>`;
|
|
1462
|
+
});
|
|
1463
|
+
html += '</tbody></table>';
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
return html;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Rules section (returns {summary, body} or null)
|
|
1470
|
+
function buildRulesSection(r) {
|
|
1471
|
+
const metricsA = r.pipeline_a.all_metrics || [];
|
|
1472
|
+
const metricsB = r.pipeline_b.all_metrics || [];
|
|
1473
|
+
|
|
1474
|
+
function getRules(metrics) {
|
|
1475
|
+
for (const m of metrics) {
|
|
1476
|
+
if (m.metric_name === 'rule_pass_rate' && m.metadata && m.metadata.rule_results) return m.metadata.rule_results;
|
|
1477
|
+
}
|
|
1478
|
+
for (const m of metrics) {
|
|
1479
|
+
if (m.metadata && m.metadata.rule_results) return m.metadata.rule_results;
|
|
1480
|
+
}
|
|
1481
|
+
return [];
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const rulesA = getRules(metricsA);
|
|
1485
|
+
const rulesB = getRules(metricsB);
|
|
1486
|
+
if (rulesA.length === 0 && rulesB.length === 0) return null;
|
|
1487
|
+
|
|
1488
|
+
const maxLen = Math.max(rulesA.length, rulesB.length);
|
|
1489
|
+
let diffCount = 0;
|
|
1490
|
+
for (let i = 0; i < maxLen; i++) {
|
|
1491
|
+
const pA = (rulesA[i] || {}).passed;
|
|
1492
|
+
const pB = (rulesB[i] || {}).passed;
|
|
1493
|
+
if (pA !== undefined && pB !== undefined && pA !== pB) diffCount++;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const summary = `Rules (${maxLen} total${diffCount > 0 ? `, <strong>${diffCount} differ</strong>` : ''})`;
|
|
1497
|
+
|
|
1498
|
+
let body = `<table class="rule-table"><thead><tr><th>#</th><th>Type</th><th>Name</th><th>Page</th><th style="text-align:center">${esc(pipelineAName)}</th><th style="text-align:center">${esc(pipelineBName)}</th><th></th></tr></thead><tbody>`;
|
|
1499
|
+
|
|
1500
|
+
for (let i = 0; i < maxLen; i++) {
|
|
1501
|
+
const rA = rulesA[i] || {};
|
|
1502
|
+
const rB = rulesB[i] || {};
|
|
1503
|
+
const passA = rA.passed;
|
|
1504
|
+
const passB = rB.passed;
|
|
1505
|
+
let rowClass = '';
|
|
1506
|
+
if (passA !== undefined && passB !== undefined) {
|
|
1507
|
+
rowClass = passA === passB ? (passA ? 'rule-same-pass' : 'rule-same-fail') : 'rule-diff-row';
|
|
1508
|
+
}
|
|
1509
|
+
const type = rA.type || rB.type || '';
|
|
1510
|
+
const name = rA.name || rB.name || '';
|
|
1511
|
+
const page = rA.page || rB.page || '';
|
|
1512
|
+
const fmtPass = v => v === true ? '<span class="rule-pass">PASS</span>' : v === false ? '<span class="rule-fail">FAIL</span>' : '<span class="na">—</span>';
|
|
1513
|
+
const explA = rA.explanation || '';
|
|
1514
|
+
const explB = rB.explanation || '';
|
|
1515
|
+
const hasExpl = explA || explB;
|
|
1516
|
+
body += `<tr class="${rowClass}"><td>${i+1}</td><td>${esc(type)}</td><td>${esc(name)}</td><td>${page||''}</td><td style="text-align:center">${fmtPass(passA)}</td><td style="text-align:center">${fmtPass(passB)}</td><td>${hasExpl ? '<span class="rule-expander" onclick="toggleRuleExpl(this)">details</span>' : ''}</td></tr>`;
|
|
1517
|
+
if (hasExpl) {
|
|
1518
|
+
body += `<tr class="${rowClass}" style="display:none;" data-expl-row="1"><td colspan="7" style="padding:0.25rem 0.6rem;"><div style="font-size:0.75rem;color:var(--muted);">`;
|
|
1519
|
+
if (explA) body += `<div><strong>${esc(pipelineAName)}:</strong> ${esc(explA)}</div>`;
|
|
1520
|
+
if (explB) body += `<div><strong>${esc(pipelineBName)}:</strong> ${esc(explB)}</div>`;
|
|
1521
|
+
body += '</div></td></tr>';
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
body += '</tbody></table>';
|
|
1525
|
+
|
|
1526
|
+
return { summary, body };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function toggleRuleExpl(el) {
|
|
1530
|
+
const tr = el.closest('tr');
|
|
1531
|
+
const next = tr.nextElementSibling;
|
|
1532
|
+
if (next && next.dataset.explRow) {
|
|
1533
|
+
next.style.display = next.style.display === 'none' ? '' : 'none';
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// ---- Output tab ----
|
|
1538
|
+
function buildOutputTab(r, index) {
|
|
1539
|
+
if (productType === 'parse') {
|
|
1540
|
+
return buildParseOutputTab(r, index);
|
|
1541
|
+
} else if (productType === 'extract') {
|
|
1542
|
+
return buildExtractOutputTab(r, index);
|
|
1543
|
+
}
|
|
1544
|
+
return '<p class="na" style="padding:1rem;">No output comparison available for this product type.</p>';
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function buildParseOutputTab(r, index) {
|
|
1548
|
+
const outA = r.pipeline_a.output || '';
|
|
1549
|
+
const outB = r.pipeline_b.output || '';
|
|
1550
|
+
|
|
1551
|
+
let html = '';
|
|
1552
|
+
|
|
1553
|
+
// View mode toggle
|
|
1554
|
+
html += `<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;">`;
|
|
1555
|
+
html += `<div class="view-toggle">`;
|
|
1556
|
+
html += `<button class="active" onclick="setParseView(${index}, 'rendered', this)">Rendered</button>`;
|
|
1557
|
+
html += `<button onclick="setParseView(${index}, 'raw', this)">Raw</button>`;
|
|
1558
|
+
html += `<button onclick="setParseView(${index}, 'diff', this)">Diff</button>`;
|
|
1559
|
+
html += `</div></div>`;
|
|
1560
|
+
|
|
1561
|
+
// Side-by-side rendered
|
|
1562
|
+
html += `<div id="parse-rendered-${index}" class="output-grid">`;
|
|
1563
|
+
html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineAName)}</div>`;
|
|
1564
|
+
html += `<div class="output-panel-body md-view" id="md-a-${index}">${outA ? marked.parse(outA) : '<em>No output</em>'}</div></div>`;
|
|
1565
|
+
html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineBName)}</div>`;
|
|
1566
|
+
html += `<div class="output-panel-body md-view" id="md-b-${index}">${outB ? marked.parse(outB) : '<em>No output</em>'}</div></div>`;
|
|
1567
|
+
html += `</div>`;
|
|
1568
|
+
|
|
1569
|
+
// Side-by-side raw
|
|
1570
|
+
html += `<div id="parse-raw-${index}" class="output-grid" style="display:none;">`;
|
|
1571
|
+
html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineAName)}</div>`;
|
|
1572
|
+
html += `<div class="output-panel-body json-view">${esc(outA) || '<em>No output</em>'}</div></div>`;
|
|
1573
|
+
html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineBName)}</div>`;
|
|
1574
|
+
html += `<div class="output-panel-body json-view">${esc(outB) || '<em>No output</em>'}</div></div>`;
|
|
1575
|
+
html += `</div>`;
|
|
1576
|
+
|
|
1577
|
+
// Diff view
|
|
1578
|
+
html += `<div id="parse-diff-${index}" style="display:none;">`;
|
|
1579
|
+
html += `<div class="diff-container"><div class="diff-header">Unified Diff</div>`;
|
|
1580
|
+
html += `<div class="diff-body">${computeLineDiff(outA, outB)}</div></div>`;
|
|
1581
|
+
html += `</div>`;
|
|
1582
|
+
|
|
1583
|
+
return html;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function setParseView(index, mode, btn) {
|
|
1587
|
+
const toggle = btn.closest('.view-toggle');
|
|
1588
|
+
toggle.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
|
1589
|
+
btn.classList.add('active');
|
|
1590
|
+
['rendered', 'raw', 'diff'].forEach(m => {
|
|
1591
|
+
const el = document.getElementById(`parse-${m}-${index}`);
|
|
1592
|
+
if (el) el.style.display = m === mode ? '' : 'none';
|
|
1593
|
+
});
|
|
1594
|
+
if (mode === 'rendered' || mode === 'raw') {
|
|
1595
|
+
setupSyncScroll(index, mode);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function buildExtractOutputTab(r, index) {
|
|
1600
|
+
const outA = r.pipeline_a.output;
|
|
1601
|
+
const outB = r.pipeline_b.output;
|
|
1602
|
+
const jsonA = outA != null ? JSON.stringify(outA, null, 2) : '';
|
|
1603
|
+
const jsonB = outB != null ? JSON.stringify(outB, null, 2) : '';
|
|
1604
|
+
|
|
1605
|
+
let html = `<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;">`;
|
|
1606
|
+
html += `<div class="view-toggle">`;
|
|
1607
|
+
html += `<button class="active" onclick="setExtractView(${index}, 'side', this)">Side by Side</button>`;
|
|
1608
|
+
html += `<button onclick="setExtractView(${index}, 'diff', this)">Diff</button>`;
|
|
1609
|
+
html += `</div></div>`;
|
|
1610
|
+
|
|
1611
|
+
// Side by side JSON
|
|
1612
|
+
html += `<div id="extract-side-${index}" class="output-grid">`;
|
|
1613
|
+
html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineAName)}</div>`;
|
|
1614
|
+
html += `<div class="output-panel-body json-view">${esc(jsonA) || '<em>No output</em>'}</div></div>`;
|
|
1615
|
+
html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineBName)}</div>`;
|
|
1616
|
+
html += `<div class="output-panel-body json-view">${esc(jsonB) || '<em>No output</em>'}</div></div>`;
|
|
1617
|
+
html += `</div>`;
|
|
1618
|
+
|
|
1619
|
+
// Diff
|
|
1620
|
+
html += `<div id="extract-diff-${index}" style="display:none;">`;
|
|
1621
|
+
html += `<div class="diff-container"><div class="diff-header">Unified Diff</div>`;
|
|
1622
|
+
html += `<div class="diff-body">${computeLineDiff(jsonA, jsonB)}</div></div>`;
|
|
1623
|
+
html += `</div>`;
|
|
1624
|
+
|
|
1625
|
+
return html;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function setExtractView(index, mode, btn) {
|
|
1629
|
+
const toggle = btn.closest('.view-toggle');
|
|
1630
|
+
toggle.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
|
1631
|
+
btn.classList.add('active');
|
|
1632
|
+
['side', 'diff'].forEach(m => {
|
|
1633
|
+
const el = document.getElementById(`extract-${m}-${index}`);
|
|
1634
|
+
if (el) el.style.display = m === mode ? '' : 'none';
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// ---- Input tab ----
|
|
1639
|
+
function buildInputTab(r) {
|
|
1640
|
+
const inputFile = r.input_file;
|
|
1641
|
+
const inputFileRel = r.input_file_rel;
|
|
1642
|
+
const dataUrl = r.input_data_url;
|
|
1643
|
+
|
|
1644
|
+
if (!inputFile && !dataUrl) {
|
|
1645
|
+
return '<p class="na" style="padding:1rem;">No input file available.</p>';
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const ext = inputFile ? inputFile.split('.').pop().toLowerCase() : '';
|
|
1649
|
+
const isPdf = ext === 'pdf';
|
|
1650
|
+
|
|
1651
|
+
let html = '<div class="input-preview">';
|
|
1652
|
+
|
|
1653
|
+
if (inputFile) {
|
|
1654
|
+
html += `<p class="file-path">${esc(inputFile)}</p>`;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
if (dataUrl && !isPdf) {
|
|
1658
|
+
// Embedded image
|
|
1659
|
+
html += `<img src="${dataUrl}" />`;
|
|
1660
|
+
} else if (isPdf) {
|
|
1661
|
+
// Resolve PDF URL: use pdfBaseUrl + relative path, like the detailed report
|
|
1662
|
+
let pdfSrc = '';
|
|
1663
|
+
if (pdfBaseUrl && inputFileRel) {
|
|
1664
|
+
const base = pdfBaseUrl.endsWith('/') ? pdfBaseUrl.slice(0, -1) : pdfBaseUrl;
|
|
1665
|
+
const rel = inputFileRel.startsWith('/') ? inputFileRel.slice(1) : inputFileRel;
|
|
1666
|
+
pdfSrc = base + '/' + rel;
|
|
1667
|
+
} else if (inputFile) {
|
|
1668
|
+
pdfSrc = resolveFilePath(inputFile);
|
|
1669
|
+
}
|
|
1670
|
+
if (pdfSrc) {
|
|
1671
|
+
const viewerId = 'pdfviewer-' + Math.random().toString(36).slice(2, 10);
|
|
1672
|
+
html += `<div id="${viewerId}" class="pdfjs-viewer" data-pdf-src="${esc(pdfSrc)}" data-pdf-pending="true" style="max-height:700px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:8px;background:#f5f5f4;"><p class="na">Loading PDF...</p></div>`;
|
|
1673
|
+
} else {
|
|
1674
|
+
html += `<p class="na">PDF preview not available — no base URL configured.</p>`;
|
|
1675
|
+
}
|
|
1676
|
+
} else if (inputFile) {
|
|
1677
|
+
if (['png', 'jpg', 'jpeg', 'gif'].includes(ext)) {
|
|
1678
|
+
// Resolve image URL via file server, same as PDFs
|
|
1679
|
+
let imgSrc = '';
|
|
1680
|
+
if (pdfBaseUrl && inputFileRel) {
|
|
1681
|
+
const base = pdfBaseUrl.endsWith('/') ? pdfBaseUrl.slice(0, -1) : pdfBaseUrl;
|
|
1682
|
+
const rel = inputFileRel.startsWith('/') ? inputFileRel.slice(1) : inputFileRel;
|
|
1683
|
+
imgSrc = base + '/' + rel;
|
|
1684
|
+
} else {
|
|
1685
|
+
imgSrc = resolveFilePath(inputFile);
|
|
1686
|
+
}
|
|
1687
|
+
html += `<img src="${esc(imgSrc)}" />`;
|
|
1688
|
+
} else {
|
|
1689
|
+
html += `<p class="na">Preview not available for .${esc(ext)} files</p>`;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
html += '</div>';
|
|
1694
|
+
return html;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// ---- Layout detection detail ----
|
|
1698
|
+
const LAYOUTDET_COLORS = {
|
|
1699
|
+
'Caption': '#E91E63', 'Footnote': '#9C27B0', 'Formula': '#673AB7',
|
|
1700
|
+
'List-item': '#3F51B5', 'Page-footer': '#2196F3', 'Page-header': '#00BCD4',
|
|
1701
|
+
'Picture': '#4CAF50', 'Section-header': '#FF9800', 'Table': '#FF5722',
|
|
1702
|
+
'Text': '#795548', 'Title': '#F44336'
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1705
|
+
function buildLayoutDetectionDetail(r, index) {
|
|
1706
|
+
const predA = r.pipeline_a.predictions || [];
|
|
1707
|
+
const predB = r.pipeline_b.predictions || [];
|
|
1708
|
+
const gt = r.gt_annotations || [];
|
|
1709
|
+
const imgPath = r.input_data_url || resolveFilePath(r.input_file || '');
|
|
1710
|
+
|
|
1711
|
+
// Store in window for drawing
|
|
1712
|
+
window[`predA_${index}`] = predA;
|
|
1713
|
+
window[`predB_${index}`] = predB;
|
|
1714
|
+
window[`gt_${index}`] = gt;
|
|
1715
|
+
window[`bboxState_${index}`] = { showA: true, showB: true, showGT: true };
|
|
1716
|
+
|
|
1717
|
+
const metricA = r.pipeline_a.metric_value != null ? (r.pipeline_a.metric_value * 100).toFixed(1) + '%' : 'N/A';
|
|
1718
|
+
const metricB = r.pipeline_b.metric_value != null ? (r.pipeline_b.metric_value * 100).toFixed(1) + '%' : 'N/A';
|
|
1719
|
+
|
|
1720
|
+
let html = '';
|
|
1721
|
+
|
|
1722
|
+
// Metric pills at top
|
|
1723
|
+
html += buildMetricPills(r);
|
|
1724
|
+
|
|
1725
|
+
// 3-column grid
|
|
1726
|
+
html += `<div class="layoutdet-grid">`;
|
|
1727
|
+
|
|
1728
|
+
// Panel A
|
|
1729
|
+
html += `<div class="layoutdet-panel"><div class="layoutdet-panel-header"><span>${esc(pipelineAName)} (${metricA})</span>`;
|
|
1730
|
+
html += `<button class="bbox-btn active" onclick="toggleLayoutBbox(${index}, 'A', this)">Bboxes</button>`;
|
|
1731
|
+
html += `</div><div class="layoutdet-panel-body"><img id="ld-img-a-${index}" src="${imgPath}" onload="drawLayoutPanel(${index}, 'A')" /><canvas id="ld-canvas-a-${index}" class="layoutdet-canvas"></canvas></div></div>`;
|
|
1732
|
+
|
|
1733
|
+
// Panel B
|
|
1734
|
+
html += `<div class="layoutdet-panel"><div class="layoutdet-panel-header"><span>${esc(pipelineBName)} (${metricB})</span>`;
|
|
1735
|
+
html += `<button class="bbox-btn active" onclick="toggleLayoutBbox(${index}, 'B', this)">Bboxes</button>`;
|
|
1736
|
+
html += `</div><div class="layoutdet-panel-body"><img id="ld-img-b-${index}" src="${imgPath}" onload="drawLayoutPanel(${index}, 'B')" /><canvas id="ld-canvas-b-${index}" class="layoutdet-canvas"></canvas></div></div>`;
|
|
1737
|
+
|
|
1738
|
+
// GT Panel
|
|
1739
|
+
html += `<div class="layoutdet-panel"><div class="layoutdet-panel-header"><span>Ground Truth</span>`;
|
|
1740
|
+
html += `<span style="display:flex;gap:4px;">`;
|
|
1741
|
+
html += `<button class="bbox-btn active" onclick="toggleLayoutOverlay(${index}, 'GT', this)">GT</button>`;
|
|
1742
|
+
html += `<button class="bbox-btn active" onclick="toggleLayoutOverlay(${index}, 'A', this)">A</button>`;
|
|
1743
|
+
html += `<button class="bbox-btn active" onclick="toggleLayoutOverlay(${index}, 'B', this)">B</button>`;
|
|
1744
|
+
html += `</span></div><div class="layoutdet-panel-body"><img id="ld-img-gt-${index}" src="${imgPath}" onload="drawLayoutOverlay(${index})" /><canvas id="ld-canvas-gt-${index}" class="layoutdet-canvas"></canvas></div></div>`;
|
|
1745
|
+
|
|
1746
|
+
html += `</div>`;
|
|
1747
|
+
|
|
1748
|
+
// Legend
|
|
1749
|
+
html += `<div class="comparison-legend">`;
|
|
1750
|
+
html += `<div class="legend-item"><div class="legend-swatch" style="background:#4CAF50;border:2px dashed #4CAF50;"></div><span>Ground Truth</span></div>`;
|
|
1751
|
+
html += `<div class="legend-item"><div class="legend-swatch" style="background:#2196F3;"></div><span>${esc(pipelineAName)}</span></div>`;
|
|
1752
|
+
html += `<div class="legend-item"><div class="legend-swatch" style="background:#9C27B0;border:2px dashed #9C27B0;"></div><span>${esc(pipelineBName)}</span></div>`;
|
|
1753
|
+
html += `</div>`;
|
|
1754
|
+
|
|
1755
|
+
return html;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function drawLayoutPanel(index, which) {
|
|
1759
|
+
const canvas = document.getElementById(`ld-canvas-${which.toLowerCase()}-${index}`);
|
|
1760
|
+
const img = document.getElementById(`ld-img-${which.toLowerCase()}-${index}`);
|
|
1761
|
+
const preds = window[`pred${which}_${index}`];
|
|
1762
|
+
const state = window[`bboxState_${index}`];
|
|
1763
|
+
if (!canvas || !img) return;
|
|
1764
|
+
const ctx = canvas.getContext('2d');
|
|
1765
|
+
const scale = img.clientWidth / img.naturalWidth;
|
|
1766
|
+
canvas.width = img.clientWidth;
|
|
1767
|
+
canvas.height = img.clientHeight;
|
|
1768
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1769
|
+
if (!state[`show${which}`] || !preds) return;
|
|
1770
|
+
preds.forEach(p => {
|
|
1771
|
+
const bbox = p.bbox.map(v => v * scale);
|
|
1772
|
+
const color = LAYOUTDET_COLORS[p.class] || '#999';
|
|
1773
|
+
ctx.fillStyle = color + '1a';
|
|
1774
|
+
ctx.fillRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
|
|
1775
|
+
ctx.strokeStyle = color;
|
|
1776
|
+
ctx.lineWidth = 2;
|
|
1777
|
+
ctx.strokeRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
|
|
1778
|
+
const label = p.score != null ? `${p.class} (${(p.score * 100).toFixed(0)}%)` : p.class;
|
|
1779
|
+
ctx.font = '10px Arial';
|
|
1780
|
+
const tw = ctx.measureText(label).width;
|
|
1781
|
+
ctx.fillStyle = color;
|
|
1782
|
+
ctx.fillRect(bbox[0], bbox[1] - 13, tw + 4, 13);
|
|
1783
|
+
ctx.fillStyle = '#fff';
|
|
1784
|
+
ctx.fillText(label, bbox[0] + 2, bbox[1] - 2);
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function drawLayoutOverlay(index) {
|
|
1789
|
+
const canvas = document.getElementById(`ld-canvas-gt-${index}`);
|
|
1790
|
+
const img = document.getElementById(`ld-img-gt-${index}`);
|
|
1791
|
+
if (!canvas || !img) return;
|
|
1792
|
+
const ctx = canvas.getContext('2d');
|
|
1793
|
+
const scale = img.clientWidth / img.naturalWidth;
|
|
1794
|
+
canvas.width = img.clientWidth;
|
|
1795
|
+
canvas.height = img.clientHeight;
|
|
1796
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1797
|
+
const state = window[`bboxState_${index}`];
|
|
1798
|
+
|
|
1799
|
+
// GT (dashed green)
|
|
1800
|
+
const gt = window[`gt_${index}`] || [];
|
|
1801
|
+
if (state.showGT && gt.length) {
|
|
1802
|
+
ctx.setLineDash([5, 3]);
|
|
1803
|
+
gt.forEach(item => {
|
|
1804
|
+
const bbox = [item.bbox[0], item.bbox[1], item.bbox[0] + item.bbox[2], item.bbox[1] + item.bbox[3]].map(v => v * scale);
|
|
1805
|
+
ctx.strokeStyle = '#4CAF50';
|
|
1806
|
+
ctx.lineWidth = 2;
|
|
1807
|
+
ctx.strokeRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
// Pred A (solid blue)
|
|
1811
|
+
const predA = window[`predA_${index}`] || [];
|
|
1812
|
+
if (state.showA && predA.length) {
|
|
1813
|
+
ctx.setLineDash([]);
|
|
1814
|
+
predA.forEach(item => {
|
|
1815
|
+
const bbox = item.bbox.map(v => v * scale);
|
|
1816
|
+
ctx.strokeStyle = '#2196F3';
|
|
1817
|
+
ctx.lineWidth = 2;
|
|
1818
|
+
ctx.strokeRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
// Pred B (dashed purple)
|
|
1822
|
+
const predB = window[`predB_${index}`] || [];
|
|
1823
|
+
if (state.showB && predB.length) {
|
|
1824
|
+
ctx.setLineDash([3, 2]);
|
|
1825
|
+
predB.forEach(item => {
|
|
1826
|
+
const bbox = item.bbox.map(v => v * scale);
|
|
1827
|
+
ctx.strokeStyle = '#9C27B0';
|
|
1828
|
+
ctx.lineWidth = 2;
|
|
1829
|
+
ctx.strokeRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
ctx.setLineDash([]);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function toggleLayoutBbox(index, which, btn) {
|
|
1836
|
+
const state = window[`bboxState_${index}`];
|
|
1837
|
+
state[`show${which}`] = !state[`show${which}`];
|
|
1838
|
+
btn.classList.toggle('active', state[`show${which}`]);
|
|
1839
|
+
drawLayoutPanel(index, which);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function toggleLayoutOverlay(index, which, btn) {
|
|
1843
|
+
const state = window[`bboxState_${index}`];
|
|
1844
|
+
state[`show${which}`] = !state[`show${which}`];
|
|
1845
|
+
btn.classList.toggle('active', state[`show${which}`]);
|
|
1846
|
+
drawLayoutOverlay(index);
|
|
1847
|
+
// Also redraw individual panels if A or B toggled
|
|
1848
|
+
if (which === 'A' || which === 'B') {
|
|
1849
|
+
drawLayoutPanel(index, which);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// ---- Diff computation (simple line diff) ----
|
|
1854
|
+
function computeLineDiff(textA, textB) {
|
|
1855
|
+
if (!textA && !textB) return '<div class="diff-line diff-ctx">(both empty)</div>';
|
|
1856
|
+
const linesA = (textA || '').split('\\n');
|
|
1857
|
+
const linesB = (textB || '').split('\\n');
|
|
1858
|
+
|
|
1859
|
+
// Simple LCS-based diff
|
|
1860
|
+
const m = linesA.length, n = linesB.length;
|
|
1861
|
+
|
|
1862
|
+
// For very large files, fall back to simple comparison
|
|
1863
|
+
if (m + n > 4000) {
|
|
1864
|
+
let html = '';
|
|
1865
|
+
const maxLen = Math.max(m, n);
|
|
1866
|
+
for (let i = 0; i < maxLen; i++) {
|
|
1867
|
+
const a = i < m ? linesA[i] : undefined;
|
|
1868
|
+
const b = i < n ? linesB[i] : undefined;
|
|
1869
|
+
if (a === b) {
|
|
1870
|
+
html += `<div class="diff-line diff-ctx"> ${esc(a)}</div>`;
|
|
1871
|
+
} else {
|
|
1872
|
+
if (a !== undefined) html += `<div class="diff-line diff-del">-${esc(a)}</div>`;
|
|
1873
|
+
if (b !== undefined) html += `<div class="diff-line diff-add">+${esc(b)}</div>`;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return html;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Build LCS table
|
|
1880
|
+
const dp = Array.from({length: m + 1}, () => new Uint16Array(n + 1));
|
|
1881
|
+
for (let i = 1; i <= m; i++) {
|
|
1882
|
+
for (let j = 1; j <= n; j++) {
|
|
1883
|
+
dp[i][j] = linesA[i-1] === linesB[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Backtrack
|
|
1888
|
+
const ops = [];
|
|
1889
|
+
let i = m, j = n;
|
|
1890
|
+
while (i > 0 || j > 0) {
|
|
1891
|
+
if (i > 0 && j > 0 && linesA[i-1] === linesB[j-1]) {
|
|
1892
|
+
ops.push({type: 'ctx', line: linesA[i-1]});
|
|
1893
|
+
i--; j--;
|
|
1894
|
+
} else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
|
|
1895
|
+
ops.push({type: 'add', line: linesB[j-1]});
|
|
1896
|
+
j--;
|
|
1897
|
+
} else {
|
|
1898
|
+
ops.push({type: 'del', line: linesA[i-1]});
|
|
1899
|
+
i--;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
ops.reverse();
|
|
1903
|
+
|
|
1904
|
+
// Render with context collapsing
|
|
1905
|
+
let html = '';
|
|
1906
|
+
let ctxCount = 0;
|
|
1907
|
+
const CTX_LIMIT = 3;
|
|
1908
|
+
|
|
1909
|
+
ops.forEach((op, idx) => {
|
|
1910
|
+
if (op.type === 'ctx') {
|
|
1911
|
+
// Show context lines near changes
|
|
1912
|
+
const nearChange = ops.slice(Math.max(0, idx - CTX_LIMIT), idx).some(o => o.type !== 'ctx')
|
|
1913
|
+
|| ops.slice(idx + 1, idx + CTX_LIMIT + 1).some(o => o.type !== 'ctx');
|
|
1914
|
+
if (nearChange) {
|
|
1915
|
+
if (ctxCount > 0) {
|
|
1916
|
+
html += `<div class="diff-line diff-hunk">@@ ${ctxCount} unchanged lines @@</div>`;
|
|
1917
|
+
ctxCount = 0;
|
|
1918
|
+
}
|
|
1919
|
+
html += `<div class="diff-line diff-ctx"> ${esc(op.line)}</div>`;
|
|
1920
|
+
} else {
|
|
1921
|
+
ctxCount++;
|
|
1922
|
+
}
|
|
1923
|
+
} else {
|
|
1924
|
+
if (ctxCount > 0) {
|
|
1925
|
+
html += `<div class="diff-line diff-hunk">@@ ${ctxCount} unchanged lines @@</div>`;
|
|
1926
|
+
ctxCount = 0;
|
|
1927
|
+
}
|
|
1928
|
+
const cls = op.type === 'add' ? 'diff-add' : 'diff-del';
|
|
1929
|
+
const prefix = op.type === 'add' ? '+' : '-';
|
|
1930
|
+
html += `<div class="diff-line ${cls}">${prefix}${esc(op.line)}</div>`;
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
if (ctxCount > 0) {
|
|
1935
|
+
html += `<div class="diff-line diff-hunk">@@ ${ctxCount} unchanged lines @@</div>`;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
return html || '<div class="diff-line diff-ctx">(no differences)</div>';
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// ---- Sync scroll ----
|
|
1942
|
+
function setupSyncScroll(index, mode) {
|
|
1943
|
+
const container = document.getElementById(`parse-${mode}-${index}`);
|
|
1944
|
+
if (!container) return;
|
|
1945
|
+
const panels = container.querySelectorAll('.output-panel-body');
|
|
1946
|
+
if (panels.length < 2) return;
|
|
1947
|
+
|
|
1948
|
+
let syncing = false;
|
|
1949
|
+
panels.forEach((pane, i) => {
|
|
1950
|
+
pane.addEventListener('scroll', () => {
|
|
1951
|
+
if (syncing) return;
|
|
1952
|
+
syncing = true;
|
|
1953
|
+
const other = panels[1 - i];
|
|
1954
|
+
const pct = pane.scrollTop / (pane.scrollHeight - pane.clientHeight || 1);
|
|
1955
|
+
other.scrollTop = pct * (other.scrollHeight - other.clientHeight);
|
|
1956
|
+
setTimeout(() => { syncing = false; }, 10);
|
|
1957
|
+
});
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// ---- Detail interactions initialization ----
|
|
1962
|
+
function initDetailInteractions(index) {
|
|
1963
|
+
// Setup sync scroll for rendered view
|
|
1964
|
+
if (productType === 'parse') {
|
|
1965
|
+
setTimeout(() => setupSyncScroll(index, 'rendered'), 100);
|
|
1966
|
+
}
|
|
1967
|
+
// Render any pending PDF viewers
|
|
1968
|
+
const detail = document.getElementById('detail-' + index);
|
|
1969
|
+
if (detail) {
|
|
1970
|
+
detail.querySelectorAll('[data-pdf-pending]').forEach(el => {
|
|
1971
|
+
el.removeAttribute('data-pdf-pending');
|
|
1972
|
+
renderEmbeddedPdf(el.id);
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// ---- Utilities ----
|
|
1978
|
+
function metricColorClass(v) {
|
|
1979
|
+
if (v === null || v === undefined) return 'metric-na';
|
|
1980
|
+
if (v >= 0.9) return 'metric-high';
|
|
1981
|
+
if (v >= 0.7) return 'metric-mid';
|
|
1982
|
+
if (v >= 0.5) return 'metric-low';
|
|
1983
|
+
return 'metric-bad';
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function esc(str) {
|
|
1987
|
+
if (str === null || str === undefined) return '';
|
|
1988
|
+
const div = document.createElement('div');
|
|
1989
|
+
div.textContent = String(str);
|
|
1990
|
+
return div.innerHTML;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
"""
|
|
1994
|
+
+ TOOLTIP_JS
|
|
1995
|
+
+ """
|
|
1996
|
+
|
|
1997
|
+
// ---- PDF.js rendering ----
|
|
1998
|
+
const pdfJsCdnList = [
|
|
1999
|
+
{ src: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js',
|
|
2000
|
+
worker: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js' },
|
|
2001
|
+
{ src: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.min.js',
|
|
2002
|
+
worker: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.worker.min.js' },
|
|
2003
|
+
];
|
|
2004
|
+
let pdfJsLoadPromise = null;
|
|
2005
|
+
|
|
2006
|
+
function ensurePdfJsLoaded() {
|
|
2007
|
+
if (window.pdfjsLib) return Promise.resolve(true);
|
|
2008
|
+
if (pdfJsLoadPromise) return pdfJsLoadPromise;
|
|
2009
|
+
pdfJsLoadPromise = new Promise(resolve => {
|
|
2010
|
+
let idx = 0;
|
|
2011
|
+
const tryLoad = () => {
|
|
2012
|
+
if (idx >= pdfJsCdnList.length) { resolve(false); return; }
|
|
2013
|
+
const { src, worker } = pdfJsCdnList[idx++];
|
|
2014
|
+
const s = document.createElement('script');
|
|
2015
|
+
s.src = src; s.async = true;
|
|
2016
|
+
s.onload = () => {
|
|
2017
|
+
if (window.pdfjsLib) {
|
|
2018
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = worker;
|
|
2019
|
+
resolve(true);
|
|
2020
|
+
} else tryLoad();
|
|
2021
|
+
};
|
|
2022
|
+
s.onerror = tryLoad;
|
|
2023
|
+
document.head.appendChild(s);
|
|
2024
|
+
};
|
|
2025
|
+
tryLoad();
|
|
2026
|
+
});
|
|
2027
|
+
return pdfJsLoadPromise;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
async function renderEmbeddedPdf(viewerId) {
|
|
2031
|
+
const viewer = document.getElementById(viewerId);
|
|
2032
|
+
if (!viewer) return;
|
|
2033
|
+
const pdfSrc = viewer.getAttribute('data-pdf-src');
|
|
2034
|
+
if (!pdfSrc) { viewer.innerHTML = '<p class="na">No PDF data</p>'; return; }
|
|
2035
|
+
|
|
2036
|
+
const ready = await ensurePdfJsLoaded();
|
|
2037
|
+
if (!ready) { viewer.innerHTML = '<p class="na">Failed to load PDF.js</p>'; return; }
|
|
2038
|
+
|
|
2039
|
+
try {
|
|
2040
|
+
const loadingTask = pdfjsLib.getDocument(pdfSrc);
|
|
2041
|
+
const pdfDoc = await loadingTask.promise;
|
|
2042
|
+
viewer.innerHTML = '';
|
|
2043
|
+
|
|
2044
|
+
for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
|
|
2045
|
+
const page = await pdfDoc.getPage(pageNum);
|
|
2046
|
+
const containerWidth = viewer.clientWidth - 16;
|
|
2047
|
+
const unscaledViewport = page.getViewport({ scale: 1 });
|
|
2048
|
+
const scale = containerWidth / unscaledViewport.width;
|
|
2049
|
+
const viewport = page.getViewport({ scale });
|
|
2050
|
+
|
|
2051
|
+
const canvas = document.createElement('canvas');
|
|
2052
|
+
canvas.width = viewport.width;
|
|
2053
|
+
canvas.height = viewport.height;
|
|
2054
|
+
canvas.style.display = 'block';
|
|
2055
|
+
canvas.style.marginBottom = '8px';
|
|
2056
|
+
canvas.style.borderRadius = '4px';
|
|
2057
|
+
viewer.appendChild(canvas);
|
|
2058
|
+
|
|
2059
|
+
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
|
|
2060
|
+
}
|
|
2061
|
+
} catch (e) {
|
|
2062
|
+
viewer.innerHTML = `<p class="na">Failed to render PDF: ${esc(e.message || '')}</p>`;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
</script>"""
|
|
2066
|
+
)
|