fullstackgtm 0.20.0 → 0.21.1
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.
- package/CHANGELOG.md +52 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/market.d.ts +14 -0
- package/dist/marketReport.js +183 -89
- package/dist/marketScale.d.ts +53 -24
- package/dist/marketScale.js +144 -44
- package/package.json +1 -1
- package/src/index.ts +9 -1
- package/src/market.ts +14 -0
- package/src/marketReport.ts +189 -89
- package/src/marketScale.ts +204 -64
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.21.1] — 2026-06-12
|
|
9
|
+
|
|
10
|
+
Report design pass: analyst-grade restraint, legible dense scatters.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **De-slopped the field report**: removed the rotated stamp, paper-grain
|
|
15
|
+
background, parchment palette, letterspaced kickers, and editorial hero
|
|
16
|
+
voice. White page, hairline rules, plain headings — the design recedes,
|
|
17
|
+
the data reads.
|
|
18
|
+
- **Strategic map legibility**: bubbles are now numbered and colored
|
|
19
|
+
(Okabe–Ito colorblind-safe palette) with a legend table beside the chart
|
|
20
|
+
that doubles as the share table (number · color · vendor · est. share or
|
|
21
|
+
LOUD count, anchor bolded). Larger bubbles render first so overlapping
|
|
22
|
+
clusters stay readable; the number resolves what overlapping name labels
|
|
23
|
+
never could. Hover tooltips keep the names.
|
|
24
|
+
- **Axis pole labels can no longer collide**: wrapped to ≤2 short lines
|
|
25
|
+
(parentheticals dropped first), x poles at the bottom corners, y poles
|
|
26
|
+
rotated along the left margin — four positions, standard chart
|
|
27
|
+
convention.
|
|
28
|
+
|
|
29
|
+
## [0.21.0] — 2026-06-12
|
|
30
|
+
|
|
31
|
+
Scale estimation v2 — dimensional, calibrated, SMB-bias-robust.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **`market scale` estimates revenue, not a normalized blend.** v1 averaged
|
|
36
|
+
[0,1]-normalized signals, which quietly mixed dimensions: review/customer
|
|
37
|
+
counts proxy CUSTOMER COUNT while employees/revenue proxy REVENUE, so
|
|
38
|
+
many-small-customer vendors outranked fewer-bigger-customer ones (observed
|
|
39
|
+
live: a ~$20M SMB dialer outranked a ~$33M mid-market platform). v2
|
|
40
|
+
converts every signal into revenue space first: revenue signals are used
|
|
41
|
+
directly; headcount × a revenue-per-employee ratio; customer counts × a
|
|
42
|
+
revenue-per-customer ratio **calibrated within the set (median over
|
|
43
|
+
vendors that have both) and stratified by the vendor's new `acvBand`** —
|
|
44
|
+
revenue-per-review spans ~75× between SMB tools and enterprise suites,
|
|
45
|
+
which is the bias, killed at the source. Per-vendor output is an
|
|
46
|
+
estimated revenue (weighted geometric mean: revenue 3 / headcount 2 /
|
|
47
|
+
customers 1) with a disclosed max/min **uncertainty spread**, an index =
|
|
48
|
+
share of the set's summed estimates, and the full calibration table.
|
|
49
|
+
Uncalibratable metrics (no revenue pair anywhere) are skipped and named.
|
|
50
|
+
- Report bubbles: dot area ∝ estimated revenue share (normalized to the
|
|
51
|
+
set's max for visual range — ratios preserved); caption now says
|
|
52
|
+
"estimated revenue share … citable but NOT audited" and points at
|
|
53
|
+
`market scale` for the per-vendor estimates and spreads.
|
|
54
|
+
- New config fields: `MarketVendor.acvBand` ("smb" | "mid" | "enterprise"
|
|
55
|
+
by convention — usually obvious from the pricing page the map already
|
|
56
|
+
captures) and `ScaleSignal.dimension` override.
|
|
57
|
+
- SMB-bias regression test: many cheap-product reviews must not outrank
|
|
58
|
+
fewer expensive-product reviews when band calibration says otherwise.
|
|
59
|
+
|
|
8
60
|
## [0.20.0] — 2026-06-12
|
|
9
61
|
|
|
10
62
|
The directive layer: the market map joined to your own CRM ground truth.
|
package/dist/index.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export { captureMarket, computeFrontStates, createFileObservationStore, diffFron
|
|
|
24
24
|
export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
|
|
25
25
|
export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
|
|
26
26
|
export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
|
|
27
|
-
export { computeScaleIndex, scaleReportToText, type ScaleReport, type VendorScale } from "./marketScale.ts";
|
|
27
|
+
export { computeScaleIndex, dimensionForMetric, scaleReportToText, type ScaleDimension, type ScaleReport, type SignalEstimate, type VendorScale, } from "./marketScale.ts";
|
|
28
28
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
29
29
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
30
30
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,6 @@ export { captureMarket, computeFrontStates, createFileObservationStore, diffFron
|
|
|
24
24
|
export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
|
|
25
25
|
export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
|
|
26
26
|
export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
|
|
27
|
-
export { computeScaleIndex, scaleReportToText } from "./marketScale.js";
|
|
27
|
+
export { computeScaleIndex, dimensionForMetric, scaleReportToText, } from "./marketScale.js";
|
|
28
28
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
29
29
|
export { suggestValues } from "./suggest.js";
|
package/dist/market.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export type ScaleSignal = {
|
|
|
53
53
|
quote: string;
|
|
54
54
|
asOf: string;
|
|
55
55
|
caveat?: string;
|
|
56
|
+
/**
|
|
57
|
+
* What the signal proxies: revenue (used directly), headcount, or
|
|
58
|
+
* customers (count-of-customers proxies like reviews). Inferred from the
|
|
59
|
+
* metric name when omitted; set explicitly for unusual metrics.
|
|
60
|
+
*/
|
|
61
|
+
dimension?: "revenue" | "headcount" | "customers";
|
|
56
62
|
};
|
|
57
63
|
export type MarketVendor = {
|
|
58
64
|
id: string;
|
|
@@ -67,6 +73,14 @@ export type MarketVendor = {
|
|
|
67
73
|
aliases?: string[];
|
|
68
74
|
/** Public scale signals; see ScaleSignal. */
|
|
69
75
|
scaleSignals?: ScaleSignal[];
|
|
76
|
+
/**
|
|
77
|
+
* ACV stratum ("smb" | "mid" | "enterprise" by convention) used to
|
|
78
|
+
* calibrate customer-count → revenue conversion in the scale index.
|
|
79
|
+
* Revenue-per-customer differs ~75× between SMB tools and enterprise
|
|
80
|
+
* suites; stratifying kills the many-small-customers bias. Usually
|
|
81
|
+
* obvious from the vendor's own pricing page (which the map captures).
|
|
82
|
+
*/
|
|
83
|
+
acvBand?: string;
|
|
70
84
|
notes?: string;
|
|
71
85
|
};
|
|
72
86
|
export type MarketAxis = {
|
package/dist/marketReport.js
CHANGED
|
@@ -79,10 +79,64 @@ export function marketMapToMarkdown(config, set) {
|
|
|
79
79
|
}
|
|
80
80
|
return `${lines.join("\n")}\n`;
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Okabe–Ito palette: categorical, colorblind-safe, print-stable. Vendors are
|
|
84
|
+
* numbered in legend order so a dense cluster stays readable — the number in
|
|
85
|
+
* the bubble resolves what overlapping labels never could.
|
|
86
|
+
*/
|
|
87
|
+
const VENDOR_COLORS = [
|
|
88
|
+
"#0072b2", "#e69f00", "#009e73", "#d55e00", "#cc79a7",
|
|
89
|
+
"#56b4e9", "#b8a000", "#717171", "#882255", "#44aa99",
|
|
90
|
+
];
|
|
91
|
+
/** Dark numerals on light fills, white on dark — simple luminance cut. */
|
|
92
|
+
function numeralColor(hex) {
|
|
93
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
94
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
95
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
96
|
+
return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#1c1c1c" : "#ffffff";
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Wrap an axis-pole label into at most two short lines. Parentheticals are
|
|
100
|
+
* dropped first — poles like "REGULATED LEAD-GEN OPERATIONS (sales-led PEO)"
|
|
101
|
+
* keep their head, and nothing ever runs into the opposite corner.
|
|
102
|
+
*/
|
|
103
|
+
function wrapPole(text, maxChars = 20) {
|
|
104
|
+
let cleaned = text.replace(/\s*\([^)]*\)/g, "").trim();
|
|
105
|
+
if (cleaned.length === 0)
|
|
106
|
+
cleaned = text.trim();
|
|
107
|
+
const words = cleaned.split(/\s+/);
|
|
108
|
+
const lines = [];
|
|
109
|
+
let current = "";
|
|
110
|
+
for (const word of words) {
|
|
111
|
+
if (current && (current + " " + word).length > maxChars) {
|
|
112
|
+
lines.push(current);
|
|
113
|
+
current = word;
|
|
114
|
+
if (lines.length === 2)
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
current = current ? `${current} ${word}` : word;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (lines.length < 2 && current)
|
|
122
|
+
lines.push(current);
|
|
123
|
+
if (lines.length === 2 && current && lines[1] !== current)
|
|
124
|
+
lines[1] = `${lines[1]}…`;
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
function poleText(lines, x, y, anchorMode, fs) {
|
|
128
|
+
const e = escapeHtml;
|
|
129
|
+
const spans = lines
|
|
130
|
+
.map((line, index) => `<tspan x="${x}" dy="${index === 0 ? 0 : fs + 2}">${e(line)}</tspan>`)
|
|
131
|
+
.join("");
|
|
132
|
+
return `<text class="ax-label" style="font-size:${fs}px" x="${x}" y="${y}" text-anchor="${anchorMode}">${spans}</text>`;
|
|
133
|
+
}
|
|
134
|
+
function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
|
|
135
|
+
const W = 640;
|
|
136
|
+
const H = 480;
|
|
137
|
+
const PAD_X = 56;
|
|
138
|
+
const PAD_TOP = 44;
|
|
139
|
+
const PAD_BOTTOM = 56;
|
|
86
140
|
const range = (axis, values) => {
|
|
87
141
|
if (axis.signed)
|
|
88
142
|
return [-1.1, 1.1];
|
|
@@ -92,28 +146,46 @@ function svgScatter(points, ax, ay, anchor, mini) {
|
|
|
92
146
|
};
|
|
93
147
|
const [xLo, xHi] = range(ax, points.map((p) => p.x));
|
|
94
148
|
const [yLo, yHi] = range(ay, points.map((p) => p.y));
|
|
95
|
-
const sx = (x) =>
|
|
96
|
-
const sy = (y) => H -
|
|
97
|
-
const fsLabel = mini ? 8.5 : 10.5;
|
|
98
|
-
const fsAx = mini ? 8 : 10;
|
|
149
|
+
const sx = (x) => PAD_X + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD_X);
|
|
150
|
+
const sy = (y) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
|
|
99
151
|
const e = escapeHtml;
|
|
100
|
-
|
|
152
|
+
// Big bubbles first so small ones stay clickable/visible on top.
|
|
153
|
+
const ordered = [...points].sort((a, b) => b.size - a.size);
|
|
154
|
+
const dots = ordered
|
|
101
155
|
.map((p) => {
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
156
|
+
const r = 7 + 24 * Math.sqrt(p.size);
|
|
157
|
+
const color = colorByVendor.get(p.vendorId) ?? "#717171";
|
|
158
|
+
const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
|
|
159
|
+
const number = numberByVendor.get(p.vendorId) ?? 0;
|
|
160
|
+
const fs = Math.max(10, Math.min(14, r * 0.9));
|
|
161
|
+
return (`<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}" fill="${color}" fill-opacity="0.88"${ring}>` +
|
|
162
|
+
`<title>${e(p.name)}</title></circle>` +
|
|
163
|
+
`<text x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) + fs * 0.36).toFixed(1)}" text-anchor="middle" ` +
|
|
164
|
+
`font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`);
|
|
107
165
|
})
|
|
108
166
|
.join("");
|
|
109
|
-
const midX = ax.signed ? `<line class="
|
|
110
|
-
const midY = ay.signed ? `<line class="
|
|
167
|
+
const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
|
|
168
|
+
const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
|
|
169
|
+
// Pole labels in four positions that cannot collide: x poles horizontal at
|
|
170
|
+
// the bottom corners; y poles rotated along the left margin (positive
|
|
171
|
+
// reading up toward the top, negative near the bottom) — the standard
|
|
172
|
+
// chart convention, wrapped to ≤2 short lines each.
|
|
173
|
+
const fsPole = 10;
|
|
174
|
+
const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
|
|
175
|
+
const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
|
|
176
|
+
const yLabel = (lines, yEdge, anchorMode) => {
|
|
177
|
+
const spans = lines
|
|
178
|
+
.map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
|
|
179
|
+
.join("");
|
|
180
|
+
return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
|
|
181
|
+
};
|
|
182
|
+
const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
|
|
183
|
+
const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
|
|
111
184
|
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
<text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${PAD - 10}">↑ ${e(ay.positivePole)}${ay.signed ? ` · ↓ ${e(ay.negativePole)}` : ""}</text>
|
|
185
|
+
<rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
|
|
186
|
+
${midX}${midY}
|
|
187
|
+
${xNeg}${xPos}
|
|
188
|
+
${yPos}${yNeg}
|
|
117
189
|
${dots}</svg>`;
|
|
118
190
|
}
|
|
119
191
|
function axisSectionsHtml(config, set) {
|
|
@@ -131,9 +203,12 @@ function axisSectionsHtml(config, set) {
|
|
|
131
203
|
const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
|
|
132
204
|
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
133
205
|
const maxLoud = Math.max(1, ...loudCounts.values());
|
|
134
|
-
|
|
206
|
+
// Bubble areas stay proportional to the metric; dividing by the max just
|
|
207
|
+
// spends the full visual range without distorting any ratio.
|
|
208
|
+
const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
|
|
209
|
+
const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
|
|
135
210
|
const sizeCaption = useScale
|
|
136
|
-
? `Dot area ∝
|
|
211
|
+
? `Dot area ∝ estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads)`
|
|
137
212
|
: "Dot area ∝ LOUD count";
|
|
138
213
|
const breadthAxis = {
|
|
139
214
|
id: "breadth",
|
|
@@ -178,9 +253,32 @@ function axisSectionsHtml(config, set) {
|
|
|
178
253
|
const axInfo = axisInfo.get(px);
|
|
179
254
|
const ayInfo = axisInfo.get(py);
|
|
180
255
|
const statusOf = (id) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
|
|
256
|
+
// Legend order doubles as bubble numbering: largest first, anchor bolded.
|
|
257
|
+
// The number inside each bubble resolves dense clusters that name labels
|
|
258
|
+
// never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
|
|
259
|
+
const points = pointsFor(px, py);
|
|
260
|
+
const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
|
|
261
|
+
const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
|
|
262
|
+
const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
|
|
263
|
+
const legendRows = legendOrder
|
|
264
|
+
.map((point) => {
|
|
265
|
+
const number = numberByVendor.get(point.vendorId);
|
|
266
|
+
const color = colorByVendor.get(point.vendorId);
|
|
267
|
+
const isAnchor = point.vendorId === config.anchorVendor;
|
|
268
|
+
const measure = useScale
|
|
269
|
+
? `${((scaleIndex.get(point.vendorId) ?? 0) * 100).toFixed(1)}%`
|
|
270
|
+
: `${loudCounts.get(point.vendorId) ?? 0} loud`;
|
|
271
|
+
return `<tr${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " · anchor" : ""}</td><td class="num">${measure}</td></tr>`;
|
|
272
|
+
})
|
|
273
|
+
.join("");
|
|
274
|
+
const legendMeasureHead = useScale ? "est. share" : "loud";
|
|
181
275
|
const strategicMap = `<section>
|
|
182
|
-
<h2
|
|
183
|
-
<figure
|
|
276
|
+
<h2>Strategic map: ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
277
|
+
<figure class="map">
|
|
278
|
+
<div class="map-row">
|
|
279
|
+
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
|
|
280
|
+
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
281
|
+
</div>
|
|
184
282
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
185
283
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
186
284
|
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
@@ -203,7 +301,6 @@ export function marketMapToHtml(config, set) {
|
|
|
203
301
|
const anchor = config.anchorVendor;
|
|
204
302
|
const e = escapeHtml;
|
|
205
303
|
const axisHtml = axisSectionsHtml(config, set);
|
|
206
|
-
const appendixNo = axisHtml.report ? "04" : "03";
|
|
207
304
|
const matrixRows = model.orderedClaimIds
|
|
208
305
|
.map((claimId) => {
|
|
209
306
|
const claim = claimsById.get(claimId);
|
|
@@ -250,81 +347,78 @@ export function marketMapToHtml(config, set) {
|
|
|
250
347
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
251
348
|
<title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
|
|
252
349
|
<style>
|
|
253
|
-
:root { --
|
|
350
|
+
:root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
|
|
254
351
|
* { box-sizing:border-box; margin:0; }
|
|
255
|
-
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background
|
|
256
|
-
max-width:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
.
|
|
264
|
-
.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
|
|
268
|
-
border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
|
|
269
|
-
h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
|
|
270
|
-
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
|
|
271
|
-
.fcard { background:var(--paper); padding:18px 18px 14px; }
|
|
272
|
-
.fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
|
|
273
|
-
.fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
|
|
352
|
+
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
|
|
353
|
+
max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
|
|
354
|
+
.mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
355
|
+
header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
|
|
356
|
+
h1 { font-size:27px; font-weight:600; line-height:1.2; }
|
|
357
|
+
.meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
|
|
358
|
+
section { margin-top:44px; }
|
|
359
|
+
h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
|
|
360
|
+
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
|
|
361
|
+
.fcard { background:#fff; padding:14px 16px 12px; }
|
|
362
|
+
.fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
|
|
363
|
+
.fcard span { font-size:11px; color:var(--soft); }
|
|
274
364
|
.fcard.open b { color:var(--accent); }
|
|
275
|
-
.openlist { margin-top:
|
|
276
|
-
.openlist li { margin:
|
|
277
|
-
.openlist .why { color:var(--
|
|
278
|
-
.
|
|
279
|
-
.lg { display:inline-flex; align-items:center; gap:
|
|
365
|
+
.openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
|
|
366
|
+
.openlist li { margin:3px 0 3px 20px; }
|
|
367
|
+
.openlist .why { color:var(--soft); font-size:13px; }
|
|
368
|
+
.key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
|
|
369
|
+
.lg { display:inline-flex; align-items:center; gap:6px; }
|
|
280
370
|
table { border-collapse:collapse; width:100%; margin-top:6px; }
|
|
281
|
-
thead th { border-bottom:
|
|
282
|
-
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px;
|
|
283
|
-
text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
|
|
371
|
+
thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
|
|
372
|
+
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
|
|
284
373
|
th.vh.anchor-col span { color:var(--green); font-weight:700; }
|
|
285
|
-
tbody th { text-align:left; font-weight:400; padding:
|
|
286
|
-
.claim-cap { display:block; font-size:
|
|
287
|
-
.claim-meta { display:block; font-size:9.5px; color:var(--
|
|
374
|
+
tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
|
|
375
|
+
.claim-cap { display:block; font-size:14px; }
|
|
376
|
+
.claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
|
|
288
377
|
td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
|
|
289
|
-
td.cell.anchor-col { background:
|
|
378
|
+
td.cell.anchor-col { background:var(--faint); }
|
|
290
379
|
td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
|
|
291
|
-
.g { display:inline-block; width:
|
|
380
|
+
.g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
|
|
292
381
|
.g-loud { background:var(--ink); }
|
|
293
|
-
.g-quiet { box-shadow:inset 0 0 0 2px
|
|
294
|
-
.g-absent { background:radial-gradient(circle at center,
|
|
295
|
-
.g-unobservable { background:repeating-linear-gradient(45deg,
|
|
382
|
+
.g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
|
|
383
|
+
.g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
|
|
384
|
+
.g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
|
|
296
385
|
tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
297
|
-
.chip { font-size:9px;
|
|
298
|
-
.chip-open { color:var(--accent); } .chip-contested { color:#
|
|
299
|
-
.chip-owned { color:var(--green); } .chip-saturated { color:var(--
|
|
300
|
-
.ev { border-bottom:1px solid var(--line); padding:
|
|
301
|
-
.ev-head { font-size:10.5px;
|
|
302
|
-
.ev blockquote {
|
|
303
|
-
.ev-src { font-size:10px; color:var(--
|
|
304
|
-
figure { margin-top:
|
|
305
|
-
.
|
|
306
|
-
.
|
|
307
|
-
.
|
|
308
|
-
.
|
|
309
|
-
.
|
|
310
|
-
.
|
|
311
|
-
|
|
312
|
-
|
|
386
|
+
.chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
|
|
387
|
+
.chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
|
|
388
|
+
.chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
|
|
389
|
+
.ev { border-bottom:1px solid var(--line); padding:10px 0; }
|
|
390
|
+
.ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
|
|
391
|
+
.ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
|
|
392
|
+
.ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
|
|
393
|
+
figure.map { margin-top:16px; border:1px solid var(--line); }
|
|
394
|
+
.map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
|
|
395
|
+
.map-row svg { flex:1 1 62%; min-width:0; }
|
|
396
|
+
.plot { fill:var(--faint); stroke:var(--line); }
|
|
397
|
+
.grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
|
|
398
|
+
.ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
399
|
+
table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
|
|
400
|
+
table.legend thead th { font-size:10px; color:var(--soft); font-weight:600; border-bottom:1px solid var(--line); padding:2px 8px 5px 0; text-align:left; }
|
|
401
|
+
table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
|
|
402
|
+
table.legend td.num, table.legend th.num { text-align:right; }
|
|
403
|
+
table.legend tr.anchor-row td { font-weight:700; }
|
|
404
|
+
.swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
|
|
405
|
+
color:#fff; font-size:10.5px; font-weight:700; }
|
|
406
|
+
figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
|
|
407
|
+
footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
|
|
313
408
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
314
|
-
@media print { body { max-width:none; padding:0 8mm;
|
|
409
|
+
@media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
|
|
410
|
+
@media (max-width:760px) { .map-row { display:block; } }
|
|
315
411
|
</style></head><body>
|
|
316
412
|
<header>
|
|
317
|
-
<
|
|
318
|
-
<h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
|
|
413
|
+
<h1>Market map — ${e(config.category.replace(/-/g, " "))}</h1>
|
|
319
414
|
<div class="meta">
|
|
320
|
-
<span>
|
|
321
|
-
<span>${config.vendors.length}
|
|
322
|
-
<span
|
|
415
|
+
<span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
|
|
416
|
+
<span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
|
|
417
|
+
<span>extractor: ${e(set.extractor)}</span>
|
|
323
418
|
</div>
|
|
324
|
-
<div class="stamp">Field Report</div>
|
|
325
419
|
</header>
|
|
326
420
|
<section>
|
|
327
|
-
<h2
|
|
421
|
+
<h2>Front summary</h2>
|
|
328
422
|
<div class="fronts">
|
|
329
423
|
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
330
424
|
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
@@ -334,8 +428,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
334
428
|
<ul class="openlist">${openList}</ul>
|
|
335
429
|
</section>
|
|
336
430
|
<section>
|
|
337
|
-
<h2
|
|
338
|
-
<div class="
|
|
431
|
+
<h2>Claim × vendor intensity matrix</h2>
|
|
432
|
+
<div class="key">
|
|
339
433
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
340
434
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
341
435
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
@@ -348,7 +442,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
348
442
|
</section>
|
|
349
443
|
${axisHtml.strategicMap}
|
|
350
444
|
<section>
|
|
351
|
-
<h2
|
|
445
|
+
<h2>Evidence appendix</h2>
|
|
352
446
|
${appendix}
|
|
353
447
|
</section>
|
|
354
448
|
<footer>
|
package/dist/marketScale.d.ts
CHANGED
|
@@ -1,41 +1,70 @@
|
|
|
1
1
|
import type { MarketConfig, ScaleSignal } from "./market.ts";
|
|
2
2
|
/**
|
|
3
|
-
* Relative scale
|
|
4
|
-
* "bubble size = market share". True segment market share is unknowable from
|
|
5
|
-
* public data for mostly-private vendor sets, so this computes a composite
|
|
6
|
-
* index from whatever citable signals exist per vendor (review counts,
|
|
7
|
-
* headcount, disclosed revenue, self-reported customers), each of which is
|
|
8
|
-
* biased in a different direction; the composite triangulates.
|
|
3
|
+
* Relative scale estimation over the mapped vendor set — v2, dimensional.
|
|
9
4
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* it cannot rank anyone).
|
|
15
|
-
* 3. A vendor's index = arithmetic mean of its normalized metric scores
|
|
16
|
-
* (mean-of-normalized rather than geometric-of-raw so missing signals
|
|
17
|
-
* neither punish nor reward), reported with coverage (which metrics).
|
|
5
|
+
* v1 normalized every signal onto [0,1] and averaged, which quietly mixed
|
|
6
|
+
* dimensions: review/customer counts proxy CUSTOMER COUNT (N), while
|
|
7
|
+
* employees and revenue proxy REVENUE (N × ACV). Averaging the two inflates
|
|
8
|
+
* many-small-customer vendors against few-big-customer ones — the SMB bias.
|
|
18
9
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
10
|
+
* v2 converts every signal into REVENUE SPACE before combining:
|
|
11
|
+
*
|
|
12
|
+
* 1. Signals are classed by dimension: revenue (used directly),
|
|
13
|
+
* headcount (× revenue-per-employee), customers (× revenue-per-customer).
|
|
14
|
+
* 2. Conversion ratios are CALIBRATED within the set, per metric, as the
|
|
15
|
+
* median ratio over vendors that have both the metric and a revenue
|
|
16
|
+
* signal — and customer-dimension ratios are stratified by each
|
|
17
|
+
* vendor's `acvBand` (smb / mid / enterprise), because revenue-per-
|
|
18
|
+
* review spans ~75× between SMB tools and enterprise suites. A band
|
|
19
|
+
* without calibration pairs falls back to the global median; a metric
|
|
20
|
+
* with no pairs anywhere is unusable and reported as skipped.
|
|
21
|
+
* 3. A vendor's estimated revenue is the weighted geometric mean of its
|
|
22
|
+
* per-signal estimates (revenue weight 3, headcount 2, customers 1 —
|
|
23
|
+
* reliability order), with an uncertainty band = max/min estimate
|
|
24
|
+
* ratio, reported, never hidden.
|
|
25
|
+
* 4. index = share of the set's summed estimated revenue; bubbles render
|
|
26
|
+
* area-proportional to it. Labeled "estimated revenue share" with the
|
|
27
|
+
* calibration disclosed — still never "market share" unqualified:
|
|
28
|
+
* it is revenue share OF THE MAPPED SET, from citable-but-unaudited
|
|
29
|
+
* signals.
|
|
30
|
+
*
|
|
31
|
+
* Deterministic and auditable end to end: same config, same estimates.
|
|
21
32
|
*/
|
|
33
|
+
export type ScaleDimension = "revenue" | "headcount" | "customers";
|
|
34
|
+
export declare function dimensionForMetric(metric: string): ScaleDimension;
|
|
35
|
+
export type SignalEstimate = {
|
|
36
|
+
metric: string;
|
|
37
|
+
dimension: ScaleDimension;
|
|
38
|
+
rawValue: number;
|
|
39
|
+
/** Revenue-per-unit ratio applied (1 for revenue signals); null = unusable. */
|
|
40
|
+
ratio: number | null;
|
|
41
|
+
estimatedRevenue: number | null;
|
|
42
|
+
/** What calibrated the ratio: "direct", "band:<name>", "global", "fallback". */
|
|
43
|
+
calibration: string;
|
|
44
|
+
};
|
|
22
45
|
export type VendorScale = {
|
|
23
46
|
vendorId: string;
|
|
24
|
-
|
|
47
|
+
acvBand?: string;
|
|
48
|
+
estimates: SignalEstimate[];
|
|
49
|
+
/** Weighted geometric mean of usable estimates; null with no usable signals. */
|
|
50
|
+
estimatedRevenue: number | null;
|
|
51
|
+
/** max/min across usable estimates — 1 = perfect agreement among signals. */
|
|
52
|
+
uncertainty: number | null;
|
|
53
|
+
/** Share of the set's summed estimated revenue; drives bubble area. */
|
|
25
54
|
index: number | null;
|
|
26
|
-
/** Metrics that contributed, with their normalized scores. */
|
|
27
|
-
coverage: Array<{
|
|
28
|
-
metric: string;
|
|
29
|
-
value: number;
|
|
30
|
-
normalized: number;
|
|
31
|
-
}>;
|
|
32
55
|
signals: ScaleSignal[];
|
|
33
56
|
};
|
|
34
57
|
export type ScaleReport = {
|
|
35
58
|
vendors: VendorScale[];
|
|
36
|
-
/** Metrics used (present for ≥2 vendors) and metrics skipped (singletons). */
|
|
37
59
|
metricsUsed: string[];
|
|
38
60
|
metricsSkipped: string[];
|
|
61
|
+
/** Calibrated ratios for the appendix: metric × stratum → revenue-per-unit. */
|
|
62
|
+
calibrations: Array<{
|
|
63
|
+
metric: string;
|
|
64
|
+
stratum: string;
|
|
65
|
+
revenuePerUnit: number;
|
|
66
|
+
pairs: number;
|
|
67
|
+
}>;
|
|
39
68
|
complete: boolean;
|
|
40
69
|
};
|
|
41
70
|
export declare function computeScaleIndex(config: MarketConfig): ScaleReport;
|