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