herringbone 0.1.0 → 0.2.0

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.
@@ -0,0 +1,1096 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Herringbone
6
+ # Renders a Parquet file's layout as one self-contained HTML page: a to-scale byte map of the
7
+ # file (row groups, column chunks, dictionary and data pages, page indexes, bloom filters,
8
+ # footer), the schema, per-column and per-page tables, page indexes and key/value metadata.
9
+ # CSS, JS and data are inline; the only external resource is highlight.js from cdnjs, used to
10
+ # colour JSON (the page works without it).
11
+ #
12
+ # The design and idea come from Parquet X-ray by cfahlgren1
13
+ # (https://huggingface.co/spaces/cfahlgren1/parquet-xray), credited at the top of every page.
14
+ #
15
+ # Used through Inspector#to_html:
16
+ #
17
+ # File.open("data.parquet", "rb") { |io| Herringbone::Inspector.new(io).to_html }
18
+ class Visualizer
19
+ HIGHLIGHT_JS = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
20
+ # The design and idea of this page come from Parquet X-ray; credited at the top of every page
21
+ CREDIT_URL = "https://huggingface.co/spaces/cfahlgren1/parquet-xray"
22
+
23
+ # Beyond this many pages in the file, per-page detail is kept only for the first chunks
24
+ # (the rest are drawn as whole column chunks) to keep the page small
25
+ MAX_PAGES = 60_000
26
+ # Footer JSON is embedded only when the footer is smaller than this
27
+ MAX_FOOTER_JSON = 512 * 1024
28
+
29
+ PAGE_TYPES = { DATA_PAGE: 0, INDEX_PAGE: 1, DICTIONARY_PAGE: 2, DATA_PAGE_V2: 3 }.freeze
30
+
31
+ # +inspector+ is an Inspector. +title+ defaults to the file's name.
32
+ def initialize(inspector, title: nil, max_pages: MAX_PAGES)
33
+ raise ArgumentError, "Expected a Herringbone::Inspector, got #{inspector.class}" unless inspector.is_a?(Inspector)
34
+ @inspector = inspector
35
+ @title = title
36
+ @max_pages = max_pages
37
+ end
38
+
39
+ def to_html
40
+ json = JSON.generate(payload).gsub("<", "\\u003c").gsub("\u2028", "\\u2028").gsub("\u2029", "\\u2029")
41
+ name = @title || @inspector.name || "Parquet file"
42
+ values = { "TITLE" => escape_html("#{name} · Parquet layout"), "HIGHLIGHT_JS" => HIGHLIGHT_JS,
43
+ "CREDIT_URL" => CREDIT_URL, "DATA" => json }
44
+ # One pass, so placeholder-like text in the data is never substituted
45
+ TEMPLATE.gsub(/%%(TITLE|HIGHLIGHT_JS|CREDIT_URL|DATA)%%/) { values.fetch(Regexp.last_match(1)) }
46
+ end
47
+
48
+ private
49
+
50
+ # The data embedded in the page
51
+ def payload
52
+ i = @inspector
53
+ budget = @max_pages
54
+ totals = i.column_totals.to_h { |t| [t[:column], t] }
55
+ {
56
+ generator: "herringbone #{VERSION}",
57
+ file: file_info,
58
+ schema: i.schema_tree,
59
+ columns: i.columns.map { |c| column_info(c, totals[c.index]) },
60
+ row_groups: i.row_groups.map do |rg|
61
+ {
62
+ i: rg.index, rows: rg.num_rows, first_row: rg.first_row, start: rg.start_offset, end: rg.end_offset,
63
+ cs: rg.compressed_size, us: rg.uncompressed_size, total_byte_size: rg.total_byte_size,
64
+ file_offset: rg.row_group.file_offset, ordinal: rg.row_group.ordinal, sorting: rg.sorting_columns,
65
+ chunks: rg.columns.map do |c|
66
+ keep = c.pages.size <= budget
67
+ budget -= c.pages.size if keep
68
+ chunk_info(c, keep)
69
+ end
70
+ }
71
+ end,
72
+ kv: i.key_value_metadata,
73
+ arrow_error: i.arrow_schema_error,
74
+ footer_json: footer_json
75
+ }
76
+ end
77
+
78
+ def file_info
79
+ s = @inspector.summary
80
+ name = @title || @inspector.name || "(IO)"
81
+ s.merge(name: name, pages_truncated: @inspector.column_chunks.sum { |c| c.pages.size } > @max_pages,
82
+ index_mismatches: @inspector.column_chunks.sum { |c| c.index_mismatches.size })
83
+ end
84
+
85
+ def column_info(col, t)
86
+ node = col.node
87
+ {
88
+ i: col.index, path: col.dotted_path, name: node.name, type: Inspector.type_name(col),
89
+ physical: Format::Type::NAMES[node.type].to_s, logical: Inspector.logical_type_name(node),
90
+ repetition: node.repetition, max_def: col.max_definition_level, max_rep: col.max_repetition_level,
91
+ sort_order: Inspector.sort_order(col),
92
+ codecs: t[:codecs], encodings: t[:encodings], cs: t[:compressed_size], us: t[:uncompressed_size],
93
+ values: t[:num_values], nulls: t[:null_count], pages: t[:num_pages], data_pages: t[:num_data_pages],
94
+ dict_pages: t[:dictionary_pages], dict_bytes: t[:dictionary_bytes],
95
+ min: disp(t[:min]), max: disp(t[:max])
96
+ }
97
+ end
98
+
99
+ def chunk_info(c, with_pages)
100
+ st = c.statistics
101
+ h = {
102
+ c: c.column.index, codec: c.codec.to_s, enc: c.encodings, estats: c.encoding_stats,
103
+ values: c.num_values, nulls: c.null_count, cs: c.compressed_size, us: c.uncompressed_size,
104
+ start: c.start_offset, end: c.end_offset, declared_end: c.declared_end_offset,
105
+ data_off: c.data_page_offset, dict_off: c.dictionary_page_offset, file_offset: c.chunk.file_offset,
106
+ stats: st && stats_info(st),
107
+ bloom: c.bloom_filter_offset && [c.bloom_filter_offset, c.bloom_filter_length],
108
+ ci: c.column_index_range, oi: c.offset_index_range,
109
+ dict: c.dictionary_page && { n: c.dictionary_page.num_values, cs: c.dictionary_page.compressed_size,
110
+ us: c.dictionary_page.uncompressed_size, sorted: c.dictionary_page.is_sorted },
111
+ np: c.pages.size, ndp: c.data_pages.size,
112
+ size_stats: c.size_statistics,
113
+ kv: c.key_value_metadata.empty? ? nil : c.key_value_metadata,
114
+ err: c.error,
115
+ # [page, field, value in page header, value in column index]; page is nil for :page_count
116
+ idx_mm: c.index_mismatches.empty? ? nil : c.index_mismatches.map { |m| mismatch_row(m) },
117
+ crc_bad: c.pages.count { |p| p.checksum == :mismatch }.then { |n| n.zero? ? nil : n }
118
+ }
119
+ if with_pages
120
+ h[:pages] = c.pages.map { |p| page_row(p) }
121
+ if (ci = c.column_index)
122
+ h[:column_index] = {
123
+ boundary: ci.boundary_order,
124
+ rows: ci.null_pages.each_index.map do |k|
125
+ [ci.null_pages[k] ? 1 : 0, disp(ci.min_values[k]), disp(ci.max_values[k]), ci.null_counts&.[](k)]
126
+ end
127
+ }
128
+ end
129
+ if (oi = c.offset_index)
130
+ h[:offset_index] = oi.page_locations.map { |l| [l.offset, l.compressed_page_size, l.first_row_index] }
131
+ end
132
+ end
133
+ Inspector.jsonable(h.compact)
134
+ end
135
+
136
+ def stats_info(st)
137
+ {
138
+ min: disp(st.min), max: disp(st.max), nulls: st.null_count, distinct: st.distinct_count,
139
+ min_exact: st.min_exact, max_exact: st.max_exact, source: st.source, caveat: st.caveat
140
+ }.compact
141
+ end
142
+
143
+ # [type, offset, header_size, compressed, uncompressed, values, nulls, rows, first_row, encoding,
144
+ # min, max, crc, extra]; crc is 0 (none), 1 (present, not verified), 2 (verified ok) or 3 (mismatch)
145
+ def page_row(p)
146
+ st = p.statistics
147
+ extra = if p.type == :DATA_PAGE_V2
148
+ "levels #{p.repetition_levels_byte_length}+#{p.definition_levels_byte_length} B" \
149
+ "#{p.is_compressed ? "" : ", not compressed"}"
150
+ elsif p.type == :DATA_PAGE
151
+ [p.definition_level_encoding && "def #{p.definition_level_encoding}",
152
+ p.repetition_level_encoding && "rep #{p.repetition_level_encoding}"].compact.join(", ")
153
+ elsif p.dictionary?
154
+ p.is_sorted ? "sorted" : nil
155
+ end
156
+ [PAGE_TYPES.fetch(p.type, 1), p.offset, p.header_size, p.compressed_size, p.uncompressed_size, p.num_values,
157
+ p.num_nulls, p.num_rows, p.first_row_index, p.encoding, st && disp(st.min), st && disp(st.max),
158
+ CRC_STATES.fetch(p.checksum) { p.crc.nil? ? 0 : 1 }, extra]
159
+ end
160
+
161
+ CRC_STATES = { absent: 0, ok: 2, mismatch: 3 }.freeze
162
+
163
+ def mismatch_row(m)
164
+ values = [m[:page_value], m[:index_value]]
165
+ values = values.map { |v| disp(v) } if m[:field] == :min || m[:field] == :max
166
+ [m[:page], m[:field].to_s, *values]
167
+ end
168
+
169
+ # Display form of a decoded statistics value: strings quoted, the rest as text
170
+ def disp(v)
171
+ s = case v
172
+ when nil then return nil
173
+ when String
174
+ t = Inspector.text(v)
175
+ v.encoding == Encoding::BINARY && t.start_with?("0x") ? t : JSON.generate(t)
176
+ when Float then v.nan? ? "NaN" : v.to_s
177
+ when BigDecimal then v.to_s("F")
178
+ when Time then Inspector.jsonable(v)
179
+ when Date then v.iso8601
180
+ when Array then JSON.generate(Inspector.jsonable(v))
181
+ else v.to_s
182
+ end
183
+ s.size > 160 ? "#{s[0, 159]}…" : s
184
+ end
185
+
186
+ def footer_json
187
+ return nil if @inspector.footer_size > MAX_FOOTER_JSON
188
+ JSON.pretty_generate(Inspector.jsonable(raw(@inspector.metadata.to_h)))
189
+ end
190
+
191
+ # Footer structs as plain data; binary statistics as hex, long strings shortened
192
+ def raw(v)
193
+ case v
194
+ when Hash then v.to_h { |k, x| [k, raw(x)] }
195
+ when Array then v.map { |x| raw(x) }
196
+ when String
197
+ t = Inspector.text(v)
198
+ t.size > 300 ? "#{t[0, 300]}… (#{v.bytesize} bytes)" : t
199
+ else v
200
+ end
201
+ end
202
+
203
+ def escape_html(s)
204
+ s.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
205
+ end
206
+
207
+ TEMPLATE = <<~'HTML'
208
+ <!doctype html>
209
+ <html lang="en">
210
+ <head>
211
+ <meta charset="utf-8">
212
+ <meta name="viewport" content="width=device-width, initial-scale=1">
213
+ <meta name="color-scheme" content="light dark">
214
+ <title>%%TITLE%%</title>
215
+ <style>
216
+ :root {
217
+ --ink: #111827; --secondary: #374151; --subtle: #6b7280; --faint: #9ca3af;
218
+ --line: #e5e7eb; --line-soft: #f1f2f4; --bg: #ffffff; --surface: #ffffff; --surface-alt: #f9fafb;
219
+ --accent: #4f46e5; --accent-soft: #eef2ff; --accent-line: #c7d2fe;
220
+ --ok: #047857; --ok-soft: #ecfdf5; --ok-line: #a7f3d0; --warn: #b45309; --warn-soft: #fffbeb; --err: #b91c1c; --err-soft: #fef2f2;
221
+ --strip-bg: #f3f4f6; --seam: rgba(255,255,255,.85); --outline: #111827;
222
+ --k-dict: #e2e5ea; --k-data: #c3c8d0; --k-ci: #d8b4fe; --k-oi: #93c5fd; --k-bloom: #6ee7b7;
223
+ --k-cmeta: #fcd9a8; --k-footer: #4b5563; --k-flen: #1f2937; --k-magic: #111827; --k-unknown: #fca5a5;
224
+ --kw: #e11d48; --ty: #7c3aed; --ann: #0d9488; --num: #b45309; --str: #047857;
225
+ --shadow: 0 10px 30px -10px rgb(17 24 39 / 25%), 0 2px 6px rgb(17 24 39 / 6%);
226
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
227
+ }
228
+ @media (prefers-color-scheme: dark) {
229
+ :root {
230
+ --ink: #e5e7eb; --secondary: #cbd2dc; --subtle: #9aa3b2; --faint: #6b7483;
231
+ --line: #2a2f38; --line-soft: #20252c; --bg: #0e1116; --surface: #13171d; --surface-alt: #181d24;
232
+ --accent: #8b93ff; --accent-soft: #1d2140; --accent-line: #3b418a;
233
+ --ok: #34d399; --ok-soft: #0d2a22; --ok-line: #1f5d4a; --warn: #fbbf24; --warn-soft: #2a2110; --err: #f87171; --err-soft: #2c1414;
234
+ --strip-bg: #1b2028; --seam: rgba(14,17,22,.8); --outline: #f9fafb;
235
+ --k-dict: #394150; --k-data: #566072; --k-ci: #7e5aa8; --k-oi: #3f6fa8; --k-bloom: #2f8f6c;
236
+ --k-cmeta: #7a5a2e; --k-footer: #9ca3af; --k-flen: #d1d5db; --k-magic: #f3f4f6; --k-unknown: #b45353;
237
+ --kw: #fb7185; --ty: #c4b5fd; --ann: #5eead4; --num: #fbbf24; --str: #6ee7b7;
238
+ --shadow: 0 10px 30px -10px rgb(0 0 0 / 70%), 0 2px 6px rgb(0 0 0 / 40%);
239
+ }
240
+ }
241
+ * { box-sizing: border-box; }
242
+ html { scrollbar-gutter: stable; }
243
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 13px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
244
+ button, input, select { font: inherit; color: inherit; }
245
+ .wrap { max-width: 1320px; margin: 0 auto; padding: 24px 24px 64px; }
246
+ header.top { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
247
+ header.top h1 { margin: 0; font-size: 20px; letter-spacing: -.01em; display: flex; align-items: center; gap: 10px; }
248
+ .logo { width: 18px; height: 18px; border-radius: 3px; background: repeating-linear-gradient(135deg, var(--accent) 0 3px, transparent 3px 6px), var(--accent-soft); outline: 1px solid var(--accent-line); }
249
+ header.top .sub { color: var(--subtle); }
250
+ .credit { flex-basis: 100%; margin: 4px 0 0; padding: 6px 10px; border-radius: 8px; background: var(--accent-soft); border: 1px solid var(--accent-line); color: var(--secondary); font-size: 12.5px; }
251
+ .credit a { color: var(--accent); font-weight: 600; }
252
+ h2 { margin: 0 0 8px; font-size: 14px; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
253
+ h2 small { font-size: 12px; font-weight: 400; color: var(--faint); }
254
+ section { margin-bottom: 22px; }
255
+ .card { border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
256
+ .pad { padding: 14px 16px; }
257
+ .mono { font-family: var(--mono); }
258
+ .faint { color: var(--faint); } .subtle { color: var(--subtle); }
259
+ .num { font-variant-numeric: tabular-nums; }
260
+ .summary header { display: flex; gap: 10px; align-items: center; padding: 10px 16px; background: var(--surface-alt); border-bottom: 1px solid var(--line); border-radius: 12px 12px 0 0; flex-wrap: wrap; }
261
+ .summary header h2 { margin: 0; font-family: var(--mono); font-size: 14px; }
262
+ .stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)) minmax(0, 1.8fr); gap: 12px; padding: 12px 16px; margin: 0; }
263
+ .stats dt { font-size: 11.5px; color: var(--subtle); }
264
+ .stats dd { margin: 2px 0 0; font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
265
+ .stats dd.mono { font-size: 12.5px; font-weight: 500; margin-top: 4px; }
266
+ .badges { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 16px 14px; }
267
+ .badge { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 999px; padding: 3px 11px; font-size: 12.5px; color: var(--subtle); }
268
+ .badge.ok { background: var(--ok-soft); border-color: var(--ok-line); color: var(--ok); }
269
+ .badge.warn { background: var(--warn-soft); border-color: var(--warn); color: var(--warn); }
270
+ .badge.err { background: var(--err-soft); border-color: var(--err); color: var(--err); }
271
+ .badge code { font-family: var(--mono); font-size: 12px; }
272
+ .map-grid { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 16px; }
273
+ .strip-label { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--faint); margin-bottom: 4px; }
274
+ .strip-label b { color: var(--secondary); font-weight: 500; }
275
+ .strip { height: 40px; position: relative; }
276
+ .strip canvas { display: block; width: 100%; height: 100%; border-radius: 3px; cursor: crosshair; }
277
+ .strip.pick canvas { cursor: pointer; }
278
+ .zoom { margin-top: 14px; }
279
+ .zoom .strip { height: 30px; }
280
+ .legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; font-size: 11.5px; color: var(--subtle); align-items: center; }
281
+ .legend .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
282
+ .legend .item { cursor: pointer; white-space: nowrap; }
283
+ .legend .item.dim { opacity: .4; }
284
+ .legend .kinds { margin-left: auto; display: inline-flex; flex-wrap: wrap; gap: 4px 13px; color: var(--faint); }
285
+ .hint { margin: 6px 0 0; font-size: 11.5px; color: var(--faint); }
286
+ .two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; align-items: start; }
287
+ .schema { background: var(--surface-alt); border-radius: 8px; outline: 1px solid var(--line); padding: 6px 0; font-family: var(--mono); font-size: 12.5px; }
288
+ .sline { display: grid; grid-template-columns: 30px minmax(0, 1fr) 140px; align-items: center; min-height: 24px; width: 100%; border: 0; background: transparent; text-align: left; padding: 0; }
289
+ button.sline { cursor: pointer; }
290
+ button.sline:hover, .sline.sel { background: var(--accent-soft); }
291
+ .sline.sel { box-shadow: inset 3px 0 0 var(--accent); }
292
+ .sline .ln { text-align: right; padding-right: 10px; color: var(--faint); opacity: .6; }
293
+ .sline .code { white-space: pre; overflow: hidden; text-overflow: ellipsis; padding-right: 12px; }
294
+ .sline .kw { color: var(--kw); } .sline .ty { color: var(--ty); } .sline .ann { color: var(--ann); } .sline .nm { font-weight: 600; }
295
+ .sline .lv { color: var(--faint); font-size: 11px; }
296
+ .sline .size { display: flex; align-items: center; gap: 8px; padding-right: 12px; font-size: 11px; color: var(--faint); }
297
+ .bar { position: relative; flex: 1; height: 4px; border-radius: 1px; background: var(--line-soft); }
298
+ .bar > span { position: absolute; top: 0; bottom: 0; left: 0; min-width: 1px; border-radius: 1px; }
299
+ .sline .size .v { width: 64px; text-align: right; white-space: nowrap; }
300
+ .rgs { overflow: hidden; }
301
+ .rg-row { display: grid; grid-template-columns: 14px 44px minmax(0, 1.1fr) minmax(0, 1fr) 76px; gap: 10px; align-items: center; height: 30px; width: 100%; padding: 0 12px; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; text-align: left; cursor: pointer; }
302
+ .rg-row:hover { background: var(--surface-alt); }
303
+ .rg-row.open { background: var(--accent-soft); }
304
+ .rg-row .caret { font-size: 10px; color: var(--faint); }
305
+ .rg-row .gbar { height: 8px; border-radius: 1px; display: block; }
306
+ .rg-body { padding: 8px 12px 12px 36px; border-bottom: 1px solid var(--line-soft); background: var(--surface-alt); }
307
+ .kvline { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-bottom: 6px; }
308
+ .kvline .k { color: var(--faint); margin-right: 4px; }
309
+ .chunk-row { display: grid; grid-template-columns: 130px minmax(0, 1fr) 70px; grid-template-areas: "name pages size" "name minmax info"; gap: 2px 12px; padding: 6px 4px; border-bottom: 1px solid var(--line-soft); cursor: pointer; border-radius: 4px; }
310
+ .chunk-row:last-child { border-bottom: 0; }
311
+ .chunk-row:hover { background: var(--surface); }
312
+ .chunk-row.sel { background: var(--accent-soft); }
313
+ .chunk-row .name { grid-area: name; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: start; }
314
+ .chunk-row .pages { grid-area: pages; display: flex; gap: 1px; height: 10px; align-items: stretch; }
315
+ .chunk-row .pages > span { min-width: 2px; border-radius: 1px; }
316
+ .chunk-row .size { grid-area: size; text-align: right; font-family: var(--mono); }
317
+ .chunk-row .minmax { grid-area: minmax; font-family: var(--mono); font-size: 11px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
318
+ .chunk-row .info { grid-area: info; text-align: right; font-size: 11px; color: var(--faint); white-space: nowrap; }
319
+ .more { margin: 6px 0 0; border: 0; background: transparent; color: var(--accent); cursor: pointer; padding: 4px; font-size: 12px; }
320
+ .tablewrap { overflow-x: auto; }
321
+ table { border-collapse: collapse; width: 100%; font-size: 12px; }
322
+ th { text-align: left; font-weight: 500; font-size: 11px; color: var(--faint); padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; position: sticky; top: 0; background: var(--surface); }
323
+ td { padding: 5px 8px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
324
+ td.r, th.r { text-align: right; }
325
+ td.mono { font-size: 11.5px; }
326
+ td.clip { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
327
+ tbody tr.click { cursor: pointer; }
328
+ tbody tr.click:hover { background: var(--surface-alt); }
329
+ tbody tr.sel { background: var(--accent-soft); }
330
+ tbody tr.hl { outline: 2px solid var(--accent); outline-offset: -2px; }
331
+ .ratio { display: flex; align-items: center; gap: 6px; min-width: 110px; }
332
+ .ratio .bar { height: 6px; }
333
+ .pill { display: inline-block; padding: 0 6px; border-radius: 4px; background: var(--line-soft); font-family: var(--mono); font-size: 11px; margin: 1px 2px 1px 0; white-space: nowrap; }
334
+ .pill.warn { background: var(--warn-soft); color: var(--warn); }
335
+ .pill.err { color: var(--err); background: var(--err-soft); }
336
+ .pill.ok { color: var(--ok); background: var(--ok-soft); }
337
+ .sline .arrow { color: var(--faint); }
338
+ .sw-inline { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
339
+ .detail .dl { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px 18px; margin: 0 0 12px; }
340
+ .detail .dl dt { font-size: 11px; color: var(--faint); }
341
+ .detail .dl dd { margin: 1px 0 0; font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
342
+ .note { font-size: 12px; padding: 6px 10px; border-radius: 6px; background: var(--warn-soft); color: var(--warn); margin: 6px 0; }
343
+ .note.err { color: var(--err); }
344
+ .tabs { display: flex; gap: 4px; margin: 10px 0 8px; border-bottom: 1px solid var(--line); }
345
+ .tabs button { border: 0; background: transparent; padding: 6px 10px; cursor: pointer; color: var(--subtle); border-bottom: 2px solid transparent; margin-bottom: -1px; }
346
+ .tabs button.on { color: var(--ink); border-bottom-color: var(--accent); }
347
+ .pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; font-size: 12px; color: var(--subtle); }
348
+ .pager button { border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 2px 10px; cursor: pointer; }
349
+ .pager button:disabled { opacity: .4; cursor: default; }
350
+ .kv { border-top: 1px solid var(--line-soft); padding: 10px 0; }
351
+ .kv:first-child { border-top: 0; padding-top: 0; }
352
+ .kv .head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }
353
+ .kv .key { font-family: var(--mono); font-weight: 600; }
354
+ pre { margin: 0; max-height: 420px; overflow: auto; background: var(--surface-alt); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
355
+ pre code { font: inherit; background: none; padding: 0; }
356
+ details > summary { cursor: pointer; color: var(--subtle); margin-bottom: 8px; }
357
+ .hljs-attr { color: var(--ty); } .hljs-string { color: var(--str); } .hljs-number, .hljs-literal { color: var(--num); } .hljs-keyword { color: var(--kw); } .hljs-punctuation { color: var(--faint); }
358
+ #pop { position: fixed; z-index: 10; pointer-events: none; min-width: 240px; max-width: 420px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); padding: 10px 12px; font-size: 12px; display: none; }
359
+ #pop .t { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
360
+ #pop .p { font-family: var(--mono); color: var(--subtle); font-size: 11.5px; }
361
+ #pop .w { color: var(--secondary); margin: 6px 0; }
362
+ #pop dl { display: grid; grid-template-columns: auto 1fr; gap: 1px 12px; margin: 6px 0 0; }
363
+ #pop dt { color: var(--faint); } #pop dd { margin: 0; font-family: var(--mono); overflow-wrap: anywhere; }
364
+ footer.foot { color: var(--faint); font-size: 11.5px; margin-top: 30px; }
365
+ @media (max-width: 900px) {
366
+ .two, .map-grid { grid-template-columns: minmax(0, 1fr); }
367
+ .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
368
+ .stats .wide { grid-column: 1 / -1; }
369
+ }
370
+ @media (max-width: 600px) {
371
+ .wrap { padding: 16px 12px 48px; }
372
+ .sline { grid-template-columns: 24px minmax(0, 1fr) 70px; }
373
+ .sline .size .bar { display: none; }
374
+ .rg-row { grid-template-columns: 14px 36px minmax(0, 1fr) 70px; }
375
+ .rg-row .gcell { display: none; }
376
+ .chunk-row { grid-template-columns: minmax(0, 1fr) 64px; grid-template-areas: "name size" "pages pages" "minmax info"; }
377
+ }
378
+ </style>
379
+ </head>
380
+ <body>
381
+ <div class="wrap">
382
+ <header class="top">
383
+ <h1><span class="logo" aria-hidden="true"></span>Parquet layout</h1>
384
+ <span class="sub">Footer, page headers and page indexes only &middot; no values were decoded</span>
385
+ <p class="credit" id="credit">Design and idea from <a href="%%CREDIT_URL%%" rel="noopener">Parquet X-ray</a> by cfahlgren1 &mdash; this page reimplements its file map, schema and row group views for Herringbone.</p>
386
+ </header>
387
+ <section class="card summary" id="summary"></section>
388
+ <section class="card pad" id="map"></section>
389
+ <div class="two">
390
+ <section id="schema"></section>
391
+ <section id="rowgroups"></section>
392
+ </div>
393
+ <section id="detail"></section>
394
+ <section id="columns"></section>
395
+ <section id="kv"></section>
396
+ <section id="footer"></section>
397
+ <footer class="foot" id="gen"></footer>
398
+ </div>
399
+ <div id="pop" role="tooltip"></div>
400
+ <noscript><p style="padding:24px">This page needs JavaScript to draw the file layout.</p></noscript>
401
+ <script type="application/json" id="hb-data">%%DATA%%</script>
402
+ <script>
403
+ (function () {
404
+ "use strict";
405
+ var D = JSON.parse(document.getElementById("hb-data").textContent);
406
+ var F = D.file;
407
+ var S = { rg: null, col: null, page: null, tab: "pages", pageOffset: 0, allRgs: false, allCols: false, allSchema: false, allChunkRgs: false };
408
+
409
+ // ---------- helpers ----------
410
+ function h(tag, props) {
411
+ var el = document.createElement(tag);
412
+ if (props) for (var k in props) {
413
+ var v = props[k];
414
+ if (v == null || v === false) continue;
415
+ if (k === "class") el.className = v;
416
+ else if (k === "text") el.textContent = v;
417
+ else if (k === "style") el.setAttribute("style", v);
418
+ else if (k.slice(0, 2) === "on") el.addEventListener(k.slice(2), v);
419
+ else el.setAttribute(k, v === true ? "" : v);
420
+ }
421
+ for (var i = 2; i < arguments.length; i++) add(el, arguments[i]);
422
+ return el;
423
+ }
424
+ function add(el, c) {
425
+ if (c == null || c === false) return;
426
+ if (Array.isArray(c)) { c.forEach(function (x) { add(el, x); }); return; }
427
+ el.appendChild(typeof c === "object" ? c : document.createTextNode(String(c)));
428
+ }
429
+ function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); return el; }
430
+ function num(n) { return n == null ? "–" : Number(n).toLocaleString("en-US"); }
431
+ function bytes(n) {
432
+ if (n == null) return "–";
433
+ var u = ["B", "KB", "MB", "GB", "TB"], i = 0, f = Number(n);
434
+ while (f >= 1024 && i < u.length - 1) { f /= 1024; i++; }
435
+ return i === 0 ? f + " B" : f.toFixed(f < 10 ? 2 : 1) + " " + u[i];
436
+ }
437
+ function pct(a, b) { if (!b) return "–"; var p = a / b * 100; return (p < 0.1 && p > 0 ? "<0.1" : p.toFixed(p < 10 ? 1 : 0)) + "%"; }
438
+ function ratio(us, cs) { return cs ? (us / cs).toFixed(2) + "×" : "–"; }
439
+ function plural(n, w) { return num(n) + " " + w + (n === 1 ? "" : "s"); }
440
+ var dark = window.matchMedia && matchMedia("(prefers-color-scheme: dark)");
441
+ function isDark() { return !!(dark && dark.matches); }
442
+ function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
443
+
444
+ // ---------- colours ----------
445
+ var HUES = [215, 28, 150, 330, 262, 45, 180, 0, 95, 300, 195, 60];
446
+ function colColor(c, dict, ignoreSel) {
447
+ var hue = HUES[c % HUES.length], d = isDark();
448
+ if (ignoreSel || S.col == null) return d ? "hsl(" + hue + " 32% " + (dict ? 30 : 46) + "%)" : "hsl(" + hue + " 45% " + (dict ? 87 : 74) + "%)";
449
+ if (c === S.col) return d ? "hsl(" + hue + " 60% " + (dict ? 40 : 60) + "%)" : "hsl(" + hue + " 60% " + (dict ? 80 : 60) + "%)";
450
+ return dict ? (d ? "#22272f" : "#eef0f3") : (d ? "#2c323c" : "#dfe2e6");
451
+ }
452
+ var KINDS = {
453
+ dict: { label: "Dictionary page", v: "--k-dict", what: "Each distinct value stored once; the data pages after it store small integer ids that point here." },
454
+ data: { label: "Data page", v: "--k-data", what: "The unit a reader fetches and decompresses: a page header followed by levels and values." },
455
+ chunk: { label: "Column chunk", v: "--k-data", what: "Pages of this chunk were not listed individually." },
456
+ ci: { label: "Column index", v: "--k-ci", what: "Min, max and null count for every page of one column chunk, so readers can skip pages that can't match a filter." },
457
+ oi: { label: "Offset index", v: "--k-oi", what: "Byte offset and first row of every page of one column chunk, so readers can jump straight to a row." },
458
+ bloom: { label: "Bloom filter", v: "--k-bloom", what: "Answers \"is this value definitely absent?\" so = and IN lookups can skip a whole row group." },
459
+ footer: { label: "Footer", v: "--k-footer", what: "Thrift-encoded FileMetaData: the schema, every row group and column chunk, their statistics and the offsets of everything else." },
460
+ flen: { label: "Footer length", v: "--k-flen", what: "4-byte little-endian length of the footer. Readers fetch the last 8 bytes first to find it." },
461
+ magic: { label: "PAR1 magic", v: "--k-magic", what: "4 bytes that open and close every Parquet file." },
462
+ cmeta: { label: "Column metadata copy", v: "--k-cmeta", what: "A copy of the chunk's ColumnMetaData written right after its pages (ColumnChunk.file_offset points here). Readers use the footer's copy." },
463
+ unknown: { label: "Unaccounted bytes", v: "--k-unknown", what: "Bytes not covered by any page, index or footer (padding, or data the metadata doesn't describe)." }
464
+ };
465
+ function kindColor(k) { return cssVar(KINDS[k].v); }
466
+ function segColor(s) {
467
+ if (s.k === "data" || s.k === "chunk") return colColor(s.c, false);
468
+ if (s.k === "dict") return colColor(s.c, true);
469
+ if ((s.k === "ci" || s.k === "oi" || s.k === "bloom" || s.k === "cmeta") && S.col != null && s.c !== S.col) return isDark() ? "#2c323c" : "#dfe2e6";
470
+ return kindColor(s.k);
471
+ }
472
+
473
+ // ---------- byte segments ----------
474
+ var PT = ["DATA_PAGE", "INDEX_PAGE", "DICTIONARY_PAGE", "DATA_PAGE_V2"];
475
+ var CRC = ["", "present, not verified", "ok", "MISMATCH"];
476
+ var segs = [{ k: "magic", s: 0, e: 4 }];
477
+ D.row_groups.forEach(function (rg) {
478
+ rg.chunks.forEach(function (ch) {
479
+ if (ch.pages && ch.pages.length) {
480
+ ch.pages.forEach(function (p, i) {
481
+ segs.push({ k: p[0] === 2 ? "dict" : "data", s: p[1], e: p[1] + p[2] + p[3], rg: rg.i, c: ch.c, p: i });
482
+ });
483
+ } else if (ch.cs > 0) {
484
+ segs.push({ k: "chunk", s: ch.start, e: ch.end, rg: rg.i, c: ch.c });
485
+ }
486
+ if (ch.bloom && ch.bloom[1]) segs.push({ k: "bloom", s: ch.bloom[0], e: ch.bloom[0] + ch.bloom[1], rg: rg.i, c: ch.c });
487
+ if (ch.ci) segs.push({ k: "ci", s: ch.ci[0], e: ch.ci[0] + ch.ci[1], rg: rg.i, c: ch.c });
488
+ if (ch.oi) segs.push({ k: "oi", s: ch.oi[0], e: ch.oi[0] + ch.oi[1], rg: rg.i, c: ch.c });
489
+ });
490
+ });
491
+ segs.push({ k: "footer", s: F.footer_offset, e: F.footer_offset + F.footer_size });
492
+ segs.push({ k: "flen", s: F.file_size - 8, e: F.file_size - 4 });
493
+ segs.push({ k: "magic", s: F.file_size - 4, e: F.file_size });
494
+ segs.sort(function (a, b) { return a.s - b.s || a.e - b.e; });
495
+ (function () {
496
+ // gaps starting at a chunk's file_offset hold an inline copy of its ColumnMetaData
497
+ var metaAt = {};
498
+ D.row_groups.forEach(function (rg) { rg.chunks.forEach(function (ch) { if (ch.file_offset != null && ch.file_offset >= ch.end) metaAt[ch.file_offset] = { rg: rg.i, c: ch.c }; }); });
499
+ var out = [], pos = 0;
500
+ segs.forEach(function (s) {
501
+ if (s.s > pos) { var m = metaAt[pos]; out.push(m ? { k: "cmeta", s: pos, e: s.s, rg: m.rg, c: m.c } : { k: "unknown", s: pos, e: s.s }); }
502
+ out.push(s); pos = Math.max(pos, s.e);
503
+ });
504
+ segs = out;
505
+ })();
506
+ var dataEnd = 4;
507
+ segs.forEach(function (s) { if (s.k === "data" || s.k === "dict" || s.k === "chunk") dataEnd = Math.max(dataEnd, s.e); });
508
+ var tailStart = F.footer_offset;
509
+ segs.forEach(function (s) { if (s.s >= dataEnd && s.s < tailStart && s.k !== "unknown") tailStart = s.s; });
510
+ var bodySegs = segs.filter(function (s) { return s.s < tailStart; });
511
+ var tailSegs = segs.filter(function (s) { return s.s >= tailStart; });
512
+ function lowerBound(list, off) { var lo = 0, hi = list.length; while (lo < hi) { var m = (lo + hi) >> 1; if (list[m].e <= off) lo = m + 1; else hi = m; } return lo; }
513
+ function segsIn(list, from, to) { var out = []; for (var i = lowerBound(list, from); i < list.length && list[i].s < to; i++) out.push(list[i]); return out; }
514
+
515
+ // ---------- canvas strips ----------
516
+ var strips = [];
517
+ function Strip(host, from, to, list, opts) {
518
+ this.host = host; this.from = from; this.to = Math.max(to, from + 1); this.list = list; this.opts = opts || {};
519
+ this.canvas = h("canvas", { "aria-label": this.opts.label || "byte layout" });
520
+ host.appendChild(this.canvas);
521
+ var self = this;
522
+ this.canvas.addEventListener("pointermove", function (ev) { if (ev.pointerType !== "mouse") return; var s = self.at(ev); self.hover = s; self.paint(); showPop(s, ev); });
523
+ this.canvas.addEventListener("pointerleave", function () { self.hover = null; self.paint(); hidePop(); });
524
+ this.canvas.addEventListener("click", function (ev) { var s = self.at(ev); if (self.opts.onpick) self.opts.onpick(s, ev); else showPop(s, ev); });
525
+ strips.push(this);
526
+ this.render();
527
+ }
528
+ Strip.prototype.at = function (ev) {
529
+ var r = this.canvas.getBoundingClientRect();
530
+ var span = this.to - this.from, off = this.from + (ev.clientX - r.left) / r.width * span, slack = span / r.width * 2;
531
+ var list = this.list, lo = 0, hi = list.length - 1, best = -1;
532
+ while (lo <= hi) { var m = (lo + hi) >> 1; if (list[m].s <= off) { best = m; lo = m + 1; } else hi = m - 1; }
533
+ var hit = list[best];
534
+ if (hit && off <= hit.e + slack) return hit;
535
+ var next = list[best + 1];
536
+ return next && next.s - off < slack ? next : null;
537
+ };
538
+ Strip.prototype.render = function () {
539
+ var w = this.host.clientWidth, hh = this.host.clientHeight;
540
+ if (!w || !hh) return;
541
+ var dpr = window.devicePixelRatio || 1, W = Math.round(w * dpr), H = Math.round(hh * dpr);
542
+ var base = document.createElement("canvas"); base.width = W; base.height = H;
543
+ var ctx = base.getContext("2d"), from = this.from, to = this.to, list = this.list;
544
+ ctx.fillStyle = cssVar("--strip-bg"); ctx.fillRect(0, 0, W, H);
545
+ // each device pixel takes the colour of the segment covering most of its bytes
546
+ var bpp = (to - from) / W, first = 0, runStart = 0, runColor = null;
547
+ for (var px = 0; px < W; px++) {
548
+ var b0 = from + px * bpp, b1 = b0 + bpp, best = null, bestBytes = 0;
549
+ while (first < list.length && list[first].e <= b0) first++;
550
+ for (var i = first; i < list.length && list[i].s < b1; i++) {
551
+ var sg = list[i], cov = Math.min(b1, sg.e) - Math.max(b0, sg.s);
552
+ if (cov > bestBytes) { bestBytes = cov; best = sg; }
553
+ }
554
+ var col = best ? segColor(best) : null;
555
+ if (col !== runColor) { if (runColor) { ctx.fillStyle = runColor; ctx.fillRect(runStart, 0, px - runStart, H); } runStart = px; runColor = col; }
556
+ }
557
+ if (runColor) { ctx.fillStyle = runColor; ctx.fillRect(runStart, 0, W - runStart, H); }
558
+ ctx.fillStyle = cssVar("--seam");
559
+ var x = function (o) { return (o - from) / (to - from) * W; };
560
+ for (var j = 0; j < list.length; j++) { var a = x(list[j].s), b = x(list[j].e); if (b - a > 4 * dpr) ctx.fillRect(a, 0, dpr, H); }
561
+ if (this.opts.targets) {
562
+ ctx.fillStyle = cssVar("--bg");
563
+ this.opts.targets.forEach(function (t) { if (x(t.e) - x(t.s) > 3 * dpr) ctx.fillRect(x(t.s), 0, 1.5 * dpr, H); });
564
+ }
565
+ this.base = base; this.dpr = dpr;
566
+ this.paint();
567
+ };
568
+ Strip.prototype.paint = function () {
569
+ if (!this.base) return;
570
+ var c = this.canvas, dpr = this.dpr, ctx = c.getContext("2d");
571
+ c.width = this.base.width; c.height = this.base.height;
572
+ ctx.drawImage(this.base, 0, 0);
573
+ var from = this.from, to = this.to, W = c.width, H = c.height;
574
+ var x = function (o) { return (o - from) / (to - from) * W; };
575
+ ctx.lineWidth = 2 * dpr;
576
+ var outline = function (s, color) { if (!s) return; ctx.strokeStyle = color; ctx.strokeRect(Math.max(0, x(s.s)) + dpr, dpr, Math.max(Math.min(W, x(s.e)) - Math.max(0, x(s.s)) - 2 * dpr, 2 * dpr), H - 2 * dpr); };
577
+ outline(this.opts.selected && this.opts.selected(), cssVar("--accent"));
578
+ outline(this.hover, cssVar("--outline"));
579
+ };
580
+ function redrawStrips() { strips = strips.filter(function (s) { return s.canvas.isConnected; }); strips.forEach(function (s) { s.render(); }); }
581
+ var rsTimer;
582
+ window.addEventListener("resize", function () { clearTimeout(rsTimer); rsTimer = setTimeout(redrawStrips, 60); });
583
+ if (dark && dark.addEventListener) dark.addEventListener("change", function () { renderAll(); });
584
+
585
+ // ---------- popover ----------
586
+ var pop = document.getElementById("pop");
587
+ function colPath(c) { return D.columns[c] ? D.columns[c].path : "#" + c; }
588
+ function chunkOf(rg, c) { var g = D.row_groups[rg]; return g && g.chunks.find(function (x) { return x.c === c; }); }
589
+ function showPop(s, ev) {
590
+ if (!s) { hidePop(); return; }
591
+ var K = KINDS[s.k], rows = [];
592
+ rows.push(["Bytes", num(s.s) + " – " + num(s.e)]);
593
+ rows.push(["Size", bytes(s.e - s.s) + " · " + pct(s.e - s.s, F.file_size) + " of file"]);
594
+ var ch = s.rg != null ? chunkOf(s.rg, s.c) : null;
595
+ if (ch && (s.k === "data" || s.k === "dict") && ch.pages) {
596
+ var p = ch.pages[s.p];
597
+ rows.push(["Header", bytes(p[2])]);
598
+ rows.push(["Body", bytes(p[3]) + " (" + bytes(p[4]) + " uncompressed)"]);
599
+ rows.push([s.k === "dict" ? "Entries" : "Values", num(p[5]) + (p[6] != null ? " · " + num(p[6]) + " nulls" : "")]);
600
+ if (p[7] != null && s.k !== "dict") rows.push(["Rows", num(p[7]) + (p[8] != null ? " from row " + num(p[8]) : "")]);
601
+ rows.push(["Encoding", p[9] || "–"]);
602
+ if (p[12]) rows.push(["CRC", CRC[p[12]]]);
603
+ if (p[10] != null || p[11] != null) rows.push(["Min … max", (p[10] || "–") + " … " + (p[11] || "–")]);
604
+ }
605
+ if (ch && (s.k === "data" || s.k === "dict" || s.k === "chunk")) {
606
+ rows.push(["Codec", ch.codec]);
607
+ if (s.k === "chunk") rows.push(["Encodings", ch.enc.join(", ")]);
608
+ }
609
+ clear(pop);
610
+ add(pop, [
611
+ h("div", { class: "t" }, h("span", { class: "sw-inline", style: "background:" + segColor(s) }), K.label + (s.p != null ? " " + s.p : "")),
612
+ ch || s.c != null ? h("div", { class: "p" }, colPath(s.c) + " · row group " + s.rg) : null,
613
+ h("div", { class: "w" }, K.what),
614
+ h("dl", null, rows.map(function (r) { return [h("dt", { text: r[0] }), h("dd", { text: r[1] })]; }))
615
+ ]);
616
+ pop.style.display = "block";
617
+ var pw = pop.offsetWidth, ph = pop.offsetHeight, px = ev.clientX + 14, py = ev.clientY + 16;
618
+ if (px + pw > innerWidth - 8) px = Math.max(8, ev.clientX - pw - 14);
619
+ if (py + ph > innerHeight - 8) py = Math.max(8, ev.clientY - ph - 12);
620
+ pop.style.left = px + "px"; pop.style.top = py + "px";
621
+ }
622
+ function hidePop() { pop.style.display = "none"; }
623
+
624
+ // ---------- state changes ----------
625
+ function selectRg(i, toggle) { S.rg = toggle && S.rg === i ? null : i; S.page = null; S.pageOffset = 0; renderAll(); }
626
+ function selectCol(c, toggle) { S.col = toggle && S.col === c ? null : c; S.page = null; S.pageOffset = 0; renderAll(); }
627
+ function selectChunk(rg, c, page) {
628
+ S.rg = rg; S.col = c; S.page = page == null ? null : page; S.tab = "pages";
629
+ S.pageOffset = page == null ? 0 : Math.floor(page / PAGE_SIZE) * PAGE_SIZE;
630
+ renderAll();
631
+ var d = document.getElementById("detail"); if (d && d.scrollIntoView) d.scrollIntoView({ behavior: "smooth", block: "start" });
632
+ }
633
+
634
+ // ---------- summary ----------
635
+ function renderSummary() {
636
+ var el = clear(document.getElementById("summary"));
637
+ var dicts = D.columns.some(function (c) { return c.dict_pages > 0; });
638
+ var stats = D.row_groups.some(function (g) { return g.chunks.some(function (c) { return c.stats && (c.stats.min != null || c.stats.max != null); }); });
639
+ var crc = D.row_groups.some(function (g) { return g.chunks.some(function (c) { return c.pages && c.pages.some(function (p) { return p[12]; }); }); });
640
+ var sorting = (D.row_groups[0] && D.row_groups[0].sorting) || [];
641
+ var sortedCols = sortedAcrossRowGroups();
642
+ function badge(ok, label, extra) { return h("span", { class: "badge" + (ok ? " ok" : "") }, h("span", { "aria-hidden": "true", text: ok ? "✓" : "–" }), h("span", { text: label }), extra ? h("code", { text: extra }) : null); }
643
+ function flag(cls, label, extra) { return h("span", { class: "badge " + cls }, h("span", { "aria-hidden": "true", text: "✗" }), h("span", { text: label }), extra ? h("code", { text: extra }) : null); }
644
+ var cs = F.checksums, crcBadge;
645
+ if (cs && cs.mismatch) crcBadge = flag("err", "Page CRC mismatches", num(cs.mismatch) + " of " + num(cs.ok + cs.mismatch));
646
+ else if (cs && cs.ok) crcBadge = badge(true, "Page CRCs verified", num(cs.ok) + " ok" + (cs.absent ? ", " + num(cs.absent) + " without" : ""));
647
+ else crcBadge = badge(crc, "Page CRCs", crc ? "not verified" : null);
648
+ add(el, [
649
+ h("header", null, h("h2", { text: F.name }), h("span", { class: "subtle" }, "created by ", h("span", { class: "mono", text: F.created_by || "unknown" }))),
650
+ h("dl", { class: "stats" },
651
+ [["Size", bytes(F.file_size)], ["Rows", num(F.num_rows)], ["Row groups", num(F.num_row_groups)], ["Columns", num(F.num_columns)],
652
+ ["Footer", bytes(F.footer_size)], ["Compression", ratio(F.uncompressed_size, F.compressed_size)]].map(function (p) {
653
+ return h("div", null, h("dt", { text: p[0] }), h("dd", { class: "num", text: p[1] }));
654
+ }),
655
+ h("div", { class: "wide" }, h("dt", { text: "Codecs · format version" }), h("dd", { class: "mono", text: (F.codecs.join(", ") || "none") + " · v" + F.format_version }))),
656
+ h("div", { class: "badges" },
657
+ badge(F.page_index, "Page index"),
658
+ badge(F.bloom_filters, "Bloom filters"),
659
+ badge(stats, "Statistics"),
660
+ badge(dicts, "Dictionary encoding"),
661
+ crcBadge,
662
+ F.index_mismatches ? flag("warn", "Page stats disagree with page index", num(F.index_mismatches)) : null,
663
+ badge(sorting.length > 0, "Sorting columns", sorting.length ? sorting.map(function (s) { return s.column + (s.descending ? " desc" : ""); }).join(", ") : null),
664
+ badge(sortedCols.length > 0, "Sorted across row groups", sortedCols.length ? (sortedCols.length > 2 ? sortedCols[0] + " +" + (sortedCols.length - 1) : sortedCols.join(", ")) : null),
665
+ F.pages_truncated ? h("span", { class: "badge" }, "page detail capped for size") : null)
666
+ ]);
667
+ }
668
+ // Columns whose row group [min, max] ranges don't overlap, in order (can't compare display strings reliably, so only numbers)
669
+ function sortedAcrossRowGroups() {
670
+ if (D.row_groups.length < 2) return [];
671
+ return D.columns.filter(function (col) {
672
+ var prev = null;
673
+ for (var g = 0; g < D.row_groups.length; g++) {
674
+ var ch = chunkOf(g, col.i); if (!ch || !ch.stats) return false;
675
+ var lo = Number(ch.stats.min), hi = Number(ch.stats.max);
676
+ if (!isFinite(lo) || !isFinite(hi)) return false;
677
+ if (prev != null && lo < prev) return false;
678
+ prev = hi;
679
+ }
680
+ return true;
681
+ }).map(function (c) { return c.path; });
682
+ }
683
+
684
+ // ---------- file map ----------
685
+ function renderMap() {
686
+ var el = clear(document.getElementById("map"));
687
+ var rgTargets = D.row_groups.map(function (g) { return { s: g.start, e: g.end, rg: g.i }; });
688
+ var bodyHost = h("div", { class: "strip pick" }), tailHost = h("div", { class: "strip" });
689
+ add(el, h("div", { class: "map-grid" },
690
+ h("div", null, h("div", { class: "strip-label" }, h("span", null, h("b", { text: "File" }), " · " + plural(D.row_groups.length, "row group") + " · click one to open it"), h("span", { text: bytes(tailStart) })), bodyHost),
691
+ h("div", null, h("div", { class: "strip-label" }, h("span", null, h("b", { text: F.page_index || F.bloom_filters ? "Indexes + footer" : "Footer" }), " magnified"), h("span", { text: bytes(F.file_size - tailStart) })), tailHost)));
692
+ new Strip(bodyHost, 0, tailStart, bodySegs, {
693
+ label: "File layout", targets: rgTargets,
694
+ selected: function () { var g = S.rg != null && D.row_groups[S.rg]; return g ? { s: g.start, e: g.end } : null; },
695
+ onpick: function (s) {
696
+ if (!s) return;
697
+ var rg = s.rg; if (rg == null) { var t = rgTargets.find(function (t) { return s.s >= t.s && s.s < t.e; }); rg = t ? t.rg : null; }
698
+ if (rg != null) { hidePop(); selectRg(rg, true); }
699
+ }
700
+ });
701
+ new Strip(tailHost, tailStart, F.file_size, tailSegs, { label: "Indexes and footer",
702
+ onpick: function (s, ev) { if (s && s.rg != null && s.c != null) { hidePop(); selectChunk(s.rg, s.c); } else showPop(s, ev); } });
703
+ if (S.rg != null && D.row_groups[S.rg]) {
704
+ var g = D.row_groups[S.rg], host = h("div", { class: "strip pick" });
705
+ add(el, h("div", { class: "zoom" }, h("div", { class: "strip-label" }, h("span", null, h("b", { text: "Row group " + g.i }), " · bytes " + num(g.start) + "–" + num(g.end) + " · click a page to open its column chunk"), h("span", { text: bytes(g.end - g.start) })), host));
706
+ new Strip(host, g.start, g.end, segsIn(bodySegs, g.start, g.end), { label: "Row group layout",
707
+ selected: function () { var ch = S.col != null && chunkOf(S.rg, S.col); return ch ? { s: ch.start, e: ch.end } : null; },
708
+ onpick: function (s) { if (s && s.c != null) { hidePop(); selectChunk(s.rg, s.c, s.p); } } });
709
+ var ch = S.col != null ? chunkOf(S.rg, S.col) : null;
710
+ if (ch && ch.pages && ch.pages.length > 1) {
711
+ var host2 = h("div", { class: "strip pick" });
712
+ add(el, h("div", { class: "zoom" }, h("div", { class: "strip-label" }, h("span", null, h("b", { text: colPath(ch.c) }), " in row group " + g.i + " · " + plural(ch.pages.length, "page")), h("span", { text: bytes(ch.end - ch.start) })), host2));
713
+ new Strip(host2, ch.start, ch.end, segsIn(bodySegs, ch.start, ch.end).filter(function (s) { return s.c === ch.c; }), { label: "Column chunk pages",
714
+ selected: function () { if (S.page == null || !ch.pages[S.page]) return null; var p = ch.pages[S.page]; return { s: p[1], e: p[1] + p[2] + p[3] }; },
715
+ onpick: function (s) { if (s && s.p != null) { hidePop(); selectChunk(s.rg, s.c, s.p); } } });
716
+ }
717
+ }
718
+ var legend = h("div", { class: "legend" });
719
+ D.columns.slice(0, 16).forEach(function (c) {
720
+ add(legend, h("span", { class: "item" + (S.col != null && S.col !== c.i ? " dim" : ""), onclick: function () { selectCol(c.i, true); } },
721
+ h("span", { class: "sw", style: "background:" + colColor(c.i, false, true) }), h("span", { class: "mono", text: c.path })));
722
+ });
723
+ if (D.columns.length > 16) add(legend, h("span", { class: "faint", text: "+" + num(D.columns.length - 16) + " more" }));
724
+ var kinds = h("span", { class: "kinds" });
725
+ ["dict", "data", "ci", "oi", "bloom", "footer"].forEach(function (k) {
726
+ if ((k === "ci" || k === "oi") && !F.page_index) return;
727
+ if (k === "bloom" && !F.bloom_filters) return;
728
+ var color = k === "dict" ? (isDark() ? "#394150" : "#e2e5ea") : k === "data" ? (isDark() ? "#566072" : "#b8bec8") : kindColor(k);
729
+ add(kinds, h("span", null, h("span", { class: "sw", style: "background:" + color }), KINDS[k].label.toLowerCase()));
730
+ });
731
+ if (segs.some(function (s) { return s.k === "cmeta"; })) add(kinds, h("span", null, h("span", { class: "sw", style: "background:" + kindColor("cmeta") }), "column metadata copy"));
732
+ if (segs.some(function (s) { return s.k === "unknown" && s.e - s.s > 0; })) add(kinds, h("span", null, h("span", { class: "sw", style: "background:" + kindColor("unknown") }), "unaccounted"));
733
+ add(legend, kinds);
734
+ add(el, legend);
735
+ add(el, h("p", { class: "hint", text: "Drawn to scale by compressed bytes; each pixel shows whatever covers most of its bytes. Hover for details, click to drill down." }));
736
+ }
737
+
738
+ // ---------- schema ----------
739
+ function renderSchema() {
740
+ var el = clear(document.getElementById("schema"));
741
+ var total = D.columns.reduce(function (a, c) { return a + c.cs; }, 0) || 1;
742
+ var lines = [];
743
+ lines.push({ depth: 0, html: [h("span", { class: "kw", text: "message " }), h("span", { class: "nm", text: "schema" }), " {"] });
744
+ function walk(n, depth) {
745
+ var ann = n.logical_type || n.converted_type;
746
+ if (n.children) {
747
+ lines.push({ depth: depth, html: [h("span", { class: "kw", text: n.repetition + " " }), h("span", { class: "ty", text: "group " }), h("span", { class: "nm", text: n.name }), ann ? h("span", { class: "ann", text: " (" + ann + ")" }) : null, " {", arrowNote(n)] });
748
+ n.children.forEach(function (c) { walk(c, depth + 1); });
749
+ lines.push({ depth: depth, html: ["}"] });
750
+ } else {
751
+ var phys = n.physical_type.toLowerCase() + (n.type_length ? "(" + n.type_length + ")" : "");
752
+ lines.push({ depth: depth, leaf: n.column, html: [h("span", { class: "kw", text: n.repetition + " " }), h("span", { class: "ty", text: phys + " " }), h("span", { class: "nm", text: n.name }), ann ? h("span", { class: "ann", text: " (" + ann + ")" }) : null, ";",
753
+ h("span", { class: "lv", text: " d" + n.max_definition_level + " r" + n.max_repetition_level }), arrowNote(n)] });
754
+ }
755
+ }
756
+ function arrowNote(n) { return n.arrow_type ? h("span", { class: "arrow", title: "Arrow type (from ARROW:schema): " + n.arrow_type, text: " // arrow: " + n.arrow_type }) : null; }
757
+ D.schema.forEach(function (n) { walk(n, 1); });
758
+ lines.push({ depth: 0, html: ["}"] });
759
+ var box = h("div", { class: "schema" });
760
+ var limit = S.allSchema ? lines.length : 400;
761
+ lines.slice(0, limit).forEach(function (l, n) {
762
+ var code = h("span", { class: "code", style: "padding-left:" + (l.depth * 2) + "ch" }, l.html);
763
+ if (l.leaf == null) { add(box, h("div", { class: "sline" }, h("span", { class: "ln", text: n + 1 }), code)); return; }
764
+ var c = D.columns[l.leaf];
765
+ add(box, h("button", { type: "button", class: "sline" + (S.col === c.i ? " sel" : ""), title: c.type + " · max definition level " + c.max_def + ", max repetition level " + c.max_rep, onclick: function () { selectCol(c.i, true); } },
766
+ h("span", { class: "ln", text: n + 1 }), code,
767
+ h("span", { class: "size" }, h("span", { class: "bar" }, h("span", { style: "width:" + (c.cs / total * 100) + "%;background:" + colColor(c.i, false) })), h("span", { class: "v", text: bytes(c.cs) }))));
768
+ });
769
+ var hasArrow = D.schema.some(function (n) { return n.arrow_type; });
770
+ add(el, [h("h2", null, "Schema", h("small", { text: "size on disk · d/r = max definition/repetition level" + (hasArrow ? " · Arrow types from ARROW:schema" : "") + " · click a column" })), box]);
771
+ if (D.arrow_error) add(el, h("div", { class: "note", text: D.arrow_error }));
772
+ if (lines.length > limit) add(el, h("button", { class: "more", onclick: function () { S.allSchema = true; renderSchema(); } }, "Show all " + num(lines.length) + " lines"));
773
+ }
774
+
775
+ // ---------- row groups ----------
776
+ function renderRowGroups() {
777
+ var el = clear(document.getElementById("rowgroups"));
778
+ var maxSize = Math.max.apply(null, D.row_groups.map(function (g) { return g.end - g.start; }).concat([1]));
779
+ var box = h("div", { class: "card rgs" });
780
+ var shown = S.allRgs ? D.row_groups : D.row_groups.slice(0, 100);
781
+ if (!D.row_groups.length) add(box, h("div", { class: "pad faint", text: "No row groups: the file has no data." }));
782
+ shown.forEach(function (g) {
783
+ var open = S.rg === g.i;
784
+ add(box, h("button", { type: "button", class: "rg-row" + (open ? " open" : ""), "aria-expanded": open ? "true" : "false", onclick: function () { selectRg(g.i, true); } },
785
+ h("span", { class: "caret", text: open ? "▾" : "▸" }), h("span", { class: "mono", text: g.i }),
786
+ h("span", { class: "mono faint", text: g.rows ? num(g.first_row) + "–" + num(g.first_row + g.rows - 1) : "0 rows" }),
787
+ h("span", { class: "gcell" }, h("span", { class: "gbar", style: "width:" + Math.max(1, (g.end - g.start) / maxSize * 100) + "%;background:" + gradient(g) })),
788
+ h("span", { class: "mono", style: "text-align:right", text: bytes(g.end - g.start) })));
789
+ if (open) add(box, rowGroupBody(g));
790
+ });
791
+ add(el, [h("h2", null, "Row groups", h("small", { text: num(D.row_groups.length) + " · click one, or click it in the file map" })), box]);
792
+ if (shown.length < D.row_groups.length) add(el, h("button", { class: "more", onclick: function () { S.allRgs = true; renderRowGroups(); } }, "Show all " + num(D.row_groups.length) + " row groups"));
793
+ }
794
+ function gradient(g) {
795
+ var total = g.chunks.reduce(function (a, c) { return a + (c.end - c.start); }, 0) || 1, at = 0, stops = [];
796
+ var step = Math.max(1, Math.ceil(g.chunks.length / 300));
797
+ for (var i = 0; i < g.chunks.length; i += step) {
798
+ var sz = 0; for (var j = i; j < Math.min(i + step, g.chunks.length); j++) sz += g.chunks[j].end - g.chunks[j].start;
799
+ var from = at / total * 100; at += sz;
800
+ stops.push(colColor(g.chunks[i].c, false) + " " + from + "% " + (at / total * 100) + "%");
801
+ }
802
+ return stops.length ? "linear-gradient(to right," + stops.join(",") + ")" : "var(--line)";
803
+ }
804
+ function rowGroupBody(g) {
805
+ var body = h("div", { class: "rg-body" });
806
+ add(body, h("div", { class: "kvline" },
807
+ [["rows", num(g.rows)], ["compressed", bytes(g.cs)], ["uncompressed", bytes(g.us)], ["bytes", num(g.start) + "–" + num(g.end)],
808
+ g.sorting.length ? ["sorted by", g.sorting.map(function (s) { return s.column + (s.descending ? " desc" : "") + (s.nulls_first ? " nulls first" : ""); }).join(", ")] : null]
809
+ .filter(Boolean).map(function (p) { return h("span", null, h("span", { class: "k", text: p[0] }), h("span", { class: "mono", text: p[1] })); })));
810
+ var maxChunk = Math.max.apply(null, g.chunks.map(function (c) { return c.end - c.start; }).concat([1]));
811
+ var chunks = g.chunks.length > 200 && !S.allChunkRgs ? g.chunks.slice(0, 200) : g.chunks;
812
+ chunks.forEach(function (ch) {
813
+ var pages = h("span", { class: "pages", style: "width:" + Math.max(6, (ch.end - ch.start) / maxChunk * 100) + "%" });
814
+ if (ch.pages && ch.pages.length <= 40) ch.pages.forEach(function (p) { add(pages, h("span", { style: "flex:" + (p[2] + p[3]) + " 0 0;background:" + colColor(ch.c, p[0] === 2) })); });
815
+ else {
816
+ // Too many pages to draw one by one: dictionary pages, then all data pages as one hatched block
817
+ var dictBytes = 0, dataBytes = 0;
818
+ (ch.pages || []).forEach(function (p) { if (p[0] === 2) dictBytes += p[2] + p[3]; else dataBytes += p[2] + p[3]; });
819
+ if (!ch.pages) dataBytes = ch.end - ch.start;
820
+ if (dictBytes) add(pages, h("span", { style: "flex:" + dictBytes + " 0 0;background:" + colColor(ch.c, true) }));
821
+ add(pages, h("span", { title: plural(ch.np, "page"), style: "flex:" + Math.max(1, dataBytes) + " 0 0;background:repeating-linear-gradient(90deg," + colColor(ch.c, false) + " 0 3px,var(--seam) 3px 4px)" }));
822
+ }
823
+ var info = [ch.np ? plural(ch.ndp, "page") : "no pages", ch.nulls ? num(ch.nulls) + " nulls" : null,
824
+ ch.crc_bad ? "✗ CRC" : null, ch.idx_mm ? "✗ index" : null].filter(Boolean).join(" · ");
825
+ add(body, h("div", { class: "chunk-row" + (S.col === ch.c ? " sel" : ""), onclick: function (ev) { ev.stopPropagation(); selectChunk(g.i, ch.c); } },
826
+ h("span", { class: "name", title: colPath(ch.c), text: colPath(ch.c) }), pages,
827
+ h("span", { class: "size", text: bytes(ch.cs) }),
828
+ h("span", { class: "minmax", text: ch.stats && (ch.stats.min != null || ch.stats.max != null) ? (ch.stats.min || "–") + " … " + (ch.stats.max || "–") : "no min/max" }),
829
+ h("span", { class: "info", text: info })));
830
+ });
831
+ if (chunks.length < g.chunks.length) add(body, h("button", { class: "more", onclick: function (ev) { ev.stopPropagation(); S.allChunkRgs = true; renderRowGroups(); } }, "Show all " + num(g.chunks.length) + " column chunks"));
832
+ return body;
833
+ }
834
+
835
+ // ---------- columns table ----------
836
+ function renderColumns() {
837
+ var el = clear(document.getElementById("columns"));
838
+ var maxRatio = Math.max.apply(null, D.columns.map(function (c) { return c.cs ? c.us / c.cs : 0; }).concat([1]));
839
+ var shown = S.allCols ? D.columns : D.columns.slice(0, 200);
840
+ var tb = h("tbody");
841
+ shown.forEach(function (c) {
842
+ var r = c.cs ? c.us / c.cs : 0;
843
+ add(tb, h("tr", { class: "click" + (S.col === c.i ? " sel" : ""), onclick: function () { selectCol(c.i, true); } },
844
+ h("td", { class: "mono clip", title: c.path }, h("span", { class: "sw-inline", style: "background:" + colColor(c.i, false, true) }), c.path),
845
+ h("td", { class: "mono", text: c.type }),
846
+ h("td", null, c.codecs.map(function (x) { return h("span", { class: "pill", text: x }); })),
847
+ h("td", null, c.encodings.map(function (x) { return h("span", { class: "pill", text: x }); })),
848
+ h("td", { class: "r num", text: bytes(c.cs) }),
849
+ h("td", { class: "r num", text: bytes(c.us) }),
850
+ h("td", null, h("span", { class: "ratio" }, h("span", { class: "bar" }, h("span", { style: "width:" + (r / maxRatio * 100) + "%;background:var(--accent)" })), h("span", { class: "num", text: ratio(c.us, c.cs) }))),
851
+ h("td", { class: "r num", text: num(c.values) }),
852
+ h("td", { class: "r num", text: num(c.nulls) }),
853
+ h("td", { class: "r num", text: num(c.data_pages) }),
854
+ h("td", { class: "r num", text: c.dict_pages ? bytes(c.dict_bytes) : "–" }),
855
+ h("td", { class: "mono clip", title: c.min || "", text: c.min != null ? c.min : "–" }),
856
+ h("td", { class: "mono clip", title: c.max || "", text: c.max != null ? c.max : "–" })));
857
+ });
858
+ var head = h("tr", null, ["column", "type", "codec", "encodings", "compressed", "uncompressed", "ratio", "values", "nulls", "data pages", "dictionary", "min", "max"].map(function (t, i) {
859
+ return h("th", { class: (i >= 4 && i <= 5) || (i >= 7 && i <= 10) ? "r" : null, text: t });
860
+ }));
861
+ add(el, [h("h2", null, "Columns", h("small", { text: "totals across all row groups · click a row for per-row-group and per-page detail" })),
862
+ h("div", { class: "card tablewrap" }, h("table", null, h("thead", null, head), tb))]);
863
+ if (shown.length < D.columns.length) add(el, h("button", { class: "more", onclick: function () { S.allCols = true; renderColumns(); } }, "Show all " + num(D.columns.length) + " columns"));
864
+ }
865
+
866
+ // ---------- column / chunk detail ----------
867
+ var PAGE_SIZE = 100;
868
+ function renderDetail() {
869
+ var el = clear(document.getElementById("detail"));
870
+ if (S.col == null) return;
871
+ var col = D.columns[S.col];
872
+ var card = h("div", { class: "card pad detail" });
873
+ add(el, [h("h2", null, h("span", { class: "mono", text: col.path }), h("small", { text: col.type + " · " + col.repetition + " · " + col.sort_order + " sort order" }),
874
+ h("button", { class: "more", style: "margin:0 0 0 auto", onclick: function () { selectCol(col.i, true); } }, "close")), card]);
875
+ add(card, h("div", { class: "kvline" },
876
+ [["compressed", bytes(col.cs) + " (" + pct(col.cs, F.file_size) + " of file)"], ["uncompressed", bytes(col.us)], ["ratio", ratio(col.us, col.cs)],
877
+ ["values", num(col.values)], ["nulls", num(col.nulls)], ["pages", num(col.pages)], ["max def/rep", col.max_def + "/" + col.max_rep]]
878
+ .map(function (p) { return h("span", null, h("span", { class: "k", text: p[0] }), h("span", { class: "mono", text: p[1] })); })));
879
+ // per row group
880
+ var tb = h("tbody");
881
+ var rgs = D.row_groups.length > 500 && !S.allChunkRgs ? D.row_groups.slice(0, 500) : D.row_groups;
882
+ rgs.forEach(function (g) {
883
+ var ch = chunkOf(g.i, col.i); if (!ch) return;
884
+ var st = ch.stats || {};
885
+ add(tb, h("tr", { class: "click" + (S.rg === g.i ? " sel" : ""), onclick: function () { selectChunk(g.i, col.i); } },
886
+ h("td", { class: "mono", text: g.i }),
887
+ h("td", { class: "mono clip", title: st.min || "", text: st.min != null ? st.min : "–" }),
888
+ h("td", { class: "mono clip", title: st.max || "", text: st.max != null ? st.max : "–" }),
889
+ h("td", { class: "r num", text: num(st.nulls) }),
890
+ h("td", { class: "r num", text: num(st.distinct) }),
891
+ h("td", { class: "r num", text: num(ch.values) }),
892
+ h("td", { class: "r num", text: num(ch.ndp) }),
893
+ h("td", { class: "r num", text: ch.dict ? num(ch.dict.n) : "–" }),
894
+ h("td", { class: "r num", text: bytes(ch.cs) }),
895
+ h("td", { class: "r num", text: ratio(ch.us, ch.cs) }),
896
+ h("td", null, h("span", { class: "pill", text: ch.codec }), st.caveat ? h("span", { class: "pill warn", title: st.caveat, text: "legacy stats" }) : null, st.source && st.source.indexOf("legacy") >= 0 && !st.caveat ? h("span", { class: "pill", text: "legacy min/max" }) : null,
897
+ st.min_exact === false || st.max_exact === false ? h("span", { class: "pill", title: "min/max were truncated by the writer", text: "truncated" }) : null,
898
+ ch.err ? h("span", { class: "pill err", title: ch.err, text: "error" }) : null,
899
+ ch.crc_bad ? h("span", { class: "pill err", text: "CRC mismatch" }) : null,
900
+ ch.idx_mm ? h("span", { class: "pill warn", title: "page statistics disagree with the column index", text: "index mismatch" }) : null)));
901
+ });
902
+ add(card, h("div", { class: "tablewrap", style: "max-height:360px;overflow:auto" }, h("table", null,
903
+ h("thead", null, h("tr", null, ["rg", "min", "max", "nulls", "distinct", "values", "data pages", "dict entries", "size", "ratio", ""].map(function (t, i) { return h("th", { class: i >= 3 && i <= 9 ? "r" : null, text: t }); }))), tb)));
904
+ if (S.rg == null) { add(card, h("p", { class: "hint", text: "Click a row group to see its column chunk: page headers, page index and metadata." })); return; }
905
+ var ch = chunkOf(S.rg, col.i);
906
+ if (ch) add(card, chunkDetail(D.row_groups[S.rg], ch, col));
907
+ }
908
+
909
+ function chunkDetail(g, ch, col) {
910
+ var box = h("div", { style: "margin-top:16px" });
911
+ add(box, h("h2", null, "Column chunk", h("small", { text: col.path + " · row group " + g.i })));
912
+ if (ch.err) add(box, h("div", { class: "note err", text: "While walking page headers: " + ch.err }));
913
+ if (ch.stats && ch.stats.caveat) add(box, h("div", { class: "note", text: "Statistics: " + ch.stats.caveat }));
914
+ if (ch.crc_bad) add(box, h("div", { class: "note err", text: plural(ch.crc_bad, "page") + " failed CRC verification: the bytes stored don't match the checksum in the page header." }));
915
+ if (ch.idx_mm) add(box, h("div", { class: "note" }, "Page statistics disagree with the column index:",
916
+ h("ul", { style: "margin:4px 0 0;padding-left:18px" }, ch.idx_mm.slice(0, 20).map(function (m) {
917
+ return h("li", { class: "mono", text: m[0] == null ? m[2] + " data pages but " + m[3] + " column index entries" : "page " + m[0] + " " + m[1] + ": page header " + (m[2] == null ? "–" : m[2]) + ", column index " + (m[3] == null ? "–" : m[3]) });
918
+ }), ch.idx_mm.length > 20 ? h("li", { text: "+" + num(ch.idx_mm.length - 20) + " more" }) : null)));
919
+ var items = [
920
+ ["codec", ch.codec], ["encodings", ch.enc.join(", ")],
921
+ ["compressed / uncompressed", bytes(ch.cs) + " / " + bytes(ch.us) + " (" + ratio(ch.us, ch.cs) + ")"],
922
+ ["bytes", num(ch.start) + " – " + num(ch.end) + (ch.end > ch.declared_end ? " (metadata says " + num(ch.declared_end) + ")" : "")],
923
+ ["data page offset", num(ch.data_off)], ["dictionary page offset", ch.dict_off != null ? num(ch.dict_off) : "–"],
924
+ ["values · nulls", num(ch.values) + " · " + num(ch.nulls)],
925
+ ["dictionary", ch.dict ? num(ch.dict.n) + " entries, " + bytes(ch.dict.cs) + (ch.dict.sorted ? ", sorted" : "") : "none"],
926
+ ["statistics", ch.stats ? (ch.stats.source || "null count only") : "none"],
927
+ ["min … max", ch.stats && (ch.stats.min != null || ch.stats.max != null) ? (ch.stats.min || "–") + " … " + (ch.stats.max || "–") : "–"],
928
+ ["distinct count", ch.stats && ch.stats.distinct != null ? num(ch.stats.distinct) : "–"],
929
+ ["bloom filter", ch.bloom ? "at " + num(ch.bloom[0]) + (ch.bloom[1] ? ", " + bytes(ch.bloom[1]) : "") : "none"],
930
+ ["column index", ch.ci ? "at " + num(ch.ci[0]) + ", " + bytes(ch.ci[1]) : "none"],
931
+ ["offset index", ch.oi ? "at " + num(ch.oi[0]) + ", " + bytes(ch.oi[1]) : "none"]
932
+ ];
933
+ if (ch.estats && ch.estats.length) items.push(["encoding stats", ch.estats.map(function (e) { return e.page_type + " " + e.encoding + " ×" + e.count; }).join("; ")]);
934
+ if (ch.size_stats && ch.size_stats.unencoded_byte_array_data_bytes != null) items.push(["unencoded byte array data", bytes(ch.size_stats.unencoded_byte_array_data_bytes)]);
935
+ if (ch.kv) Object.keys(ch.kv).forEach(function (k) { items.push(["metadata: " + k, ch.kv[k]]); });
936
+ add(box, h("dl", { class: "dl" }, items.map(function (p) { return h("div", null, h("dt", { text: p[0] }), h("dd", { text: p[1] })); })));
937
+ if (!ch.pages) { add(box, h("p", { class: "hint", text: "Page detail was left out to keep this page small (" + num(ch.np) + " pages in this chunk)." })); return box; }
938
+ var hasIdx = !!(ch.column_index || ch.offset_index);
939
+ var tabs = h("div", { class: "tabs" });
940
+ [["pages", "Pages (" + num(ch.pages.length) + ")"], hasIdx ? ["index", "Page index"] : null].filter(Boolean).forEach(function (t) {
941
+ add(tabs, h("button", { class: S.tab === t[0] ? "on" : null, onclick: function () { S.tab = t[0]; S.pageOffset = 0; renderDetail(); } }, t[1]));
942
+ });
943
+ add(box, tabs);
944
+ if (S.tab === "index" && hasIdx) add(box, pageIndexTable(ch)); else add(box, pagesTable(ch));
945
+ return box;
946
+ }
947
+
948
+ function pager(total, rerender) {
949
+ if (total <= PAGE_SIZE) return null;
950
+ var from = S.pageOffset, to = Math.min(total, from + PAGE_SIZE);
951
+ return h("div", { class: "pager" },
952
+ h("button", { disabled: from === 0 ? true : null, onclick: function () { S.pageOffset = Math.max(0, from - PAGE_SIZE); rerender(); } }, "‹ prev"),
953
+ h("span", { class: "num", text: num(from + 1) + "–" + num(to) + " of " + num(total) }),
954
+ h("button", { disabled: to >= total ? true : null, onclick: function () { S.pageOffset = from + PAGE_SIZE; rerender(); } }, "next ›"));
955
+ }
956
+
957
+ function pagesTable(ch) {
958
+ var wrap = h("div");
959
+ var tb = h("tbody"), from = S.pageOffset, rows = ch.pages.slice(from, from + PAGE_SIZE);
960
+ var bad = {};
961
+ (ch.idx_mm || []).forEach(function (m) { if (m[0] != null) (bad[m[0]] = bad[m[0]] || []).push(m[1] + ": index says " + (m[3] == null ? "–" : m[3])); });
962
+ rows.forEach(function (p, k) {
963
+ var i = from + k;
964
+ add(tb, h("tr", { class: "click" + (S.page === i ? " hl" : ""), onclick: function () { S.page = S.page === i ? null : i; renderMap(); renderDetail(); } },
965
+ h("td", { class: "num", text: i }),
966
+ h("td", null, h("span", { class: "sw-inline", style: "background:" + colColor(ch.c, p[0] === 2, true) }), PT[p[0]] || p[0]),
967
+ h("td", { class: "r num", text: num(p[1]) }),
968
+ h("td", { class: "r num", text: num(p[2]) }),
969
+ h("td", { class: "r num", text: num(p[3]) }),
970
+ h("td", { class: "r num", text: num(p[4]) }),
971
+ h("td", { class: "r num", text: num(p[5]) }),
972
+ h("td", { class: "r num", text: p[6] != null ? num(p[6]) : "–" }),
973
+ h("td", { class: "r num", text: p[7] != null ? num(p[7]) : "–" }),
974
+ h("td", { class: "r num", text: p[8] != null ? num(p[8]) : "–" }),
975
+ h("td", null, p[9] ? h("span", { class: "pill", text: p[9] }) : "–"),
976
+ h("td", { class: "mono clip", title: p[10] || "", text: p[10] != null ? p[10] : "–" }),
977
+ h("td", { class: "mono clip", title: p[11] || "", text: p[11] != null ? p[11] : "–" }),
978
+ h("td", null, crcCell(p[12]), bad[i] ? h("span", { class: "pill warn", title: bad[i].join("\n"), text: "≠ index" }) : null),
979
+ h("td", { class: "faint", text: p[13] || "" })));
980
+ });
981
+ add(wrap, h("div", { class: "tablewrap" }, h("table", null,
982
+ h("thead", null, h("tr", null, ["#", "type", "offset", "header", "compressed", "uncompressed", "values", "nulls", "rows", "first row", "encoding", "min", "max", "crc", "levels"].map(function (t, i) { return h("th", { class: i >= 2 && i <= 9 ? "r" : null, text: t }); }))), tb)));
983
+ add(wrap, pager(ch.pages.length, renderDetail));
984
+ return wrap;
985
+ }
986
+
987
+ function crcCell(v) {
988
+ if (v === 2) return h("span", { class: "pill ok", title: "CRC verified", text: "✓ ok" });
989
+ if (v === 3) return h("span", { class: "pill err", title: "the page bytes don't match the CRC in its header", text: "✗ mismatch" });
990
+ return v ? h("span", { title: "the page header has a CRC (not verified; use --verify-checksums)", text: "✓" }) : "";
991
+ }
992
+
993
+ function pageIndexTable(ch) {
994
+ var wrap = h("div");
995
+ var ci = ch.column_index, oi = ch.offset_index || [];
996
+ var n = Math.max(ci ? ci.rows.length : 0, oi.length);
997
+ if (ci) add(wrap, h("div", { class: "kvline" }, h("span", null, h("span", { class: "k", text: "boundary order" }), h("span", { class: "mono", text: String(ci.boundary) })), h("span", null, h("span", { class: "k", text: "entries" }), h("span", { class: "mono", text: num(n) }))));
998
+ var tb = h("tbody"), from = S.pageOffset;
999
+ // column index entries follow the data pages; map them back to page numbers
1000
+ var dataPages = [], badEntry = {};
1001
+ ch.pages.forEach(function (p, k) { if (p[0] !== 2) dataPages.push(k); });
1002
+ (ch.idx_mm || []).forEach(function (m) { if (m[0] != null) badEntry[dataPages.indexOf(m[0])] = true; });
1003
+ for (var i = from; i < Math.min(n, from + PAGE_SIZE); i++) {
1004
+ var c = ci && ci.rows[i], o = oi[i];
1005
+ add(tb, h("tr", { class: badEntry[i] ? "hl" : null, title: badEntry[i] ? "disagrees with the page header's statistics" : null },
1006
+ h("td", { class: "num", text: i }),
1007
+ h("td", { class: "r num", text: o ? num(o[0]) : "–" }),
1008
+ h("td", { class: "r num", text: o ? num(o[1]) : "–" }),
1009
+ h("td", { class: "r num", text: o ? num(o[2]) : "–" }),
1010
+ h("td", { text: c ? (c[0] ? "yes" : "") : "–" }),
1011
+ h("td", { class: "mono clip", title: c && c[1] || "", text: c && c[1] != null ? c[1] : "–" }),
1012
+ h("td", { class: "mono clip", title: c && c[2] || "", text: c && c[2] != null ? c[2] : "–" }),
1013
+ h("td", { class: "r num", text: c && c[3] != null ? num(c[3]) : "–" })));
1014
+ }
1015
+ add(wrap, h("div", { class: "tablewrap" }, h("table", null,
1016
+ h("thead", null, h("tr", null, ["#", "offset", "compressed size", "first row", "null page", "min", "max", "null count"].map(function (t, i) { return h("th", { class: (i >= 1 && i <= 3) || i === 7 ? "r" : null, text: t }); }))), tb)));
1017
+ add(wrap, pager(n, renderDetail));
1018
+ return wrap;
1019
+ }
1020
+
1021
+ // ---------- key/value metadata ----------
1022
+ function renderKV() {
1023
+ var el = clear(document.getElementById("kv"));
1024
+ if (!D.kv.length) return;
1025
+ var card = h("div", { class: "card pad" });
1026
+ D.kv.forEach(function (kv) {
1027
+ var body;
1028
+ if (kv.json !== undefined) body = code(JSON.stringify(kv.json, null, 2), "json");
1029
+ else body = h("pre", null, h("code", { text: kv.value }));
1030
+ var extra = kv.arrow_schema ? arrowTree(kv.arrow_schema)
1031
+ : kv.arrow_fields ? h("div", { class: "hint", text: "Arrow schema fields: " + kv.arrow_fields.join(", ") }) : null;
1032
+ if (kv.arrow_error) extra = [h("div", { class: "note", text: kv.arrow_error }), extra];
1033
+ var head = h("div", { class: "head" }, h("span", { class: "key", text: kv.key }), h("span", { class: "pill", text: kv.format }), h("span", { class: "faint", text: bytes(kv.bytesize) }), kv.summary ? h("span", { class: "subtle", text: kv.summary }) : null);
1034
+ if (kv.format === "arrow_schema" || kv.bytesize > 4000) add(card, h("div", { class: "kv" }, head, extra, h("details", null, h("summary", { text: "show value" }), body)));
1035
+ else add(card, h("div", { class: "kv" }, head, body));
1036
+ });
1037
+ add(el, [h("h2", null, "Key/value metadata", h("small", { text: num(D.kv.length) + " entries" })), card]);
1038
+ }
1039
+ function arrowTree(a) {
1040
+ var box = h("div", { class: "schema", style: "margin:4px 0 8px" }), n = 0;
1041
+ function walk(f, depth) {
1042
+ if (n++ > 2000) return;
1043
+ var notes = [];
1044
+ if (!f.nullable) notes.push("not null");
1045
+ if (f.extension) notes.push("extension " + f.extension);
1046
+ if (f.dictionary) notes.push("dictionary-encoded, " + f.dictionary.index_type + " indices" + (f.dictionary.ordered ? ", ordered" : ""));
1047
+ var meta = f.metadata ? Object.keys(f.metadata).filter(function (k) { return k.indexOf("ARROW:extension:") !== 0; }) : [];
1048
+ if (meta.length) notes.push("metadata " + meta.map(function (k) { return k + "=" + JSON.stringify(f.metadata[k]).slice(0, 80); }).join(", "));
1049
+ add(box, h("div", { class: "sline", title: f.type },
1050
+ h("span", { class: "ln" }),
1051
+ h("span", { class: "code", style: "padding-left:" + (depth * 2) + "ch" }, h("span", { class: "nm", text: f.name }), ": ",
1052
+ h("span", { class: "ty", text: f.children && f.type.length > 60 ? f.type.slice(0, f.type.indexOf("<") + 1) + "…>" : f.type }),
1053
+ notes.length ? h("span", { class: "ann", text: " " + notes.join("; ") }) : null)));
1054
+ (f.children || []).forEach(function (c) { walk(c, depth + 1); });
1055
+ }
1056
+ a.fields.forEach(function (f) { walk(f, 0); });
1057
+ var meta = a.metadata ? Object.keys(a.metadata) : [];
1058
+ return [h("div", { class: "hint", text: "Arrow schema" + (a.endianness === "big" ? " (big-endian)" : "") + (meta.length ? " · schema metadata: " + meta.join(", ") : "") }), box];
1059
+ }
1060
+ function code(text, lang) {
1061
+ var c = h("code", { class: "language-" + lang, text: text });
1062
+ highlight(c);
1063
+ return h("pre", null, c);
1064
+ }
1065
+ var pending = [];
1066
+ function highlight(el) {
1067
+ if (window.hljs) { try { window.hljs.highlightElement(el); } catch (e) {} }
1068
+ else pending.push(el);
1069
+ }
1070
+ window.__hbHighlight = function () { var p = pending; pending = []; p.forEach(function (el) { if (el.isConnected) highlight(el); }); };
1071
+
1072
+ // ---------- footer JSON ----------
1073
+ function renderFooter() {
1074
+ var el = clear(document.getElementById("footer"));
1075
+ var det = h("details", null, h("summary", { text: "FileMetaData as JSON (" + bytes(F.footer_size) + " footer)" }));
1076
+ if (D.footer_json) {
1077
+ var done = false;
1078
+ det.addEventListener("toggle", function () { if (det.open && !done) { done = true; add(det, code(D.footer_json, "json")); } });
1079
+ } else add(det, h("p", { class: "hint", text: "The footer is too large to embed." }));
1080
+ add(el, [h("h2", null, "Footer", h("small", { text: "the raw metadata this page was drawn from" })), h("div", { class: "card pad" }, det)]);
1081
+ }
1082
+
1083
+ function renderAll() {
1084
+ renderSummary(); renderMap(); renderSchema(); renderRowGroups(); renderDetail(); renderColumns();
1085
+ }
1086
+ renderAll(); renderKV(); renderFooter();
1087
+ document.getElementById("gen").textContent = "Generated by " + D.generator + " · " + F.name;
1088
+ document.addEventListener("click", function (ev) { if (ev.target.tagName !== "CANVAS") hidePop(); });
1089
+ })();
1090
+ </script>
1091
+ <script src="%%HIGHLIGHT_JS%%" async onload="window.__hbHighlight && window.__hbHighlight()"></script>
1092
+ </body>
1093
+ </html>
1094
+ HTML
1095
+ end
1096
+ end