sidereon_core/observation_qc/
report_html.rs1use std::fmt::Write as _;
2
3use crate::format::fmtnum::fixed_decimals;
4
5use super::report_text::{format_epoch_time, format_vec3_m, severity_label, system_rows};
6use super::{IntervalSource, ObservationQcHeader, ObservationQcReport};
7
8pub fn render_html(report: &ObservationQcReport) -> String {
9 let mut out = String::new();
10 out.push_str(
11 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>RINEX Observation QC</title>",
12 );
13 out.push_str("<style>");
14 out.push_str(
15 "body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:24px;color:#1f2933;background:#f7f9fb}\
16 h1{font-size:22px;margin:0 0 18px}h2{font-size:16px;margin:22px 0 10px}\
17 table{border-collapse:collapse;width:100%;background:#fff}th,td{border:1px solid #d8dee6;padding:6px 8px;text-align:right}\
18 th{background:#e9eef4;font-weight:600}td:first-child,td:nth-child(2),th:first-child,th:nth-child(2),.text{text-align:left}\
19 dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;background:#fff;border:1px solid #d8dee6;padding:12px}\
20 dt{font-weight:600;color:#52616f}dd{margin:0}.section{max-width:1180px}",
21 );
22 out.push_str("</style></head><body><main class=\"section\">");
23 out.push_str("<h1>RINEX Observation QC +qc Summary</h1>");
24 write_header(&mut out, report);
25 write_system_table(&mut out, report);
26 write_findings(&mut out, report);
27 out.push_str("</main></body></html>");
28 out
29}
30
31fn write_header(out: &mut String, report: &ObservationQcReport) {
32 out.push_str("<h2>Header</h2><dl>");
33 let header = &report.header;
34 push_detail(out, "Marker name", header.marker_name.as_deref());
35 push_detail(out, "Marker number", header.marker_number.as_deref());
36 push_detail(out, "Marker type", header.marker_type.as_deref());
37 push_detail(out, "Receiver", format_receiver(header).as_deref());
38 push_detail(out, "Antenna", format_antenna(header).as_deref());
39 push_detail(
40 out,
41 "Position XYZ m",
42 header.approx_position_m.map(format_vec3_m).as_deref(),
43 );
44 push_detail(
45 out,
46 "Antenna HEN m",
47 header.antenna_delta_hen_m.map(format_vec3_m).as_deref(),
48 );
49 push_detail(
50 out,
51 "Time first",
52 header
53 .time_of_first_obs
54 .as_ref()
55 .map(format_epoch_time)
56 .as_deref(),
57 );
58 push_detail(
59 out,
60 "Time last",
61 header
62 .time_of_last_obs
63 .as_ref()
64 .map(format_epoch_time)
65 .as_deref(),
66 );
67 push_detail(out, "Interval s", format_interval(report).as_deref());
68 push_detail(
69 out,
70 "Duration s",
71 header
72 .duration_s
73 .map(|value| fixed_decimals(value, 1))
74 .as_deref(),
75 );
76 out.push_str("</dl>");
77}
78
79fn write_system_table(out: &mut String, report: &ObservationQcReport) {
80 out.push_str("<h2>Per-Constellation</h2><table><thead><tr>");
81 for heading in [
82 "Sys",
83 "Name",
84 "Sats",
85 "Epochs",
86 "Obs",
87 "Expect",
88 "Comp",
89 "SNR mean/min by band",
90 "MP1 RMS",
91 "MP2 RMS",
92 "Slips",
93 "Gaps",
94 "Gap s",
95 ] {
96 let _ = write!(out, "<th>{}</th>", escape_html(heading));
97 }
98 out.push_str("</tr></thead><tbody>");
99 let rows = system_rows(report);
100 if rows.is_empty() {
101 out.push_str("<tr><td class=\"text\" colspan=\"13\">None</td></tr>");
102 } else {
103 for row in rows {
104 out.push_str("<tr>");
105 cell(out, &row.system_letter.to_string(), true);
106 cell(out, row.system_name, true);
107 cell(out, &row.satellites_seen.to_string(), false);
108 cell(out, &row.epochs.to_string(), false);
109 cell(out, &row.observations.to_string(), false);
110 cell(out, &row.expected.to_string(), false);
111 cell(out, &row.completeness, false);
112 cell(out, &row.snr, true);
113 cell(out, &row.mp1, false);
114 cell(out, &row.mp2, false);
115 cell(out, &row.slips.to_string(), false);
116 cell(out, &row.gaps.to_string(), false);
117 cell(out, &row.gap_s, false);
118 out.push_str("</tr>");
119 }
120 }
121 out.push_str("</tbody></table>");
122}
123
124fn write_findings(out: &mut String, report: &ObservationQcReport) {
125 out.push_str("<h2>Findings</h2><table><thead><tr>");
126 for heading in ["Code", "Severity", "Spec ref"] {
127 let _ = write!(out, "<th>{}</th>", escape_html(heading));
128 }
129 out.push_str("</tr></thead><tbody>");
130 if report.lint_findings.is_empty() {
131 out.push_str("<tr><td class=\"text\" colspan=\"3\">None</td></tr>");
132 } else {
133 for finding in &report.lint_findings {
134 out.push_str("<tr>");
135 cell(out, &finding.code, true);
136 cell(out, severity_label(finding.severity), true);
137 cell(out, &finding.spec_ref, true);
138 out.push_str("</tr>");
139 }
140 }
141 out.push_str("</tbody></table>");
142}
143
144fn push_detail(out: &mut String, label: &str, value: Option<&str>) {
145 if let Some(value) = value.filter(|value| !value.is_empty()) {
146 let _ = write!(
147 out,
148 "<dt>{}</dt><dd>{}</dd>",
149 escape_html(label),
150 escape_html(value)
151 );
152 }
153}
154
155fn cell(out: &mut String, value: &str, text: bool) {
156 if text {
157 let _ = write!(out, "<td class=\"text\">{}</td>", escape_html(value));
158 } else {
159 let _ = write!(out, "<td>{}</td>", escape_html(value));
160 }
161}
162
163fn format_receiver(header: &ObservationQcHeader) -> Option<String> {
164 header.receiver.as_ref().and_then(|receiver| {
165 join_non_empty([
166 receiver.number.as_str(),
167 receiver.receiver_type.as_str(),
168 receiver.version.as_str(),
169 ])
170 })
171}
172
173fn format_antenna(header: &ObservationQcHeader) -> Option<String> {
174 header.antenna.as_ref().and_then(|antenna| {
175 join_non_empty([antenna.number.as_str(), antenna.antenna_type.as_str()])
176 })
177}
178
179fn join_non_empty<const N: usize>(parts: [&str; N]) -> Option<String> {
180 let joined = parts
181 .into_iter()
182 .filter(|part| !part.is_empty())
183 .collect::<Vec<_>>()
184 .join(" / ");
185 (!joined.is_empty()).then_some(joined)
186}
187
188fn format_interval(report: &ObservationQcReport) -> Option<String> {
189 report.interval_s.map(|interval_s| {
190 format!(
191 "{} ({})",
192 fixed_decimals(interval_s, 3),
193 interval_source_label(report.interval_source)
194 )
195 })
196}
197
198fn interval_source_label(source: IntervalSource) -> &'static str {
199 match source {
200 IntervalSource::Override => "override",
201 IntervalSource::Header => "header",
202 IntervalSource::Inferred => "inferred",
203 IntervalSource::Unresolved => "unresolved",
204 }
205}
206
207fn escape_html(value: &str) -> String {
208 let mut escaped = String::with_capacity(value.len());
209 for ch in value.chars() {
210 match ch {
211 '&' => escaped.push_str("&"),
212 '<' => escaped.push_str("<"),
213 '>' => escaped.push_str(">"),
214 '"' => escaped.push_str("""),
215 '\'' => escaped.push_str("'"),
216 _ => escaped.push(ch),
217 }
218 }
219 escaped
220}