fullstackgtm 0.21.1 → 0.21.2
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 +20 -0
- package/dist/marketReport.js +105 -16
- package/package.json +1 -1
- package/src/marketReport.ts +109 -20
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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.2] — 2026-06-12
|
|
9
|
+
|
|
10
|
+
Scatter interactivity + honest sizing fallbacks.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Hover tooltips on the strategic map**: rich tooltip (vendor, both axis
|
|
15
|
+
positions, LOUD count, estimated revenue/share with signal spread) follows
|
|
16
|
+
the cursor; hovering a bubble raises it to the front, so a bubble born
|
|
17
|
+
fully underneath a bigger one is one mouse-over from visible; hovering
|
|
18
|
+
dims the rest. Legend rows cross-highlight their bubbles.
|
|
19
|
+
- **No more implied share**: without scaleSignals the map renders UNIFORM
|
|
20
|
+
dots with the caption "Dot size carries no meaning on this map" — the
|
|
21
|
+
LOUD-count sizing fallback is gone (a map owner read it as market share;
|
|
22
|
+
the legend still lists LOUD counts). With partial coverage (majority of
|
|
23
|
+
vendors estimated), scale mode holds and signal-less vendors render as
|
|
24
|
+
minimal DASHED bubbles — visibly "no measurable scale", never silently
|
|
25
|
+
resized; their legend row shows "—".
|
|
26
|
+
- Numbers move above bubbles too small to contain them.
|
|
27
|
+
|
|
8
28
|
## [0.21.1] — 2026-06-12
|
|
9
29
|
|
|
10
30
|
Report design pass: analyst-grade restraint, legible dense scatters.
|
package/dist/marketReport.js
CHANGED
|
@@ -149,19 +149,26 @@ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
|
|
|
149
149
|
const sx = (x) => PAD_X + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD_X);
|
|
150
150
|
const sy = (y) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
|
|
151
151
|
const e = escapeHtml;
|
|
152
|
-
// Big bubbles first so small ones stay clickable/visible on top
|
|
152
|
+
// Big bubbles first so small ones stay clickable/visible on top; hover
|
|
153
|
+
// raises a bubble to the front (JS re-appends its <g>), so even a bubble
|
|
154
|
+
// born fully underneath a bigger one is one mouse-over from visible.
|
|
153
155
|
const ordered = [...points].sort((a, b) => b.size - a.size);
|
|
154
156
|
const dots = ordered
|
|
155
157
|
.map((p) => {
|
|
156
|
-
const r = 7 + 24 * Math.sqrt(p.size);
|
|
158
|
+
const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
|
|
157
159
|
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
160
|
const number = numberByVendor.get(p.vendorId) ?? 0;
|
|
161
|
+
const cx = sx(p.x).toFixed(1);
|
|
162
|
+
const cy = sy(p.y);
|
|
163
|
+
const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
|
|
164
|
+
// No measurable scale: minimal dashed outline — visibly "no data", never a guess.
|
|
165
|
+
const fill = p.noScale ? ` fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1.5" stroke-dasharray="3 2"` : ` fill="${color}" fill-opacity="0.78"${ring}`;
|
|
166
|
+
// Numbers go inside when they fit, above the bubble when they don't.
|
|
160
167
|
const fs = Math.max(10, Math.min(14, r * 0.9));
|
|
161
|
-
|
|
162
|
-
`<
|
|
163
|
-
`<text x="${
|
|
164
|
-
|
|
168
|
+
const numberSvg = r >= 10 && !p.noScale
|
|
169
|
+
? `<text x="${cx}" y="${(cy + fs * 0.36).toFixed(1)}" text-anchor="middle" font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`
|
|
170
|
+
: `<text x="${cx}" y="${(cy - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
|
|
171
|
+
return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}"><circle cx="${cx}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}"${fill}/>${numberSvg}</g>`;
|
|
165
172
|
})
|
|
166
173
|
.join("");
|
|
167
174
|
const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
|
|
@@ -199,17 +206,27 @@ function axisSectionsHtml(config, set) {
|
|
|
199
206
|
// when every placeable vendor has one; LOUD count otherwise — never mix
|
|
200
207
|
// the two semantics on one chart.
|
|
201
208
|
const scale = computeScaleIndex(config);
|
|
209
|
+
const scaleByVendor = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor]));
|
|
202
210
|
const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
|
|
203
|
-
const
|
|
211
|
+
const estimated = report.vendors.filter((vendorId) => typeof scaleIndex.get(vendorId) === "number");
|
|
212
|
+
// Scale mode needs a real majority of vendors estimated; the stragglers
|
|
213
|
+
// (idea-stage anchors, signal-less vendors) render as minimal dashed
|
|
214
|
+
// bubbles — visibly "no measurable scale", never silently re-sized.
|
|
215
|
+
const useScale = estimated.length >= 2 && estimated.length * 2 >= report.vendors.length;
|
|
204
216
|
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
205
|
-
const maxLoud = Math.max(1, ...loudCounts.values());
|
|
206
217
|
// Bubble areas stay proportional to the metric; dividing by the max just
|
|
207
218
|
// spends the full visual range without distorting any ratio.
|
|
208
219
|
const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
|
|
209
|
-
const sizeOf = (vendorId) =>
|
|
220
|
+
const sizeOf = (vendorId) => {
|
|
221
|
+
if (!useScale)
|
|
222
|
+
return 0.14; // uniform: size carries NO meaning without scale signals
|
|
223
|
+
const share = scaleIndex.get(vendorId);
|
|
224
|
+
return typeof share === "number" ? share / maxShare : 0;
|
|
225
|
+
};
|
|
226
|
+
const noScaleFor = (vendorId) => useScale && typeof scaleIndex.get(vendorId) !== "number";
|
|
210
227
|
const sizeCaption = useScale
|
|
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)
|
|
212
|
-
: "Dot
|
|
228
|
+
? `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). Dashed outline = no measurable scale signals.`
|
|
229
|
+
: "Dot size carries no meaning on this map (no scaleSignals in the config); the legend lists LOUD counts";
|
|
213
230
|
const breadthAxis = {
|
|
214
231
|
id: "breadth",
|
|
215
232
|
label: "Message breadth",
|
|
@@ -247,6 +264,7 @@ function axisSectionsHtml(config, set) {
|
|
|
247
264
|
x: xs.get(vendorId),
|
|
248
265
|
y: ys.get(vendorId),
|
|
249
266
|
size: sizeOf(vendorId),
|
|
267
|
+
noScale: noScaleFor(vendorId),
|
|
250
268
|
}));
|
|
251
269
|
};
|
|
252
270
|
const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
|
|
@@ -265,13 +283,35 @@ function axisSectionsHtml(config, set) {
|
|
|
265
283
|
const number = numberByVendor.get(point.vendorId);
|
|
266
284
|
const color = colorByVendor.get(point.vendorId);
|
|
267
285
|
const isAnchor = point.vendorId === config.anchorVendor;
|
|
286
|
+
const share = scaleIndex.get(point.vendorId);
|
|
268
287
|
const measure = useScale
|
|
269
|
-
?
|
|
288
|
+
? typeof share === "number"
|
|
289
|
+
? `${(share * 100).toFixed(1)}%`
|
|
290
|
+
: "—"
|
|
270
291
|
: `${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>`;
|
|
292
|
+
return `<tr data-v="${e(point.vendorId)}"${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
293
|
})
|
|
273
294
|
.join("");
|
|
274
295
|
const legendMeasureHead = useScale ? "est. share" : "loud";
|
|
296
|
+
const money = (value) => value >= 1e9 ? `$${(value / 1e9).toFixed(1)}B` : value >= 1e6 ? `$${(value / 1e6).toFixed(1)}M` : `$${Math.round(value / 1e3)}K`;
|
|
297
|
+
const tipData = {};
|
|
298
|
+
for (const point of points) {
|
|
299
|
+
const vendorScale = scaleByVendor.get(point.vendorId);
|
|
300
|
+
const lines = [
|
|
301
|
+
`${axInfo.label}: ${point.x.toFixed(2)}`,
|
|
302
|
+
`${ayInfo.label}: ${point.y.toFixed(2)}`,
|
|
303
|
+
`LOUD claims: ${loudCounts.get(point.vendorId) ?? 0}`,
|
|
304
|
+
];
|
|
305
|
+
if (useScale) {
|
|
306
|
+
if (vendorScale?.estimatedRevenue != null) {
|
|
307
|
+
lines.push(`est. revenue: ~${money(vendorScale.estimatedRevenue)} (${((scaleIndex.get(point.vendorId) ?? 0) * 100).toFixed(1)}% of set${vendorScale.uncertainty && vendorScale.uncertainty > 1 ? `, ×${vendorScale.uncertainty.toFixed(1)} signal spread` : ""})`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
lines.push("est. revenue: no measurable scale signals");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
tipData[point.vendorId] = { name: point.name, n: numberByVendor.get(point.vendorId) ?? 0, lines };
|
|
314
|
+
}
|
|
275
315
|
const strategicMap = `<section>
|
|
276
316
|
<h2>Strategic map: ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
277
317
|
<figure class="map">
|
|
@@ -279,6 +319,47 @@ function axisSectionsHtml(config, set) {
|
|
|
279
319
|
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
|
|
280
320
|
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
281
321
|
</div>
|
|
322
|
+
<div class="map-tip" id="map-tip" hidden></div>
|
|
323
|
+
<script type="application/json" id="map-data">${JSON.stringify(tipData)}</script>
|
|
324
|
+
<script>
|
|
325
|
+
(function () {
|
|
326
|
+
var data = JSON.parse(document.getElementById("map-data").textContent);
|
|
327
|
+
var tip = document.getElementById("map-tip");
|
|
328
|
+
var fig = tip.closest("figure");
|
|
329
|
+
var bubbles = fig.querySelectorAll("g.bubble");
|
|
330
|
+
var rows = fig.querySelectorAll("table.legend tbody tr");
|
|
331
|
+
function show(v, evt) {
|
|
332
|
+
var d = data[v];
|
|
333
|
+
if (!d) return;
|
|
334
|
+
tip.innerHTML = "<b>" + d.n + " · " + d.name + "</b>" + d.lines.map(function (l) { return "<div>" + l + "</div>"; }).join("");
|
|
335
|
+
tip.hidden = false;
|
|
336
|
+
var box = fig.getBoundingClientRect();
|
|
337
|
+
tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
|
|
338
|
+
tip.style.top = (evt.clientY - box.top + 14) + "px";
|
|
339
|
+
}
|
|
340
|
+
function focusOn(v) {
|
|
341
|
+
bubbles.forEach(function (b) {
|
|
342
|
+
b.classList.toggle("dim", b.getAttribute("data-v") !== v);
|
|
343
|
+
if (b.getAttribute("data-v") === v) b.parentNode.appendChild(b); // raise hidden bubbles
|
|
344
|
+
});
|
|
345
|
+
rows.forEach(function (r) { r.classList.toggle("hl", r.getAttribute("data-v") === v); });
|
|
346
|
+
}
|
|
347
|
+
function clear() {
|
|
348
|
+
tip.hidden = true;
|
|
349
|
+
bubbles.forEach(function (b) { b.classList.remove("dim"); });
|
|
350
|
+
rows.forEach(function (r) { r.classList.remove("hl"); });
|
|
351
|
+
}
|
|
352
|
+
bubbles.forEach(function (b) {
|
|
353
|
+
b.addEventListener("mousemove", function (evt) { show(b.getAttribute("data-v"), evt); });
|
|
354
|
+
b.addEventListener("mouseenter", function () { focusOn(b.getAttribute("data-v")); });
|
|
355
|
+
b.addEventListener("mouseleave", clear);
|
|
356
|
+
});
|
|
357
|
+
rows.forEach(function (r) {
|
|
358
|
+
r.addEventListener("mouseenter", function () { focusOn(r.getAttribute("data-v")); });
|
|
359
|
+
r.addEventListener("mouseleave", clear);
|
|
360
|
+
});
|
|
361
|
+
})();
|
|
362
|
+
</script>
|
|
282
363
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
283
364
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
284
365
|
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
@@ -390,7 +471,15 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
|
390
471
|
.ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
|
|
391
472
|
.ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
|
|
392
473
|
.ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
|
|
393
|
-
figure.map { margin-top:16px; border:1px solid var(--line); }
|
|
474
|
+
figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
|
|
475
|
+
g.bubble { cursor:pointer; }
|
|
476
|
+
g.bubble.dim { opacity:0.25; transition:opacity .12s; }
|
|
477
|
+
table.legend tbody tr { cursor:default; }
|
|
478
|
+
table.legend tbody tr.hl td { background:var(--faint); }
|
|
479
|
+
.map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
|
|
480
|
+
padding:8px 10px; border-radius:3px; pointer-events:none; max-width:260px;
|
|
481
|
+
font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
482
|
+
.map-tip b { display:block; margin-bottom:3px; }
|
|
394
483
|
.map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
|
|
395
484
|
.map-row svg { flex:1 1 62%; min-width:0; }
|
|
396
485
|
.plot { fill:var(--faint); stroke:var(--line); }
|
|
@@ -406,7 +495,7 @@ table.legend tr.anchor-row td { font-weight:700; }
|
|
|
406
495
|
figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
|
|
407
496
|
footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
|
|
408
497
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
409
|
-
@media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
|
|
498
|
+
@media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } .map-tip { display:none; } }
|
|
410
499
|
@media (max-width:760px) { .map-row { display:block; } }
|
|
411
500
|
</style></head><body>
|
|
412
501
|
<header>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/marketReport.ts
CHANGED
|
@@ -107,7 +107,7 @@ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet):
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/** size is normalized [0,1]; rendered area-proportionally (radius ∝ √size). */
|
|
110
|
-
type ScatterPoint = { vendorId: string; name: string; x: number; y: number; size: number };
|
|
110
|
+
type ScatterPoint = { vendorId: string; name: string; x: number; y: number; size: number; noScale?: boolean };
|
|
111
111
|
type ScatterAxis = { label: string; negativePole: string; positivePole: string; signed: boolean };
|
|
112
112
|
|
|
113
113
|
/**
|
|
@@ -185,21 +185,27 @@ function svgScatter(
|
|
|
185
185
|
const sy = (y: number) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
|
|
186
186
|
const e = escapeHtml;
|
|
187
187
|
|
|
188
|
-
// Big bubbles first so small ones stay clickable/visible on top
|
|
188
|
+
// Big bubbles first so small ones stay clickable/visible on top; hover
|
|
189
|
+
// raises a bubble to the front (JS re-appends its <g>), so even a bubble
|
|
190
|
+
// born fully underneath a bigger one is one mouse-over from visible.
|
|
189
191
|
const ordered = [...points].sort((a, b) => b.size - a.size);
|
|
190
192
|
const dots = ordered
|
|
191
193
|
.map((p) => {
|
|
192
|
-
const r = 7 + 24 * Math.sqrt(p.size);
|
|
194
|
+
const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
|
|
193
195
|
const color = colorByVendor.get(p.vendorId) ?? "#717171";
|
|
194
|
-
const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
|
|
195
196
|
const number = numberByVendor.get(p.vendorId) ?? 0;
|
|
197
|
+
const cx = sx(p.x).toFixed(1);
|
|
198
|
+
const cy = sy(p.y);
|
|
199
|
+
const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
|
|
200
|
+
// No measurable scale: minimal dashed outline — visibly "no data", never a guess.
|
|
201
|
+
const fill = p.noScale ? ` fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1.5" stroke-dasharray="3 2"` : ` fill="${color}" fill-opacity="0.78"${ring}`;
|
|
202
|
+
// Numbers go inside when they fit, above the bubble when they don't.
|
|
196
203
|
const fs = Math.max(10, Math.min(14, r * 0.9));
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
);
|
|
204
|
+
const numberSvg =
|
|
205
|
+
r >= 10 && !p.noScale
|
|
206
|
+
? `<text x="${cx}" y="${(cy + fs * 0.36).toFixed(1)}" text-anchor="middle" font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`
|
|
207
|
+
: `<text x="${cx}" y="${(cy - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
|
|
208
|
+
return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}"><circle cx="${cx}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}"${fill}/>${numberSvg}</g>`;
|
|
203
209
|
})
|
|
204
210
|
.join("");
|
|
205
211
|
|
|
@@ -244,18 +250,26 @@ function axisSectionsHtml(
|
|
|
244
250
|
// when every placeable vendor has one; LOUD count otherwise — never mix
|
|
245
251
|
// the two semantics on one chart.
|
|
246
252
|
const scale = computeScaleIndex(config);
|
|
253
|
+
const scaleByVendor = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor]));
|
|
247
254
|
const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
|
|
248
|
-
const
|
|
255
|
+
const estimated = report.vendors.filter((vendorId) => typeof scaleIndex.get(vendorId) === "number");
|
|
256
|
+
// Scale mode needs a real majority of vendors estimated; the stragglers
|
|
257
|
+
// (idea-stage anchors, signal-less vendors) render as minimal dashed
|
|
258
|
+
// bubbles — visibly "no measurable scale", never silently re-sized.
|
|
259
|
+
const useScale = estimated.length >= 2 && estimated.length * 2 >= report.vendors.length;
|
|
249
260
|
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
250
|
-
const maxLoud = Math.max(1, ...loudCounts.values());
|
|
251
261
|
// Bubble areas stay proportional to the metric; dividing by the max just
|
|
252
262
|
// spends the full visual range without distorting any ratio.
|
|
253
263
|
const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
|
|
254
|
-
const sizeOf = (vendorId: string): number =>
|
|
255
|
-
useScale
|
|
264
|
+
const sizeOf = (vendorId: string): number => {
|
|
265
|
+
if (!useScale) return 0.14; // uniform: size carries NO meaning without scale signals
|
|
266
|
+
const share = scaleIndex.get(vendorId);
|
|
267
|
+
return typeof share === "number" ? share / maxShare : 0;
|
|
268
|
+
};
|
|
269
|
+
const noScaleFor = (vendorId: string): boolean => useScale && typeof scaleIndex.get(vendorId) !== "number";
|
|
256
270
|
const sizeCaption = useScale
|
|
257
|
-
? `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)
|
|
258
|
-
: "Dot
|
|
271
|
+
? `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). Dashed outline = no measurable scale signals.`
|
|
272
|
+
: "Dot size carries no meaning on this map (no scaleSignals in the config); the legend lists LOUD counts";
|
|
259
273
|
|
|
260
274
|
const breadthAxis: ScatterAxis & { id: string } = {
|
|
261
275
|
id: "breadth",
|
|
@@ -298,6 +312,7 @@ function axisSectionsHtml(
|
|
|
298
312
|
x: xs.get(vendorId) as number,
|
|
299
313
|
y: ys.get(vendorId) as number,
|
|
300
314
|
size: sizeOf(vendorId),
|
|
315
|
+
noScale: noScaleFor(vendorId),
|
|
301
316
|
}));
|
|
302
317
|
};
|
|
303
318
|
|
|
@@ -318,14 +333,39 @@ function axisSectionsHtml(
|
|
|
318
333
|
const number = numberByVendor.get(point.vendorId);
|
|
319
334
|
const color = colorByVendor.get(point.vendorId) as string;
|
|
320
335
|
const isAnchor = point.vendorId === config.anchorVendor;
|
|
336
|
+
const share = scaleIndex.get(point.vendorId);
|
|
321
337
|
const measure = useScale
|
|
322
|
-
?
|
|
338
|
+
? typeof share === "number"
|
|
339
|
+
? `${(share * 100).toFixed(1)}%`
|
|
340
|
+
: "—"
|
|
323
341
|
: `${loudCounts.get(point.vendorId) ?? 0} loud`;
|
|
324
|
-
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>`;
|
|
342
|
+
return `<tr data-v="${e(point.vendorId)}"${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>`;
|
|
325
343
|
})
|
|
326
344
|
.join("");
|
|
327
345
|
const legendMeasureHead = useScale ? "est. share" : "loud";
|
|
328
346
|
|
|
347
|
+
const money = (value: number) =>
|
|
348
|
+
value >= 1e9 ? `$${(value / 1e9).toFixed(1)}B` : value >= 1e6 ? `$${(value / 1e6).toFixed(1)}M` : `$${Math.round(value / 1e3)}K`;
|
|
349
|
+
const tipData: Record<string, { name: string; n: number; lines: string[] }> = {};
|
|
350
|
+
for (const point of points) {
|
|
351
|
+
const vendorScale = scaleByVendor.get(point.vendorId);
|
|
352
|
+
const lines = [
|
|
353
|
+
`${axInfo.label}: ${point.x.toFixed(2)}`,
|
|
354
|
+
`${ayInfo.label}: ${point.y.toFixed(2)}`,
|
|
355
|
+
`LOUD claims: ${loudCounts.get(point.vendorId) ?? 0}`,
|
|
356
|
+
];
|
|
357
|
+
if (useScale) {
|
|
358
|
+
if (vendorScale?.estimatedRevenue != null) {
|
|
359
|
+
lines.push(
|
|
360
|
+
`est. revenue: ~${money(vendorScale.estimatedRevenue)} (${(((scaleIndex.get(point.vendorId) as number) ?? 0) * 100).toFixed(1)}% of set${vendorScale.uncertainty && vendorScale.uncertainty > 1 ? `, ×${vendorScale.uncertainty.toFixed(1)} signal spread` : ""})`,
|
|
361
|
+
);
|
|
362
|
+
} else {
|
|
363
|
+
lines.push("est. revenue: no measurable scale signals");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
tipData[point.vendorId] = { name: point.name, n: numberByVendor.get(point.vendorId) ?? 0, lines };
|
|
367
|
+
}
|
|
368
|
+
|
|
329
369
|
const strategicMap = `<section>
|
|
330
370
|
<h2>Strategic map: ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
331
371
|
<figure class="map">
|
|
@@ -333,6 +373,47 @@ function axisSectionsHtml(
|
|
|
333
373
|
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
|
|
334
374
|
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
335
375
|
</div>
|
|
376
|
+
<div class="map-tip" id="map-tip" hidden></div>
|
|
377
|
+
<script type="application/json" id="map-data">${JSON.stringify(tipData)}</script>
|
|
378
|
+
<script>
|
|
379
|
+
(function () {
|
|
380
|
+
var data = JSON.parse(document.getElementById("map-data").textContent);
|
|
381
|
+
var tip = document.getElementById("map-tip");
|
|
382
|
+
var fig = tip.closest("figure");
|
|
383
|
+
var bubbles = fig.querySelectorAll("g.bubble");
|
|
384
|
+
var rows = fig.querySelectorAll("table.legend tbody tr");
|
|
385
|
+
function show(v, evt) {
|
|
386
|
+
var d = data[v];
|
|
387
|
+
if (!d) return;
|
|
388
|
+
tip.innerHTML = "<b>" + d.n + " · " + d.name + "</b>" + d.lines.map(function (l) { return "<div>" + l + "</div>"; }).join("");
|
|
389
|
+
tip.hidden = false;
|
|
390
|
+
var box = fig.getBoundingClientRect();
|
|
391
|
+
tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
|
|
392
|
+
tip.style.top = (evt.clientY - box.top + 14) + "px";
|
|
393
|
+
}
|
|
394
|
+
function focusOn(v) {
|
|
395
|
+
bubbles.forEach(function (b) {
|
|
396
|
+
b.classList.toggle("dim", b.getAttribute("data-v") !== v);
|
|
397
|
+
if (b.getAttribute("data-v") === v) b.parentNode.appendChild(b); // raise hidden bubbles
|
|
398
|
+
});
|
|
399
|
+
rows.forEach(function (r) { r.classList.toggle("hl", r.getAttribute("data-v") === v); });
|
|
400
|
+
}
|
|
401
|
+
function clear() {
|
|
402
|
+
tip.hidden = true;
|
|
403
|
+
bubbles.forEach(function (b) { b.classList.remove("dim"); });
|
|
404
|
+
rows.forEach(function (r) { r.classList.remove("hl"); });
|
|
405
|
+
}
|
|
406
|
+
bubbles.forEach(function (b) {
|
|
407
|
+
b.addEventListener("mousemove", function (evt) { show(b.getAttribute("data-v"), evt); });
|
|
408
|
+
b.addEventListener("mouseenter", function () { focusOn(b.getAttribute("data-v")); });
|
|
409
|
+
b.addEventListener("mouseleave", clear);
|
|
410
|
+
});
|
|
411
|
+
rows.forEach(function (r) {
|
|
412
|
+
r.addEventListener("mouseenter", function () { focusOn(r.getAttribute("data-v")); });
|
|
413
|
+
r.addEventListener("mouseleave", clear);
|
|
414
|
+
});
|
|
415
|
+
})();
|
|
416
|
+
</script>
|
|
336
417
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
337
418
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
338
419
|
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
@@ -458,7 +539,15 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
|
458
539
|
.ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
|
|
459
540
|
.ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
|
|
460
541
|
.ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
|
|
461
|
-
figure.map { margin-top:16px; border:1px solid var(--line); }
|
|
542
|
+
figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
|
|
543
|
+
g.bubble { cursor:pointer; }
|
|
544
|
+
g.bubble.dim { opacity:0.25; transition:opacity .12s; }
|
|
545
|
+
table.legend tbody tr { cursor:default; }
|
|
546
|
+
table.legend tbody tr.hl td { background:var(--faint); }
|
|
547
|
+
.map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
|
|
548
|
+
padding:8px 10px; border-radius:3px; pointer-events:none; max-width:260px;
|
|
549
|
+
font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
550
|
+
.map-tip b { display:block; margin-bottom:3px; }
|
|
462
551
|
.map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
|
|
463
552
|
.map-row svg { flex:1 1 62%; min-width:0; }
|
|
464
553
|
.plot { fill:var(--faint); stroke:var(--line); }
|
|
@@ -474,7 +563,7 @@ table.legend tr.anchor-row td { font-weight:700; }
|
|
|
474
563
|
figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
|
|
475
564
|
footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
|
|
476
565
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
477
|
-
@media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
|
|
566
|
+
@media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } .map-tip { display:none; } }
|
|
478
567
|
@media (max-width:760px) { .map-row { display:block; } }
|
|
479
568
|
</style></head><body>
|
|
480
569
|
<header>
|