Files
windmill/benchmarks/graph.ts
pyranota 74b662d8de feat(benchmarks): k8s sim mode + util-group dashboard + reliability fixes
Stand up a minikube-backed simulation subsystem for benching Windmill under
realistic multi-node load, with a per-bench measurement pipeline and a
dashboard renderer that consolidates throughput, queue depth, per-node CPU,
PG latency/conns, OOM events, and per-node CPU-util-vs-oversaturation into
one SVG report.

Sim infrastructure (sim/):
- k8s_provisioner: minikube up + heterogeneous node sizing from topology JSON
- helm_deploy: helm install Windmill with smoke.yaml + local.yaml overlays
- image_cache: pre-load required images so bench bringup is offline-safe
- toxiproxy_k8s: per-node toxiproxy DaemonSet for cross-node latency injection
- cpu_sampler_k8s: privileged DS reading per-cgroup cpu.stat at 10Hz, dual-
  writes to stdout AND a host-mounted log file (/var/log/wm-sim-cpu-sampler/
  sampler.tsv) so heavy benches no longer lose early samples to kubelet log
  rotation
- pg_logging: ALTER SYSTEM + SIGHUP to enable verbose PG logging without restart
- pgbadger: post-bench PG log analysis HTML report
- readiness: pre-bench cluster health check (samplers stable ≥30s, workers
  ready, PG responsive, queue empty, **deploy.status rollout-complete**) —
  the rollout-complete check catches mid-rolling-update fires that previously
  starved m04's sampler under cgroup_mutex contention

Per-bench JSONL pollers, started/finalized alongside the bench loop:
- pod_timeline: 1Hz workers-per-node Ready counts (used for the workers panel)
- oom_poller: live OOM event capture (kernel + kubelet evictions + cgroup)
- pg_latency_poller: 4Hz psql \\timing on SELECT 1 vs kubectl-exec roundtrip
- pg_conn_poller: 1Hz pg_stat_activity by state (active/idle/idle_in_xact)
- node_load_poller: 2Hz /proc/loadavg + /proc/stat procs_running per node

Dashboard renderer (sim/render_report.ts + graph.ts):
- Util group: one panel per node with translucent orange oversaturation area
  BEHIND solid blue CPU-util area, 100% reference line, phase-boundary verticals.
  cols:2 grid wraps after 2 panels per row.
- PG node tinted with [PG] flag in legend across the dashboard.
- Phase-boundary verticals + push-window shaded zones layered consistently.
- All x-axes switched from wall-clock HH:MM to relative seconds-from-bench-
  start. Shared origin sourced from meta.json's bench_start_ms so 0s on every
  panel = the same wall-clock moment (previously each chart picked its own
  earliest sample as origin, causing drift between panels).

Oversaturation metric, with explicit fallback:
- Primary: (procs_running - ncpu) / ncpu × 100 — true CPU run-queue pressure.
- Fallback to load1 when procs_running is missing (older reports).
- load1 overcounted previously because it includes uninterruptible D-state
  procs (PG backends in disk I/O, cgroup_mutex waits), inflating "saturation"
  by 5-10x under load.
- Pure helper extracted to sim/util_metrics.ts; 8 unit tests cover the
  procs_running > load1 preference, the clamp-at-zero, invalid-ncpu cases.

Sampler reliability:
- HostPath log file in addition to stdout so the bench's scp-based collector
  bypasses kubelet log rotation entirely.
- main.ts truncates the host log file on every node before pushers start
  (parallel ssh, best-effort) so it doesn't grow unbounded across runs.
- Collector falls back to kubectl-logs when scp fails for any node.

Workloads (workloads/):
- io_4phase: four-phase IO step (idle → 2.5s → 500ms → 150ms jobs)
- io_150ms_flood / io_300ms_flood / io_1s_flood / io_2s_flood: single-phase
  flood configs to isolate the worker-host CFS context-switch storm vs PG
  contention regime
- burst, ops_day, cpu_*, etc. for other scenarios

Tests:
- sim/util_metrics_test.ts — 8 cases for computeOversatPct
- sim/util_panel_snapshot_test.ts — 5 assertions guarding util-panel SVG
  invariants (orange behind blue, 100% ref line, relative-time ticks NOT
  wall-clock, phase-boundary verticals, shared-origin override)

