///|
/// The HTML report: one self-contained document with four charts.
///
/// The charts are drawn by Apache ECharts, which is vendored under `assets/` and
/// inlined here by the caller. Inlining rather than linking keeps the promise the
/// rest of the tool makes — everything happens on the machine you run it on, with
/// no network and no second file to keep next to the report.
///|
/// One module with the functions that belong to it, heaviest first.
///
/// The treemap needs the nesting, and the pie needs the totals, so the grouping
/// is done once here rather than twice in the page.
priv struct ModuleGroup {
name : String
size : Int
functions : Int
children : Array[FunctionStat]
}
///|
/// Group functions by the package their names carry, heaviest total first.
fn group_modules(stats : Array[FunctionStat]) -> Array[ModuleGroup] {
let order : Array[String] = []
let index : Map[String, Int] = Map([])
let groups : Array[ModuleGroup] = []
for stat in stats {
let at = match index.get(stat.module_name) {
Some(at) => at
None => {
index.set(stat.module_name, groups.length())
order.push(stat.module_name)
groups.push({
name: stat.module_name,
size: 0,
functions: 0,
children: [],
})
groups.length() - 1
}
}
let group = groups[at]
groups[at] = {
name: group.name,
size: group.size + stat.total_size,
functions: group.functions + 1,
children: group.children,
}
groups[at].children.push(stat)
}
for group in groups {
group.children.sort_by(fn(a, b) {
if a.total_size != b.total_size {
b.total_size - a.total_size
} else {
a.index - b.index
}
})
}
groups.sort_by(fn(a, b) {
if a.size != b.size {
b.size - a.size
} else {
String::compare(a.name, b.name)
}
})
groups
}
///|
/// The bytes spent on a named section, or zero when the module has none.
fn section_size(parsed : WasmModule, kind : SectionId) -> Int {
let mut total = 0
for section in parsed.sections {
if same_kind(section.kind, kind) {
total = total + section.total_size
}
}
total
}
///|
/// The bytes in functions no root can reach.
fn dead_size(analysis : Analysis) -> Int {
let mut total = 0
for stat in analysis.stats {
if !analysis.reachable.contains(stat.index) {
total = total + stat.total_size
}
}
total
}
///|
/// An integer as a JSON number.
///
/// The representation is pinned to the decimal text: a file size has no
/// fractional part, and letting the printer choose would turn 10645 into
/// `10645.0` or `1.0645E4` depending on its mood.
fn int_json(value : Int) -> Json {
Json::number(value.to_double(), repr=value.to_string())
}
///|
/// The payload the page draws from, as one JSON object.
fn report_data(analysis : Analysis, file_path : String) -> Json {
let parsed = analysis.parsed
let members : Map[String, Json] = Map([])
members.set("file", Json::string(file_path))
members.set("fileSize", int_json(parsed.file_size))
members.set("gzipSize", int_json(compressed_size(analysis.data)))
members.set("codeSize", int_json(section_size(parsed, Code)))
members.set("deadSize", int_json(dead_size(analysis)))
let sections : Array[Json] = []
for section in ordered_sections(parsed) {
let row : Map[String, Json] = Map([])
row.set("name", Json::string(section_label(section)))
row.set("total", int_json(section.total_size))
row.set("payload", int_json(section.payload_size))
row.set(
"gzip",
int_json(
compressed_span(analysis.data, section.offset, section.total_size),
),
)
sections.push(Json::object(row))
}
members.set("sections", Json::array(sections))
let ranked = analysis.stats.copy()
ranked.sort_by(fn(a, b) {
if a.body_size != b.body_size {
b.body_size - a.body_size
} else {
a.index - b.index
}
})
let functions : Array[Json] = []
for stat in ranked {
let row : Map[String, Json] = Map([])
row.set("index", int_json(stat.index))
row.set("name", Json::string(stat.name))
row.set("module", Json::string(stat.module_name))
row.set("bodySize", int_json(stat.body_size))
row.set("size", int_json(stat.total_size))
row.set(
"gzip",
int_json(compressed_span(analysis.data, stat.offset, stat.total_size)),
)
row.set("dead", Json::boolean(!analysis.reachable.contains(stat.index)))
functions.push(Json::object(row))
}
members.set("functions", Json::array(functions))
let groups = group_modules(analysis.stats)
let modules : Array[Json] = []
for group in groups {
let children : Array[Json] = []
for child in group.children {
let leaf : Map[String, Json] = Map([])
leaf.set("name", Json::string(child.name))
leaf.set("value", int_json(child.total_size))
leaf.set(
"gzip",
int_json(compressed_span(analysis.data, child.offset, child.total_size)),
)
leaf.set("dead", Json::boolean(!analysis.reachable.contains(child.index)))
children.push(Json::object(leaf))
}
let row : Map[String, Json] = Map([])
row.set("name", Json::string(group.name))
row.set("size", int_json(group.size))
row.set("functions", int_json(group.functions))
row.set("children", Json::array(children))
modules.push(Json::object(row))
}
members.set("modules", Json::array(modules))
// The pie gets its own roster: thin slices are rolled up there, while the
// treemap keeps every module because its cells are drawn to scale.
let slices : Array[Json] = []
for slice in pie_slices(groups) {
let row : Map[String, Json] = Map([])
row.set("name", Json::string(slice.name))
row.set("size", int_json(slice.size))
let names : Array[Json] = []
for name in slice.members {
names.push(Json::string(name))
}
row.set("members", Json::array(names))
slices.push(Json::object(row))
}
members.set("pie", Json::array(slices))
Json::object(members)
}
///|
/// One slice of the module pie.
priv struct PieSlice {
name : String
size : Int
/// The modules this slice stands for; empty when it is a module itself.
members : Array[String]
}
///|
/// A slice below half a percent of the code section cannot carry a legible label
/// or be aimed at with a pointer, so the ones below it are rolled into a single
/// slice that names them on hover.
const MIN_SLICE_TENTHS : Int = 5
///|
/// The pie's slices: every module at or above the threshold, plus one `other`
/// slice holding the rest.
///
/// The comparison is integer arithmetic — `size / total < 0.5%` is
/// `size * 1000 < total * 5` — so it cannot drift the way a float would.
fn pie_slices(groups : Array[ModuleGroup]) -> Array[PieSlice] {
let slices : Array[PieSlice] = []
let mut total = 0
for group in groups {
total = total + group.size
}
let big : Array[ModuleGroup] = []
let small : Array[ModuleGroup] = []
for group in groups {
if total > 0 && group.size * 1000 < total * MIN_SLICE_TENTHS {
small.push(group)
} else {
big.push(group)
}
}
// Bucketing nothing, or bucketing everything, would both make the chart worse.
if small.length() == 0 || big.length() == 0 {
for group in groups {
slices.push({ name: group.name, size: group.size, members: [], })
}
return slices
}
for group in big {
slices.push({ name: group.name, size: group.size, members: [], })
}
let mut rest = 0
let members : Array[String] = []
for group in small {
rest = rest + group.size
members.push(group.name)
}
slices.push({
name: "other (" + small.length().to_string() + ")",
size: rest,
members,
})
slices
}
///|
/// The length of the UTF-8 sequence a lead byte introduces.
fn utf8_width(lead : Byte) -> Int {
if lead < b'\x80' {
1
} else if lead >= b'\xf0' {
4
} else if lead >= b'\xe0' {
3
} else if lead >= b'\xc0' {
2
} else {
1
}
}
///|
/// Replace every occurrence of `needle` in `text`, which must not be empty.
fn replace_all(text : String, needle : String, replacement : String) -> String {
if needle.length() == 0 {
return text
}
let bytes = @utf8.encode(text)
let find = @utf8.encode(needle)
let out = StringBuilder(size_hint=bytes.length())
let mut i = 0
while i < bytes.length() {
if i + find.length() <= bytes.length() && matches_at(bytes, find, i) {
out.write_string(replacement)
i = i + find.length()
} else {
// A whole character at a time: one byte of a multi-byte character is not
// decodable on its own, so decoding it lossily would turn every non-ASCII
// character in the document into a replacement character.
let width = utf8_width(bytes[i])
let end = if i + width <= bytes.length() { i + width } else { i + 1 }
out.write_string(@utf8.decode_lossy(bytes[i:end]))
i = end
}
}
out.to_string()
}
///|
/// True when `needle` sits in `bytes` at `at`.
fn matches_at(bytes : Bytes, needle : Bytes, at : Int) -> Bool {
let mut i = 0
while i < needle.length() {
if bytes[at + i] != needle[i] {
return false
}
i = i + 1
}
true
}
///|
/// Make text safe inside a ` String {
replace_all(text, "<", "\\u003c")
}
///|
/// Break up any ` String {
replace_all(
replace_all(text, " String {
let out = replace_all(text, "&", "&")
let out = replace_all(out, "<", "<")
let out = replace_all(out, ">", ">")
let out = replace_all(out, "\"", """)
replace_all(out, "'", "'")
}
///|
/// The document's styling.
const HTML_STYLE : String = " :root {\n --bg: #f6f8fb; --panel: #ffffff; --ink: #0f172a;\n --muted: #64748b; --line: #e2e8f0; --accent: #2563eb;\n }\n * { box-sizing: border-box; }\n body {\n margin: 0; background: var(--bg); color: var(--ink);\n font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n }\n header { padding: 26px 28px 6px; }\n h1 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }\n h1 span { color: var(--muted); font-weight: 400; }\n .file {\n margin: 5px 0 0; color: var(--muted); font-size: 12px; word-break: break-all;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n .cards {\n display: grid; gap: 12px; padding: 16px 28px 4px;\n grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));\n }\n .card {\n background: var(--panel); border: 1px solid var(--line);\n border-radius: 10px; padding: 14px 16px;\n }\n .card .label {\n display: block; color: var(--muted); font-size: 11px; font-weight: 600;\n text-transform: uppercase; letter-spacing: 0.07em;\n }\n .card .value {\n display: block; margin-top: 7px; font-size: 23px; font-weight: 600;\n font-variant-numeric: tabular-nums;\n }\n .card .sub { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }\n .charts {\n display: grid; gap: 12px; padding: 12px 28px 28px;\n grid-template-columns: repeat(auto-fit, minmax(430px, 1fr));\n }\n .panel {\n background: var(--panel); border: 1px solid var(--line);\n border-radius: 10px; padding: 14px 16px 6px; overflow: hidden;\n }\n .panel.wide { grid-column: 1 / -1; }\n .panel h2 {\n margin: 0 0 8px; font-size: 12px; font-weight: 600; color: var(--muted);\n text-transform: uppercase; letter-spacing: 0.07em;\n }\n .chart { width: 100%; height: 320px; }\n .chart.tall { height: 440px; }\n .empty {\n height: 320px; display: flex; align-items: center; justify-content: center;\n color: var(--muted); font-size: 13px;\n }\n .card.alert .value { color: #dc2626; }\n footer { padding: 0 28px 30px; color: var(--muted); font-size: 12px; }"
///|
/// The page's script: number formatting, four chart builders, and the wiring.
const HTML_SCRIPT : String = " const DATA = __DATA__;\n\n const esc = (s) => String(s).replace(/[&<>\"']/g, (c) => ({\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''',\n }[c]));\n\n const BYTES = (v) => {\n if (v >= 1048576) return (v / 1048576).toFixed(2) + ' MB';\n if (v >= 1024) return (v / 1024).toFixed(1) + ' KB';\n return v + ' B';\n };\n\n const NUM = (v) => v.toLocaleString('en-US');\n const PCT = (part, whole) => whole > 0 ? (part / whole * 100).toFixed(1) + '%' : 'n/a';\n const RATIO = (raw, gzip) => gzip > 0 ? (raw / gzip).toFixed(2) + 'x' : 'n/a';\n\n const INK = '#334155';\n const LINE = '#e2e8f0';\n const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';\n\n const axis = (extra) => Object.assign({\n axisLine: { lineStyle: { color: '#cbd5e1' } },\n axisTick: { show: false },\n axisLabel: { color: INK, fontSize: 11 },\n splitLine: { lineStyle: { color: LINE } },\n }, extra || {});\n\n // A static report: the grow-in animation would leave a chart empty in a\n // screenshot, a print, or any capture that does not run animation frames.\n const base = {\n animation: false,\n textStyle: { fontFamily: 'ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif' },\n };\n\n function barOption(rows, color, maxValue, gzipColor) {\n // One unit across the axis: mixing \"1000 B\" with \"2.9 KB\" makes the ticks\n // read as unrelated numbers and, being long, collide with each other.\n const unit = maxValue >= 1048576 ? 1048576 : maxValue >= 1024 ? 1024 : 1;\n const suffix = unit === 1048576 ? ' MB' : unit === 1024 ? ' KB' : ' B';\n // A step that is a whole number of units keeps every tick round: stepping\n // by whole kilobytes reads \"0, 2, 4 KB\" instead of \"0.9, 2.9, 4.9 KB\".\n const step = unit * Math.max(1, Math.ceil(maxValue / unit / 4));\n // Pinning the top of the axis to a whole step keeps ECharts from adding a\n // final tick at its own niced extent, which is the one label that would\n // not be round.\n const top = unit === 1 ? undefined : step * Math.ceil(maxValue / step);\n const tick = (v) => unit === 1 ? String(Math.round(v)) : (v / unit).toFixed(1).replace(/\\.0$/, '') + suffix;\n // The two views are the same bars measured two ways, so they share one\n // axis and overlap exactly. Switching then shows how much shorter the\n // compressed bars are, rather than rescaling the axis to hide it.\n const series = [{\n name: 'raw',\n type: 'bar',\n data: rows.map((r) => r.size),\n itemStyle: { color: color, borderRadius: [0, 3, 3, 0] },\n label: {\n show: true, position: 'right', color: INK, fontSize: 11,\n formatter: (p) => BYTES(p.value),\n },\n }];\n let legend;\n if (gzipColor !== undefined) {\n series.push({\n name: 'gzip',\n type: 'bar',\n barGap: '-100%',\n data: rows.map((r) => r.gzip),\n itemStyle: { color: gzipColor, borderRadius: [0, 3, 3, 0] },\n label: {\n show: true, position: 'right', color: INK, fontSize: 11,\n formatter: (p) => BYTES(p.value),\n },\n });\n legend = {\n data: ['raw', 'gzip'],\n selected: { raw: true, gzip: false },\n top: 0, right: 0, icon: 'roundRect', itemWidth: 10, itemHeight: 8,\n itemGap: 10, textStyle: { color: INK, fontSize: 11 },\n };\n }\n const option = Object.assign({}, base, {\n grid: { left: 4, right: 96, top: legend ? 24 : 12, bottom: 4, containLabel: true },\n tooltip: {\n trigger: 'axis',\n axisPointer: { type: 'shadow' },\n formatter: (ps) => {\n const r = rows[ps[0].dataIndex];\n let text = '' + esc(r.name) + '
raw ' + NUM(r.size) + ' B (' + PCT(r.size, DATA.fileSize) + ' of file)';\n if (r.gzip !== undefined) {\n text += '
gzip ' + NUM(r.gzip) + ' B (' + PCT(r.gzip, r.size) + ' of raw, ' + RATIO(r.size, r.gzip) + ')';\n }\n return text;\n },\n },\n xAxis: axis({\n type: 'value',\n splitNumber: unit === 1 ? 3 : undefined,\n interval: unit === 1 ? undefined : step,\n max: top,\n axisLabel: { color: INK, fontSize: 11, hideOverlap: true, formatter: tick },\n }),\n yAxis: axis({\n type: 'category',\n inverse: true,\n data: rows.map((r) => r.name),\n axisLabel: { color: INK, fontSize: 11, fontFamily: MONO, width: 190, overflow: 'truncate', interval: 0 },\n splitLine: { show: false },\n }),\n series: series,\n });\n if (legend !== undefined) option.legend = legend;\n return option;\n }\n\n // Below this share of the ring a slice is too narrow to hold its own label,\n // so it is identified by the legend and the tooltip instead.\n const LABELLED_PCT = 6;\n\n function pieOption(rows) {\n const total = rows.reduce((sum, r) => sum + r.size, 0);\n return Object.assign({}, base, {\n tooltip: {\n trigger: 'item',\n formatter: (p) => {\n const r = rows[p.dataIndex];\n let text = '' + esc(p.name) + '
' + NUM(p.value) + ' B
' + p.percent + '% of the code section';\n if (r && r.members && r.members.length) text += '
' + esc(r.members.join(', '));\n return text;\n },\n },\n legend: {\n type: 'scroll', bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8,\n textStyle: { color: INK, fontSize: 11 },\n },\n series: [{\n type: 'pie',\n radius: ['40%', '66%'],\n center: ['50%', '47%'],\n avoidLabelOverlap: true,\n itemStyle: { borderColor: '#fff', borderWidth: 1 },\n // Labels sit inside the ring, where they cannot collide with the\n // legend or with each other, and only the slices wide enough to hold\n // one keep it. Everything else is identified by the legend and the\n // tooltip — never by a leader line pointing at empty space.\n label: {\n position: 'inside',\n fontSize: 11,\n color: '#fff',\n textBorderColor: 'rgba(15,23,42,0.45)',\n textBorderWidth: 2,\n formatter: '{d}%',\n },\n labelLine: { show: false },\n data: rows.map((r) => {\n const labelled = r.size / total * 100 >= LABELLED_PCT;\n return {\n name: r.name,\n value: r.size,\n label: { show: labelled },\n labelLine: { show: labelled },\n itemStyle: r.members && r.members.length ? { color: '#94a3b8' } : undefined,\n };\n }),\n }],\n });\n }\n\n function treemapOption(rows) {\n return Object.assign({}, base, {\n tooltip: {\n // The area stays the raw size — a treemap whose cells were drawn by\n // compressed bytes would be a different chart, and a less useful one —\n // so the compressed number is reported instead of drawn.\n formatter: (p) => {\n let text = '' + esc(p.name) + '
' + NUM(p.value) + ' B';\n if (p.data && p.data.gzip !== undefined) {\n text += '
gzip ' + NUM(p.data.gzip) + ' B (' + RATIO(p.value, p.data.gzip) + ')';\n }\n if (p.data && p.data.dead) text += '
unreachable';\n return text;\n },\n },\n series: [{\n type: 'treemap',\n roam: false,\n nodeClick: false,\n breadcrumb: { show: false },\n width: '100%',\n height: '100%',\n top: 2,\n left: 2,\n right: 2,\n bottom: 2,\n upperLabel: { show: true, height: 22, fontSize: 11, color: '#fff' },\n itemStyle: { borderColor: '#fff', borderWidth: 1, gapWidth: 1 },\n levels: [\n { itemStyle: { borderWidth: 0, gapWidth: 3 }, colorSaturation: [0.4, 0.55] },\n { itemStyle: { borderWidth: 1, gapWidth: 1 }, colorSaturation: [0.35, 0.5] },\n ],\n data: rows.map((m) => ({\n name: m.name,\n value: m.size,\n children: m.children.map((c) => ({\n name: c.name,\n value: c.value,\n dead: c.dead,\n itemStyle: c.dead ? { color: '#f87171' } : undefined,\n })),\n })),\n }],\n });\n }\n\n function emptyInto(id, message) {\n const el = document.getElementById(id);\n el.className = 'empty';\n el.textContent = message;\n }\n\n const charts = [];\n\n function mount(id, option) {\n const chart = echarts.init(document.getElementById(id));\n chart.setOption(option);\n charts.push(chart);\n }\n\n function main() {\n const sections = DATA.sections\n .map((s) => ({ name: s.name, size: s.total, payload: s.payload, gzip: s.gzip }))\n .sort((a, b) => b.size - a.size);\n const top = DATA.functions.slice(0, 20).map((f) => ({ name: f.name, size: f.bodySize }));\n\n // Give the two bar charts the height their rows need. A fixed box is what\n // used to squeeze thirteen rows into a space that fits eight.\n const barRows = Math.max(sections.length, top.length);\n const barHeight = Math.max(320, barRows * 26 + 60);\n ['sections', 'functions'].forEach((id) => {\n document.getElementById(id).style.height = barHeight + 'px';\n });\n\n if (sections.length) mount('sections', barOption(sections, '#60a5fa', sections[0].size, '#a78bfa'));\n else emptyInto('sections', 'no sections');\n\n if (top.length) mount('functions', barOption(top, '#34d399', top[0].size));\n else emptyInto('functions', 'no functions');\n\n const modules = DATA.modules.filter((m) => m.size > 0);\n // The ring needs room for the labels that sit outside it, so it is taller\n // than the default box even though its row is sized by the bar charts.\n document.getElementById('modules').style.height = '460px';\n if (DATA.pie.length) mount('modules', pieOption(DATA.pie));\n else emptyInto('modules', 'no functions');\n\n if (modules.length) mount('treemap', treemapOption(modules));\n else emptyInto('treemap', 'no functions');\n\n window.addEventListener('resize', () => charts.forEach((c) => c.resize()));\n }\n\n main();"
///|
/// A byte count with thousands separators, for the headline numbers.
fn grouped(value : Int) -> String {
let digits = value.to_string()
let out = StringBuilder(size_hint=digits.length() + 4)
let mut i = 0
while i < digits.length() {
if i > 0 && (digits.length() - i) % 3 == 0 {
out.write_string(",")
}
out.write_string(@utf8.decode_lossy(@utf8.encode(digits)[i:i + 1]))
i = i + 1
}
out.to_string()
}
///|
/// A byte count in the units a person reads sizes in.
///
/// Integer arithmetic rather than floating point, so the same number always
/// prints the same way.
fn human_bytes(bytes : Int) -> String {
if bytes >= 1048576 {
let tenths = bytes * 10 / 1048576
(tenths / 10).to_string() + "." + (tenths % 10).to_string() + " MB"
} else if bytes >= 1024 {
let tenths = bytes * 10 / 1024
(tenths / 10).to_string() + "." + (tenths % 10).to_string() + " KB"
} else {
bytes.to_string() + " B"
}
}
///|
/// One headline number.
fn card(label : String, value : String, sub : String, alert : Bool) -> String {
let klass = if alert { "card alert" } else { "card" }
" \n " +
html_escape(label) +
"\n " +
html_escape(value) +
"\n " +
html_escape(sub) +
"\n \n"
}
///|
/// Render the whole analysis as one HTML document.
///
/// `echarts` is the chart library's source. Passing it inlines the library, so
/// the report opens from any directory with no network and no sibling file;
/// leaving it empty emits a CDN `\n",
)
} else {
out.write_string(
"\n",
)
}
out.write_string("\n\n