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 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.
@@ -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
- 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>`);
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 useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
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) => useScale ? scaleIndex.get(vendorId) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
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 &#8733; 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 area &#8733; LOUD count";
228
+ ? `Dot area &#8733; 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
- ? `${((scaleIndex.get(point.vendorId) ?? 0) * 100).toFixed(1)}%`
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 ? " ·&nbsp;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 ? " ·&nbsp;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)} &#215; ${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=&#189;) 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.1",
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",
@@ -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
- return (
198
- `<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}" fill="${color}" fill-opacity="0.88"${ring}>` +
199
- `<title>${e(p.name)}</title></circle>` +
200
- `<text x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) + fs * 0.36).toFixed(1)}" text-anchor="middle" ` +
201
- `font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`
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 useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
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 ? (scaleIndex.get(vendorId) as number) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
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 &#8733; 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 area &#8733; LOUD count";
271
+ ? `Dot area &#8733; 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
- ? `${(((scaleIndex.get(point.vendorId) as number) ?? 0) * 100).toFixed(1)}%`
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 ? " ·&nbsp;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 ? " ·&nbsp;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)} &#215; ${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=&#189;) 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>