Helm values:
- sim/values/smoke.yaml — bench-tuned: workers w/ no CPU limit & low mem
  request, PG w/ 3-core request + wm-critical priorityClass + oomImmune +
  maxConnections, app w/ wm-critical + oomImmune + no resource limits.
- sim/values/local.example.yaml — template for the gitignored local.yaml
  that carries the EE license key.
- Depends on the wm-critical PriorityClass + oomImmune + maxConnections
  knobs landing in windmill-helm-charts (separate PR).

graph.ts additions:
- areaFills param: ordered list of per-kind translucent area fills drawn
  before lines, used by the util panel for orange-behind-blue layering
- lineColorOverrides: pin per-kind line colors so oversaturation reliably
  renders orange regardless of d3 ordinal-color insertion order
- highlightKindToken: substring-match flag for the PG-node tint in Node CPU
- xRelativeOriginMs: shared bench-start origin for the relative-time x-axis
- DataPointMulti is now exported for downstream tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:43:47 +02:00

793 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
import { JSDOM } from "https://jspm.dev/jsdom@22";
type DataPoint = {
value: number;
date: Date;
};
export function drawGraph(
data: DataPoint[],
title: string,
yLabel: string = "[jobs/s]",
yMax?: number,
) {
const context = {
jsdom: new JSDOM(""),
};
const { window } = context.jsdom;
const { document } = window;
const body = d3.select(document).select("body");
// Time-series chart — 900px wide so the many-points lines (throughput,
// CPU util, memory, PG memory etc.) have room to read individual peaks.
// drawBars/drawDonut below stay at 600 since bar charts don't benefit
// from extra width.
const width = 900;
const height = 200;
const marginTop = 20;
const marginRight = 30;
const marginBottom = 30;
const marginLeft = 60;
let svg = body
.append("svg")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("width", width + marginLeft + marginRight)
.attr("height", height + marginTop + marginBottom);
svg
.append("rect")
.attr("width", "100%")
.attr("height", "100%")
.attr("fill", "white");
svg = svg
.append("g")
.attr("transform", "translate(" + marginLeft + "," + marginTop + ")");
const x = d3
.scaleTime()
.domain(
d3.extent(data, function (d: DataPoint) {
return d.date;
})
)
// No .nice() on the time scale — it rounds the domain to "nice" tick
// boundaries (e.g. extends domain to next 30s mark), pushing the first
// few seconds of data off the left edge so phase 1 (idle baseline) and
// m02's CPU curve appear to "start from the middle". Exact data extent
// keeps the line starting at the actual first sample.
.range([0, width]);
// Relative-time x-axis: labels show seconds since the earliest data point
// in this chart ("0s, 30s, 60s ..."). Wall-clock HH:MM:SS labels were
// confusing — they alternated between "03:45" and ":30" formats and made
// same-time comparisons across panels hard. Relative units anchor every
// panel at 0s.
const xDom = x.domain() as [Date, Date];
const xOriginMs = xRelativeOriginMs ?? xDom[0].getTime();
const xAxis = d3.axisBottom(x).ticks(5).tickFormat((d) => {
const sec = Math.round(((d as Date).getTime() - xOriginMs) / 1000);
return `${sec}s`;
});
svg
.append("g")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
// Add Y axis. When yMax is set, also `.clamp(true)` so out-of-range data
// values (e.g. a CPU spike to 250% on a chart capped at 150%) render at the
// axis boundary instead of escaping the chart area entirely.
const y = d3
.scaleLinear()
.domain([
0,
yMax !== undefined ? yMax : d3.max(data, function (d: DataPoint) {
return +d.value;
}) * 1.5,
])
.range([height, 0])
.clamp(yMax !== undefined)
.nice();
svg.append("g").call(d3.axisLeft(y));
svg
.append("text")
.attr("text-anchor", "middle")
.attr("style", "font-size: 12px")
.attr("transform", "rotate(-90)")
.attr("y", -marginLeft + 20)
.attr("x", -height / 2)
.text(yLabel);
svg
.append("text")
.attr("text-anchor", "middle")
.attr("style", "font-size: 16px")
.attr("y", 0)
.attr("x", width / 2)
.text(title);
// Add the line
svg
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr(
"d",
d3.line(
function (d: DataPoint) {
return x(d.date);
},
function (d: DataPoint) {
return y(d.value);
}
)
);
return body.node().innerHTML;
}
export interface DataPointMulti extends DataPoint {
kind: string;
}
export function drawGraphMulti(
data: DataPointMulti[],
title: string,
yLabel: string = "[jobs/s]",
// When set, fixes the chart Y axis at [0, yMax] instead of auto-scaling to
// 1.5x the max sample. Use for percent-based panels (Node CPU at 150 caps
// noise spikes that would otherwise compress the rest of the chart).
yMax?: number,
// Optional vertical dashed lines at given timestamps. Used by the phased
// bench to mark phase boundaries (warmup → peak → cooldown, etc.) on every
// time-series panel so you can attribute throughput/CPU shifts to phases.
// Accepts a plain Date[] (lines + labels drawn) or `{ dates, hideLabels }`
// — the renderer suppresses the P1>P2 labels on charts other than the
// "primary" one so the dashboard doesn't end up with 27 boundary labels
// (3 per chart × 9 charts) which reads as duplication side-by-side.
verticalLines?: Date[] | { dates: Date[]; hideLabels?: boolean },
// Optional horizontal reference lines at given y-values (in chart units).
// Drawn as labeled dashed grey lines — used to show e.g. the 100% per-VM
// ceiling on the Node CPU chart so saturation is obvious at a glance.
horizontalLines?: { y: number; label: string }[],
// Optional translucent shaded rectangles spanning [from, to] on the x-axis.
// Used to mark the "push window" on Throughput / Queue depth / Node CPU so
// it's visually obvious which range is "bench actively pushing" vs
// "drain only" without staring at vertical phase boundaries.
shadedZones?: { from: Date; to: Date; fill?: string; label?: string }[],
// Optional substring match: if a series' `kind` contains this token, render
// a translucent colored area fill under its curve so it stands out as the
// "tinted" series. Used to flag the PG-hosting node in Node CPU.
highlightKindToken?: string,
// Optional ordered list of area fills drawn BEFORE the lines (back to front
// in this order, so the first entry is the backmost). Each entry tints any
// series whose `kind` exactly equals `kind`. Used for the util group where
// we want oversaturation as the backmost orange band with CPU util on top.
areaFills?: { kind: string; color: string; opacity?: number }[],
// Optional per-kind line color override. Without it, lines use d3's
// schemeCategory10 in the order kinds were inserted into sumstat — which
// means "oversaturation" wouldn't naturally come out orange. Used by the
// util group to pin each series' line color to its area fill color.
lineColorOverrides?: Record<string, string>,
// Optional shared origin (epoch ms) for the relative-time x-axis. When
// unset, each chart uses its own earliest data point — fine in isolation
// but inconsistent across panels because pollers / pushers / CPU samples
// start at slightly different moments. Pass meta.json's `bench_start_ms`
// here so 0s on every panel is the same wall-clock moment.
xRelativeOriginMs?: number,
) {
const context = {
jsdom: new JSDOM(""),
};
const { window } = context.jsdom;
const { document } = window;
const body = d3.select(document).select("body");
// Multi-series time chart — 900px wide for the same reason as drawGraph above.
// Throughput, Queue depth, Node CPU/memory, PG memory, Failed jobs, Workers per
// node all flow through this; bumping width here is the user-requested
// "make the lots-of-points charts 50% wider".
const width = 900;
const height = 200;
const marginTop = 20;
const marginRight = 100;
const marginBottom = 30;
const marginLeft = 60;
let svg = body
.append("svg")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("width", width + marginLeft + marginRight)
.attr("height", height + marginTop + marginBottom);
svg
.append("rect")
.attr("width", "100%")
.attr("height", "100%")
.attr("fill", "white");
svg = svg
.append("g")
.attr("transform", "translate(" + marginLeft + "," + marginTop + ")");
const x = d3
.scaleTime()
.domain(
d3.extent(data, function (d: DataPoint) {
return d.date;
})
)
// No .nice() on the time scale — it rounds the domain to "nice" tick
// boundaries (e.g. extends domain to next 30s mark), pushing the first
// few seconds of data off the left edge so phase 1 (idle baseline) and
// m02's CPU curve appear to "start from the middle". Exact data extent
// keeps the line starting at the actual first sample.
.range([0, width]);
// Relative-time x-axis: labels show seconds since the earliest data point
// in this chart ("0s, 30s, 60s ..."). Wall-clock HH:MM:SS labels were
// confusing — they alternated between "03:45" and ":30" formats and made
// same-time comparisons across panels hard. Relative units anchor every
// panel at 0s.
const xDom = x.domain() as [Date, Date];
const xOriginMs = xRelativeOriginMs ?? xDom[0].getTime();
const xAxis = d3.axisBottom(x).ticks(5).tickFormat((d) => {
const sec = Math.round(((d as Date).getTime() - xOriginMs) / 1000);
return `${sec}s`;
});
svg
.append("g")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
// Add Y axis. When yMax is set, also `.clamp(true)` so out-of-range data
// values (e.g. a CPU spike to 250% on a chart capped at 150%) render at the
// axis boundary instead of escaping the chart area entirely.
const y = d3
.scaleLinear()
.domain([
0,
yMax !== undefined ? yMax : d3.max(data, function (d: DataPoint) {
return +d.value;
}) * 1.5,
])
.range([height, 0])
.clamp(yMax !== undefined)
.nice();
svg.append("g").call(d3.axisLeft(y));
svg
.append("text")
.attr("text-anchor", "middle")
.attr("style", "font-size: 12px")
.attr("transform", "rotate(-90)")
.attr("y", -marginLeft + 20)
.attr("x", -height / 2)
.text(yLabel);
svg
.append("text")
.attr("text-anchor", "middle")
.attr("style", "font-size: 16px")
.attr("y", 0)
.attr("x", width / 2)
.text(title);
const sumstat = d3.group(data, function (d: DataPointMulti) {
return d.kind;
});
const keys = Array.from(sumstat.keys());
const color = d3
.scaleOrdinal()
.domain(keys)
.range([
"#e41a1c",
"#377eb8",
"#4daf4a",
"#984ea3",
"#ff7f00",
"#ffff33",
"#a65628",
"#f781bf",
"#999999",
]);
// Shaded zones (e.g. push window) — drawn BEFORE the lines so lines
// paint on top. Translucent fill so the chart underneath stays
// readable. Optional label hugs the top-left of the zone.
if (shadedZones && shadedZones.length > 0) {
svg
.selectAll(".shaded-zone")
.data(shadedZones)
.enter()
.append("rect")
.attr("class", "shaded-zone")
.attr("x", (d: { from: Date }) => x(d.from))
.attr("width", (d: { from: Date; to: Date }) => Math.max(0, x(d.to) - x(d.from)))
.attr("y", 0)
.attr("height", height)
.attr("fill", (d: { fill?: string }) => d.fill ?? "#1f77b4")
.attr("fill-opacity", 0.08)
.attr("stroke", "none");
svg
.selectAll(".shaded-zone-label")
.data(shadedZones.filter((z: { label?: string }) => z.label))
.enter()
.append("text")
.attr("class", "shaded-zone-label")
.attr("x", (d: { from: Date }) => x(d.from) + 4)
.attr("y", 22)
.attr("text-anchor", "start")
.style("font-size", "10px")
.style("font-weight", "600")
.style("fill", "#3a5d8a")
.style("font-family", "monospace")
.text((d: { label?: string }) => d.label!);
}
// Explicit area-fills list — drawn in order so caller controls back-to-front
// z-order. Each is the area under the matching series' curve.
if (areaFills && areaFills.length > 0) {
const entries = Array.from(sumstat as any) as any[];
for (const fill of areaFills) {
const match = entries.find((e: any) => String(e[0]) === fill.kind);
if (!match) continue;
svg.append("path")
.attr("class", "area-fill")
.attr("fill", fill.color)
.attr("fill-opacity", fill.opacity ?? 0.25)
.attr("stroke", "none")
.attr("d", d3
.area()
.x((p: any) => x(p.date))
.y0(height)
.y1((p: any) => y(p.value))(match[1]));
}
}
// Tinted area fill under the highlighted series — drawn BEFORE the line so
// line stays crisp on top. Translucent so the underlying x-axis and
// overlapping series remain visible. sumstat is a d3.InternMap (Map-like);
// convert to entries Array to filter.
if (highlightKindToken) {
const highlightSeries = Array.from(sumstat as any).filter(
(d: any) => String(d[0]).includes(highlightKindToken),
);
svg
.selectAll("path.tint")
.data(highlightSeries)
.join("path")
.attr("class", "tint")
.attr("fill", function (d: any) { return color(d[0]); })
.attr("fill-opacity", 0.18)
.attr("stroke", "none")
.attr("d", (d: any) => {
return d3
.area()
.x((p: any) => x(p.date))
.y0(height)
.y1((p: any) => y(p.value))(d[1]);
});
}
// Add the line
svg
.selectAll("path.line")
.data(sumstat)
.join("path")
.attr("class", "line")
.attr("fill", "none")
.attr("stroke", function (d) {
return (lineColorOverrides && lineColorOverrides[String(d[0])]) ?? color(d[0]);
})
.attr("stroke-width", 1.5)
.attr("d", (d) => {
return d3
.line()
.x((d) => x(d.date))
.y((d) => y(d.value))(d[1]);
});
const size = 15;
svg
.selectAll(".dot")
.data(keys)
.enter()
.append("rect")
.attr("class", "dot")
.attr("x", 400)
.attr("y", function (d, i) {
return 5 + i * (size + 5);
})
.attr("width", size)
.attr("height", size)
.style("fill", function (d) {
return color(d);
});
svg
.selectAll(".label")
.data(keys)
.enter()
.append("text")
.attr("class", "label")
.attr("x", 400 + size * 1.2)
.attr("y", function (d, i) {
return 5 + i * (size + 5) + size / 2;
})
.style("fill", function (d) {
return color(d);
})
.text(function (d) {
return d;
})
.attr("text-anchor", "left")
.style("alignment-baseline", "middle");
// Phase-boundary dashed verticals. Drawn last so they overlay the data lines.
// Each line gets a tiny "P{n}>P{n+1}" label at the top so the migration is
// visible at a glance — useful for phased benches where throughput / CPU
// shifts between phases.
// Normalize verticalLines into { dates, hideLabels }.
const vlNormalized = Array.isArray(verticalLines)
? { dates: verticalLines, hideLabels: false }
: verticalLines;
if (vlNormalized && vlNormalized.dates.length > 0) {
svg
.selectAll(".phase-boundary")
.data(vlNormalized.dates)
.enter()
.append("line")
.attr("class", "phase-boundary")
.attr("x1", (d: Date) => x(d))
.attr("x2", (d: Date) => x(d))
.attr("y1", 0)
.attr("y2", height)
.attr("stroke", "#888")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "3 3");
if (!vlNormalized.hideLabels) {
svg
.selectAll(".phase-boundary-label")
.data(vlNormalized.dates)
.enter()
.append("text")
.attr("class", "phase-boundary-label")
.attr("x", (d: Date) => x(d) + 2)
.attr("y", 10)
.attr("text-anchor", "start")
.style("font-size", "9px")
.style("fill", "#666")
.style("font-family", "monospace")
.text((_: Date, i: number) => `P${i + 1}>P${i + 2}`);
}
}
// Horizontal reference lines (e.g. CPU 100% ceiling) — labeled dashed
// grey, with the label hugging the right edge so it doesn't collide
// with the data lines.
if (horizontalLines && horizontalLines.length > 0) {
svg
.selectAll(".h-ref")
.data(horizontalLines)
.enter()
.append("line")
.attr("class", "h-ref")
.attr("x1", 0)
.attr("x2", width)
.attr("y1", (d: { y: number; label: string }) => y(d.y))
.attr("y2", (d: { y: number; label: string }) => y(d.y))
.attr("stroke", "#999")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4 4");
svg
.selectAll(".h-ref-label")
.data(horizontalLines)
.enter()
.append("text")
.attr("class", "h-ref-label")
.attr("x", width - 4)
.attr("y", (d: { y: number; label: string }) => y(d.y) - 4)
.attr("text-anchor", "end")
.style("font-size", "9px")
.style("fill", "#666")
.style("font-family", "monospace")
.text((d: { y: number; label: string }) => d.label);
}
return body.node().innerHTML;
}
// Bar chart for one-dimensional distributions. Each bin is { label, count }.
// Works for both continuous (histogram bins, label = midpoint as string) and
// categorical (label = category name). Summary stats are rendered as text in
// the top-right.
export function drawBars(
bins: { label: string; count: number; color?: string }[],
title: string,
xLabel: string,
stats?: { min: number; max: number; avg: number },
opts: { rotateDeg?: number; fontSize?: string } = {},
): string {
const context = { jsdom: new JSDOM("") };
const { document } = context.jsdom.window;
const body = d3.select(document).select("body");
const width = 600;
const height = 200;
const marginTop = 30;
const marginRight = 30;
// 100px bottom margin so long rotated tick labels (e.g. OOM panel pod
// names like "windmill-postgresql-0 (12.5G) (cgroup)") aren't cropped.
const marginBottom = 100;
const marginLeft = 60;
let svg = body
.append("svg")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("width", width + marginLeft + marginRight)
.attr("height", height + marginTop + marginBottom);
svg
.append("rect")
.attr("width", "100%")
.attr("height", "100%")
.attr("fill", "white");
svg = svg
.append("g")
.attr("transform", "translate(" + marginLeft + "," + marginTop + ")");
// Title
svg
.append("text")
.attr("x", width / 2)
.attr("y", -10)
.attr("text-anchor", "middle")
.style("font-size", "14px")
.style("font-weight", "600")
.text(title);
const x = d3.scaleBand()
.domain(bins.map((b) => b.label))
.range([0, width])
.padding(0.1);
const y = d3.scaleLinear()
.domain([0, d3.max(bins, (b) => b.count) || 1])
.nice()
.range([height, 0]);
// X axis — label EVERY bar so the user can read what each one is.
// rotateDeg / fontSize are caller-tunable: -45° + 10px is fine for
// ~12 bars; -90° + smaller font lets a chart fit ~100 labels (per-pod
// memory bars on a ~80-worker cluster).
const rotateDeg = opts.rotateDeg ?? -45;
const fontSize = opts.fontSize ?? (bins.length > 12 ? "9px" : "10px");
svg.append("g")
.attr("transform", "translate(0," + height + ")")
.call(
d3.axisBottom(x).tickValues(bins.map((b) => b.label)),
)
.selectAll("text")
.attr("transform", `rotate(${rotateDeg})`)
.style("text-anchor", "end")
.style("font-size", fontSize);
svg.append("text")
.attr("x", width / 2)
.attr("y", height + 90) // ↓ below rotated tick labels (was 45)
.attr("text-anchor", "middle")
.style("font-size", "11px")
.text(xLabel);
svg.append("g").call(d3.axisLeft(y));
svg.selectAll(".bar")
.data(bins)
.join("rect")
.attr("class", "bar")
.attr("x", (b) => x(b.label) || 0)
.attr("y", (b) => y(b.count))
.attr("width", x.bandwidth())
.attr("height", (b) => height - y(b.count))
.attr("fill", (b: { color?: string }) => b.color ?? "#377eb8");
// Dashed vertical divider lines between contiguous groups of same-color
// bars. With the Pod inventory chart (one node = one color), this draws a
// separator between m02's run of bars and m03's, etc., making it
// visually obvious which bars belong to which node.
const groupBoundaries: number[] = [];
for (let i = 1; i < bins.length; i++) {
if (bins[i].color !== bins[i - 1].color) groupBoundaries.push(i);
}
if (groupBoundaries.length > 0) {
const bandStep = x.step();
svg.selectAll(".group-divider")
.data(groupBoundaries)
.enter()
.append("line")
.attr("class", "group-divider")
.attr("x1", (i: number) => (x(bins[i].label) || 0) - bandStep * (1 - x.bandwidth() / bandStep) / 2)
.attr("x2", (i: number) => (x(bins[i].label) || 0) - bandStep * (1 - x.bandwidth() / bandStep) / 2)
.attr("y1", 0)
.attr("y2", height + 5)
.attr("stroke", "#aaa")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "3 3");
}
// Stats text top-right.
if (stats) {
const fmt = (n: number) =>
n >= 1000 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2);
const lines = [
`min: ${fmt(stats.min)}`,
`avg: ${fmt(stats.avg)}`,
`max: ${fmt(stats.max)}`,
];
svg.selectAll(".stat")
.data(lines)
.enter()
.append("text")
.attr("class", "stat")
.attr("x", width)
.attr("y", (_, i) => 12 + i * 14)
.attr("text-anchor", "end")
.style("font-size", "11px")
.style("font-family", "monospace")
.style("fill", "#555")
.text((d) => d);
}
return body.node().innerHTML;
}
// Donut chart for categorical distributions. Slices labeled with category +
// percentage; legend on the right.
export function drawDonut(
slices: { label: string; count: number }[],
title: string,
): string {
const context = { jsdom: new JSDOM("") };
const { document } = context.jsdom.window;
const body = d3.select(document).select("body");
const width = 600;
const height = 220;
const marginTop = 30;
const marginBottom = 10;
const marginLeft = 20;
const marginRight = 20;
const total = slices.reduce((a, b) => a + b.count, 0) || 1;
let svg = body
.append("svg")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("width", width + marginLeft + marginRight)
.attr("height", height + marginTop + marginBottom);
svg
.append("rect")
.attr("width", "100%")
.attr("height", "100%")
.attr("fill", "white");
// Title
svg
.append("text")
.attr("x", (width + marginLeft + marginRight) / 2)
.attr("y", marginTop - 8)
.attr("text-anchor", "middle")
.style("font-size", "14px")
.style("font-weight", "600")
.text(title);
const r = Math.min(width, height) / 2 - 10;
const cx = marginLeft + r + 10;
const cy = marginTop + height / 2;
const palette = [
"#377eb8", "#e41a1c", "#4daf4a", "#984ea3",
"#ff7f00", "#a65628", "#f781bf", "#999999",
];
const color = (i: number) => palette[i % palette.length];
const pie = d3.pie<{ label: string; count: number }>().value((d) => d.count).sort(null);
const arc = d3.arc<d3.PieArcDatum<{ label: string; count: number }>>()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcs = pie(slices);
const g = svg.append("g").attr("transform", `translate(${cx},${cy})`);
g.selectAll("path")
.data(arcs)
.join("path")
.attr("d", arc as never)
.attr("fill", (_, i) => color(i))
.attr("stroke", "white")
.attr("stroke-width", 2);
// Legend on the right
const legendX = cx + r + 30;
const legendY = marginTop + 20;
const sw = 12;
slices.forEach((s, i) => {
const pct = ((s.count / total) * 100).toFixed(1);
svg.append("rect")
.attr("x", legendX)
.attr("y", legendY + i * 22)
.attr("width", sw)
.attr("height", sw)
.attr("fill", color(i));
svg.append("text")
.attr("x", legendX + sw + 6)
.attr("y", legendY + i * 22 + sw - 1)
.style("font-size", "12px")
.style("font-family", "monospace")
.style("fill", "#333")
.text(`${s.label} ${pct}%`);
});
return body.node().innerHTML;
}
if (import.meta.main) {
const svg = drawGraph(
[
{
value: 10,
date: new Date(86400000),
},
{
value: 12,
date: new Date(86400000 * 2),
},
],
"test"
);
const svg2 = drawGraphMulti(
[
{
value: 10,
date: new Date(86400000),
kind: "test",
},
{
value: 12,
date: new Date(86400000 * 2),
kind: "test",
},
{
value: 8,
date: new Date(86400000),
kind: "test2",
},
{
value: 9,
date: new Date(86400000 * 2),
kind: "test2",
},
],
"test"
);
console.log(svg);
console.log(svg2);
Deno.exit(0);
}