diff --git a/CHANGELOG.md b/CHANGELOG.md index 4876811..06838b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Routine `stats.json` refreshes take a PATCH version bump but are grouped rather than listed individually. +## [0.28.1] - 2026-09-22 + +### Added +- Numbers page: the tender-size chart — single-bidder share per size band as three small multiples (construction, services, goods), one axis, one hue, direct labels on the ends, hover for awards. The "smaller tender, less competition" slope now reads at a glance; the table stays below it. The agency league stays a table on purpose: its shares are all 90–100% and the names are the content. + ## [0.28.0] - 2026-09-22 ### Changed diff --git a/VERSION b/VERSION index 697f087..48f7a71 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.0 +0.28.1 diff --git a/src/components/ProcurementNumbers.astro b/src/components/ProcurementNumbers.astro index 4c6f563..21be473 100644 --- a/src/components/ProcurementNumbers.astro +++ b/src/components/ProcurementNumbers.astro @@ -12,7 +12,7 @@ const calcHref = locale === 'ko' ? '/ko/procurement/#calculator' : '/procurement --- -
+

…

@@ -33,7 +33,8 @@ const calcHref = locale === 'ko' ? '/ko/procurement/#calculator' : '/procurement
{copy.depth.heading}

{copy.depth.lead}

-
{copy.categories.cat}{copy.depth.band}{copy.depth.awards}{copy.depth.bidders}{copy.depth.solo}
+ +
{copy.categories.cat}{copy.depth.band}{copy.depth.awards}{copy.depth.bidders}{copy.depth.solo}
@@ -41,7 +42,7 @@ const calcHref = locale === 'ko' ? '/ko/procurement/#calculator' : '/procurement

{copy.league.lead}

{copy.league.most}

-
{copy.league.agency}{copy.league.awards}{copy.league.solo}{copy.league.share}{copy.league.rate}
+
{copy.league.agency}{copy.league.awards}{copy.league.solo}{copy.league.share}{copy.league.rate}

{copy.league.least}

{copy.league.agency}{copy.league.awards}{copy.league.solo}{copy.league.share}{copy.league.rate}
@@ -93,6 +94,40 @@ const calcHref = locale === 'ko' ? '/ko/procurement/#calculator' : '/procurement rows.forEach(function (r) { var tr = document.createElement('tr'); td(tr, r.agency); td(tr, fmt(r.awards), true); td(tr, fmt(r.solo), true); td(tr, pct(r.share, 0), true); td(tr, r.solo_rate == null ? '—' : r.solo_rate.toFixed(1) + '%', true); tb.appendChild(tr); }); } + // Solo-award share by tender size: one small multiple per category, one + // hue, the same y axis, so the "smaller tender, less competition" slope + // reads without a legend. Bands are ordered by size, not by the data. + function depthChart(rows) { + var host = root.querySelector('[data-n-depthchart]'); if (!host) return; host.innerHTML = ''; + var order = ['<10M', '10M-50M', '50M-200M', '200M-1B', '>=1B']; + var cats = []; rows.forEach(function (r) { if (cats.indexOf(r.cat) < 0) cats.push(r.cat); }); cats.sort(); + var panelW = 210, gap = 28, left = 44, W = left + cats.length * (panelW + gap), H = 196, top = 44, bottom = 40; + var short = { '<10M': '<10M', '10M-50M': '10–50M', '50M-200M': '50–200M', '200M-1B': '200M–1B', '>=1B': '≥1B' }; + var y = function (v) { return top + (1 - v) * (H - top - bottom); }; + var svg = el('svg', { viewBox: '0 0 ' + W + ' ' + H, class: 'viz' }); + svg.appendChild(el('text', { x: 0, y: 12, class: 'viz-axis' }, root.getAttribute('data-solo-label') || '')); + [0, 0.5, 1].forEach(function (v) { + svg.appendChild(el('text', { x: left - 6, y: y(v) + 4, 'text-anchor': 'end', class: 'viz-axis' }, Math.round(v * 100) + '%')); + }); + cats.forEach(function (cat, ci) { + var x0 = left + ci * (panelW + gap), slot = panelW / order.length, bw = Math.max(4, slot - 8); + svg.appendChild(el('text', { x: x0, y: 30, class: 'viz-label' }, label(cat))); + [0, 0.5, 1].forEach(function (v) { svg.appendChild(el('line', { x1: x0, y1: y(v), x2: x0 + panelW, y2: y(v), class: 'viz-grid' })); }); + order.forEach(function (band, bi) { + var r = rows.filter(function (q) { return q.cat === cat && q.band === band; })[0]; + var bx = x0 + bi * slot + (slot - bw) / 2; + if (r && r.solo_share != null) { + var by = y(r.solo_share); + var bar = el('rect', { x: bx, y: by, width: bw, height: Math.max(0, H - bottom - by), rx: 3, class: 'viz-bar' }); + bar.appendChild(el('title', {}, label(cat) + ' · ' + band + ' · ' + pct(r.solo_share, 1) + ' uncontested · ' + fmt(r.awards) + ' awards')); + svg.appendChild(bar); + if (bi === 0 || bi === order.length - 1) svg.appendChild(el('text', { x: bx + bw / 2, y: by - 4, 'text-anchor': 'middle', class: 'viz-value' }, pct(r.solo_share, 0))); + } + svg.appendChild(el('text', { x: bx + bw / 2, y: H - (bi % 2 ? 8 : 20), 'text-anchor': 'middle', class: 'viz-axis', 'font-size': '10' }, short[band] || band)); + }); + }); + host.appendChild(svg); + } function render(d) { var span = root.getAttribute('data-span').replace('{from}', d.span.from).replace('{to}', d.span.to).replace('{awards}', fmt(d.span.awards)).replace('{as_of}', d.as_of); root.querySelector('[data-n-span]').textContent = span; @@ -105,6 +140,7 @@ const calcHref = locale === 'ko' ? '/ko/procurement/#calculator' : '/procurement var dp = root.querySelector('[data-n-depth]'); dp.innerHTML = ''; var order = ['<10M', '10M-50M', '50M-200M', '200M-1B', '>=1B']; d.depth.slice().sort(function (a, b) { return (a.cat < b.cat ? -1 : a.cat > b.cat ? 1 : 0) || order.indexOf(a.band) - order.indexOf(b.band); }).forEach(function (r) { var tr = document.createElement('tr'); td(tr, label(r.cat)); td(tr, r.band); td(tr, fmt(r.awards), true); td(tr, r.bidders == null ? '—' : String(r.bidders), true); td(tr, pct(r.solo_share, 1), true); dp.appendChild(tr); }); + depthChart(d.depth); league('[data-n-most]', d.most_uncontested); league('[data-n-least]', d.most_contested); root.querySelector('[data-n-floor]').textContent = root.getAttribute('data-floor').replace('{n}', d.min_agency_awards);