fullstackgtm 0.21.0 → 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 +41 -0
- package/dist/marketReport.js +272 -92
- package/package.json +1 -1
- package/src/marketReport.ts +282 -96
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,47 @@ 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
|
+
|
|
28
|
+
## [0.21.1] — 2026-06-12
|
|
29
|
+
|
|
30
|
+
Report design pass: analyst-grade restraint, legible dense scatters.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **De-slopped the field report**: removed the rotated stamp, paper-grain
|
|
35
|
+
background, parchment palette, letterspaced kickers, and editorial hero
|
|
36
|
+
voice. White page, hairline rules, plain headings — the design recedes,
|
|
37
|
+
the data reads.
|
|
38
|
+
- **Strategic map legibility**: bubbles are now numbered and colored
|
|
39
|
+
(Okabe–Ito colorblind-safe palette) with a legend table beside the chart
|
|
40
|
+
that doubles as the share table (number · color · vendor · est. share or
|
|
41
|
+
LOUD count, anchor bolded). Larger bubbles render first so overlapping
|
|
42
|
+
clusters stay readable; the number resolves what overlapping name labels
|
|
43
|
+
never could. Hover tooltips keep the names.
|
|
44
|
+
- **Axis pole labels can no longer collide**: wrapped to ≤2 short lines
|
|
45
|
+
(parentheticals dropped first), x poles at the bottom corners, y poles
|
|
46
|
+
rotated along the left margin — four positions, standard chart
|
|
47
|
+
convention.
|
|
48
|
+
|
|
8
49
|
## [0.21.0] — 2026-06-12
|
|
9
50
|
|
|
10
51
|
Scale estimation v2 — dimensional, calibrated, SMB-bias-robust.
|
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,53 @@ 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; 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.
|
|
155
|
+
const ordered = [...points].sort((a, b) => b.size - a.size);
|
|
156
|
+
const dots = ordered
|
|
101
157
|
.map((p) => {
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
158
|
+
const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
|
|
159
|
+
const color = colorByVendor.get(p.vendorId) ?? "#717171";
|
|
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.
|
|
167
|
+
const fs = Math.max(10, Math.min(14, r * 0.9));
|
|
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>`;
|
|
107
172
|
})
|
|
108
173
|
.join("");
|
|
109
|
-
const midX = ax.signed ? `<line class="
|
|
110
|
-
const midY = ay.signed ? `<line class="
|
|
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}"/>` : "";
|
|
175
|
+
const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
|
|
176
|
+
// Pole labels in four positions that cannot collide: x poles horizontal at
|
|
177
|
+
// the bottom corners; y poles rotated along the left margin (positive
|
|
178
|
+
// reading up toward the top, negative near the bottom) — the standard
|
|
179
|
+
// chart convention, wrapped to ≤2 short lines each.
|
|
180
|
+
const fsPole = 10;
|
|
181
|
+
const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
|
|
182
|
+
const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
|
|
183
|
+
const yLabel = (lines, yEdge, anchorMode) => {
|
|
184
|
+
const spans = lines
|
|
185
|
+
.map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
|
|
186
|
+
.join("");
|
|
187
|
+
return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
|
|
188
|
+
};
|
|
189
|
+
const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
|
|
190
|
+
const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
|
|
111
191
|
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>
|
|
192
|
+
<rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
|
|
193
|
+
${midX}${midY}
|
|
194
|
+
${xNeg}${xPos}
|
|
195
|
+
${yPos}${yNeg}
|
|
117
196
|
${dots}</svg>`;
|
|
118
197
|
}
|
|
119
198
|
function axisSectionsHtml(config, set) {
|
|
@@ -127,17 +206,27 @@ function axisSectionsHtml(config, set) {
|
|
|
127
206
|
// when every placeable vendor has one; LOUD count otherwise — never mix
|
|
128
207
|
// the two semantics on one chart.
|
|
129
208
|
const scale = computeScaleIndex(config);
|
|
209
|
+
const scaleByVendor = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor]));
|
|
130
210
|
const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
|
|
131
|
-
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;
|
|
132
216
|
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
133
|
-
const maxLoud = Math.max(1, ...loudCounts.values());
|
|
134
217
|
// Bubble areas stay proportional to the metric; dividing by the max just
|
|
135
218
|
// spends the full visual range without distorting any ratio.
|
|
136
219
|
const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
|
|
137
|
-
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";
|
|
138
227
|
const sizeCaption = useScale
|
|
139
|
-
? `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)
|
|
140
|
-
: "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";
|
|
141
230
|
const breadthAxis = {
|
|
142
231
|
id: "breadth",
|
|
143
232
|
label: "Message breadth",
|
|
@@ -175,15 +264,102 @@ function axisSectionsHtml(config, set) {
|
|
|
175
264
|
x: xs.get(vendorId),
|
|
176
265
|
y: ys.get(vendorId),
|
|
177
266
|
size: sizeOf(vendorId),
|
|
267
|
+
noScale: noScaleFor(vendorId),
|
|
178
268
|
}));
|
|
179
269
|
};
|
|
180
270
|
const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
|
|
181
271
|
const axInfo = axisInfo.get(px);
|
|
182
272
|
const ayInfo = axisInfo.get(py);
|
|
183
273
|
const statusOf = (id) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
|
|
274
|
+
// Legend order doubles as bubble numbering: largest first, anchor bolded.
|
|
275
|
+
// The number inside each bubble resolves dense clusters that name labels
|
|
276
|
+
// never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
|
|
277
|
+
const points = pointsFor(px, py);
|
|
278
|
+
const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
|
|
279
|
+
const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
|
|
280
|
+
const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
|
|
281
|
+
const legendRows = legendOrder
|
|
282
|
+
.map((point) => {
|
|
283
|
+
const number = numberByVendor.get(point.vendorId);
|
|
284
|
+
const color = colorByVendor.get(point.vendorId);
|
|
285
|
+
const isAnchor = point.vendorId === config.anchorVendor;
|
|
286
|
+
const share = scaleIndex.get(point.vendorId);
|
|
287
|
+
const measure = useScale
|
|
288
|
+
? typeof share === "number"
|
|
289
|
+
? `${(share * 100).toFixed(1)}%`
|
|
290
|
+
: "—"
|
|
291
|
+
: `${loudCounts.get(point.vendorId) ?? 0} loud`;
|
|
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>`;
|
|
293
|
+
})
|
|
294
|
+
.join("");
|
|
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
|
+
}
|
|
184
315
|
const strategicMap = `<section>
|
|
185
|
-
<h2
|
|
186
|
-
<figure
|
|
316
|
+
<h2>Strategic map: ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
317
|
+
<figure class="map">
|
|
318
|
+
<div class="map-row">
|
|
319
|
+
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
|
|
320
|
+
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
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>
|
|
187
363
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
188
364
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
189
365
|
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
@@ -206,7 +382,6 @@ export function marketMapToHtml(config, set) {
|
|
|
206
382
|
const anchor = config.anchorVendor;
|
|
207
383
|
const e = escapeHtml;
|
|
208
384
|
const axisHtml = axisSectionsHtml(config, set);
|
|
209
|
-
const appendixNo = axisHtml.report ? "04" : "03";
|
|
210
385
|
const matrixRows = model.orderedClaimIds
|
|
211
386
|
.map((claimId) => {
|
|
212
387
|
const claim = claimsById.get(claimId);
|
|
@@ -253,81 +428,86 @@ export function marketMapToHtml(config, set) {
|
|
|
253
428
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
254
429
|
<title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
|
|
255
430
|
<style>
|
|
256
|
-
:root { --
|
|
431
|
+
:root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
|
|
257
432
|
* { box-sizing:border-box; margin:0; }
|
|
258
|
-
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background
|
|
259
|
-
max-width:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.
|
|
267
|
-
.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
|
|
271
|
-
border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
|
|
272
|
-
h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
|
|
273
|
-
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
|
|
274
|
-
.fcard { background:var(--paper); padding:18px 18px 14px; }
|
|
275
|
-
.fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
|
|
276
|
-
.fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
|
|
433
|
+
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
|
|
434
|
+
max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
|
|
435
|
+
.mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
436
|
+
header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
|
|
437
|
+
h1 { font-size:27px; font-weight:600; line-height:1.2; }
|
|
438
|
+
.meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
|
|
439
|
+
section { margin-top:44px; }
|
|
440
|
+
h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
|
|
441
|
+
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
|
|
442
|
+
.fcard { background:#fff; padding:14px 16px 12px; }
|
|
443
|
+
.fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
|
|
444
|
+
.fcard span { font-size:11px; color:var(--soft); }
|
|
277
445
|
.fcard.open b { color:var(--accent); }
|
|
278
|
-
.openlist { margin-top:
|
|
279
|
-
.openlist li { margin:
|
|
280
|
-
.openlist .why { color:var(--
|
|
281
|
-
.
|
|
282
|
-
.lg { display:inline-flex; align-items:center; gap:
|
|
446
|
+
.openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
|
|
447
|
+
.openlist li { margin:3px 0 3px 20px; }
|
|
448
|
+
.openlist .why { color:var(--soft); font-size:13px; }
|
|
449
|
+
.key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
|
|
450
|
+
.lg { display:inline-flex; align-items:center; gap:6px; }
|
|
283
451
|
table { border-collapse:collapse; width:100%; margin-top:6px; }
|
|
284
|
-
thead th { border-bottom:
|
|
285
|
-
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px;
|
|
286
|
-
text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
|
|
452
|
+
thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
|
|
453
|
+
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
|
|
287
454
|
th.vh.anchor-col span { color:var(--green); font-weight:700; }
|
|
288
|
-
tbody th { text-align:left; font-weight:400; padding:
|
|
289
|
-
.claim-cap { display:block; font-size:
|
|
290
|
-
.claim-meta { display:block; font-size:9.5px; color:var(--
|
|
455
|
+
tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
|
|
456
|
+
.claim-cap { display:block; font-size:14px; }
|
|
457
|
+
.claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
|
|
291
458
|
td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
|
|
292
|
-
td.cell.anchor-col { background:
|
|
459
|
+
td.cell.anchor-col { background:var(--faint); }
|
|
293
460
|
td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
|
|
294
|
-
.g { display:inline-block; width:
|
|
461
|
+
.g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
|
|
295
462
|
.g-loud { background:var(--ink); }
|
|
296
|
-
.g-quiet { box-shadow:inset 0 0 0 2px
|
|
297
|
-
.g-absent { background:radial-gradient(circle at center,
|
|
298
|
-
.g-unobservable { background:repeating-linear-gradient(45deg,
|
|
463
|
+
.g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
|
|
464
|
+
.g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
|
|
465
|
+
.g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
|
|
299
466
|
tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
300
|
-
.chip { font-size:9px;
|
|
301
|
-
.chip-open { color:var(--accent); } .chip-contested { color:#
|
|
302
|
-
.chip-owned { color:var(--green); } .chip-saturated { color:var(--
|
|
303
|
-
.ev { border-bottom:1px solid var(--line); padding:
|
|
304
|
-
.ev-head { font-size:10.5px;
|
|
305
|
-
.ev blockquote {
|
|
306
|
-
.ev-src { font-size:10px; color:var(--
|
|
307
|
-
figure { margin-top:
|
|
308
|
-
.
|
|
309
|
-
.
|
|
310
|
-
.
|
|
311
|
-
.
|
|
312
|
-
.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
467
|
+
.chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
|
|
468
|
+
.chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
|
|
469
|
+
.chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
|
|
470
|
+
.ev { border-bottom:1px solid var(--line); padding:10px 0; }
|
|
471
|
+
.ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
|
|
472
|
+
.ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
|
|
473
|
+
.ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
|
|
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; }
|
|
483
|
+
.map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
|
|
484
|
+
.map-row svg { flex:1 1 62%; min-width:0; }
|
|
485
|
+
.plot { fill:var(--faint); stroke:var(--line); }
|
|
486
|
+
.grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
|
|
487
|
+
.ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
488
|
+
table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
|
|
489
|
+
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; }
|
|
490
|
+
table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
|
|
491
|
+
table.legend td.num, table.legend th.num { text-align:right; }
|
|
492
|
+
table.legend tr.anchor-row td { font-weight:700; }
|
|
493
|
+
.swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
|
|
494
|
+
color:#fff; font-size:10.5px; font-weight:700; }
|
|
495
|
+
figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
|
|
496
|
+
footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
|
|
316
497
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
317
|
-
@media print { body { max-width:none; padding:0 8mm;
|
|
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; } }
|
|
499
|
+
@media (max-width:760px) { .map-row { display:block; } }
|
|
318
500
|
</style></head><body>
|
|
319
501
|
<header>
|
|
320
|
-
<
|
|
321
|
-
<h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
|
|
502
|
+
<h1>Market map — ${e(config.category.replace(/-/g, " "))}</h1>
|
|
322
503
|
<div class="meta">
|
|
323
|
-
<span>
|
|
324
|
-
<span>${config.vendors.length}
|
|
325
|
-
<span
|
|
504
|
+
<span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
|
|
505
|
+
<span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
|
|
506
|
+
<span>extractor: ${e(set.extractor)}</span>
|
|
326
507
|
</div>
|
|
327
|
-
<div class="stamp">Field Report</div>
|
|
328
508
|
</header>
|
|
329
509
|
<section>
|
|
330
|
-
<h2
|
|
510
|
+
<h2>Front summary</h2>
|
|
331
511
|
<div class="fronts">
|
|
332
512
|
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
333
513
|
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
@@ -337,8 +517,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
337
517
|
<ul class="openlist">${openList}</ul>
|
|
338
518
|
</section>
|
|
339
519
|
<section>
|
|
340
|
-
<h2
|
|
341
|
-
<div class="
|
|
520
|
+
<h2>Claim × vendor intensity matrix</h2>
|
|
521
|
+
<div class="key">
|
|
342
522
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
343
523
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
344
524
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
@@ -351,7 +531,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
351
531
|
</section>
|
|
352
532
|
${axisHtml.strategicMap}
|
|
353
533
|
<section>
|
|
354
|
-
<h2
|
|
534
|
+
<h2>Evidence appendix</h2>
|
|
355
535
|
${appendix}
|
|
356
536
|
</section>
|
|
357
537
|
<footer>
|
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,19 +107,73 @@ 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
|
+
/**
|
|
114
|
+
* Okabe–Ito palette: categorical, colorblind-safe, print-stable. Vendors are
|
|
115
|
+
* numbered in legend order so a dense cluster stays readable — the number in
|
|
116
|
+
* the bubble resolves what overlapping labels never could.
|
|
117
|
+
*/
|
|
118
|
+
const VENDOR_COLORS = [
|
|
119
|
+
"#0072b2", "#e69f00", "#009e73", "#d55e00", "#cc79a7",
|
|
120
|
+
"#56b4e9", "#b8a000", "#717171", "#882255", "#44aa99",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/** Dark numerals on light fills, white on dark — simple luminance cut. */
|
|
124
|
+
function numeralColor(hex: string): string {
|
|
125
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
126
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
127
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
128
|
+
return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#1c1c1c" : "#ffffff";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wrap an axis-pole label into at most two short lines. Parentheticals are
|
|
133
|
+
* dropped first — poles like "REGULATED LEAD-GEN OPERATIONS (sales-led PEO)"
|
|
134
|
+
* keep their head, and nothing ever runs into the opposite corner.
|
|
135
|
+
*/
|
|
136
|
+
function wrapPole(text: string, maxChars = 20): string[] {
|
|
137
|
+
let cleaned = text.replace(/\s*\([^)]*\)/g, "").trim();
|
|
138
|
+
if (cleaned.length === 0) cleaned = text.trim();
|
|
139
|
+
const words = cleaned.split(/\s+/);
|
|
140
|
+
const lines: string[] = [];
|
|
141
|
+
let current = "";
|
|
142
|
+
for (const word of words) {
|
|
143
|
+
if (current && (current + " " + word).length > maxChars) {
|
|
144
|
+
lines.push(current);
|
|
145
|
+
current = word;
|
|
146
|
+
if (lines.length === 2) break;
|
|
147
|
+
} else {
|
|
148
|
+
current = current ? `${current} ${word}` : word;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (lines.length < 2 && current) lines.push(current);
|
|
152
|
+
if (lines.length === 2 && current && lines[1] !== current) lines[1] = `${lines[1]}…`;
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function poleText(lines: string[], x: number, y: number, anchorMode: string, fs: number): string {
|
|
157
|
+
const e = escapeHtml;
|
|
158
|
+
const spans = lines
|
|
159
|
+
.map((line, index) => `<tspan x="${x}" dy="${index === 0 ? 0 : fs + 2}">${e(line)}</tspan>`)
|
|
160
|
+
.join("");
|
|
161
|
+
return `<text class="ax-label" style="font-size:${fs}px" x="${x}" y="${y}" text-anchor="${anchorMode}">${spans}</text>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
113
164
|
function svgScatter(
|
|
114
165
|
points: ScatterPoint[],
|
|
115
166
|
ax: ScatterAxis,
|
|
116
167
|
ay: ScatterAxis,
|
|
117
168
|
anchor: string | undefined,
|
|
118
|
-
|
|
169
|
+
colorByVendor: Map<string, string>,
|
|
170
|
+
numberByVendor: Map<string, number>,
|
|
119
171
|
): string {
|
|
120
|
-
const W =
|
|
121
|
-
const H =
|
|
122
|
-
const
|
|
172
|
+
const W = 640;
|
|
173
|
+
const H = 480;
|
|
174
|
+
const PAD_X = 56;
|
|
175
|
+
const PAD_TOP = 44;
|
|
176
|
+
const PAD_BOTTOM = 56;
|
|
123
177
|
const range = (axis: ScatterAxis, values: number[]): [number, number] => {
|
|
124
178
|
if (axis.signed) return [-1.1, 1.1];
|
|
125
179
|
if (values.length === 0) return [0, 1];
|
|
@@ -127,30 +181,58 @@ function svgScatter(
|
|
|
127
181
|
};
|
|
128
182
|
const [xLo, xHi] = range(ax, points.map((p) => p.x));
|
|
129
183
|
const [yLo, yHi] = range(ay, points.map((p) => p.y));
|
|
130
|
-
const sx = (x: number) =>
|
|
131
|
-
const sy = (y: number) => H -
|
|
132
|
-
const fsLabel = mini ? 8.5 : 10.5;
|
|
133
|
-
const fsAx = mini ? 8 : 10;
|
|
184
|
+
const sx = (x: number) => PAD_X + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD_X);
|
|
185
|
+
const sy = (y: number) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
|
|
134
186
|
const e = escapeHtml;
|
|
135
|
-
|
|
187
|
+
|
|
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.
|
|
191
|
+
const ordered = [...points].sort((a, b) => b.size - a.size);
|
|
192
|
+
const dots = ordered
|
|
136
193
|
.map((p) => {
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
194
|
+
const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
|
|
195
|
+
const color = colorByVendor.get(p.vendorId) ?? "#717171";
|
|
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.
|
|
203
|
+
const fs = Math.max(10, Math.min(14, r * 0.9));
|
|
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>`;
|
|
144
209
|
})
|
|
145
210
|
.join("");
|
|
146
|
-
|
|
147
|
-
const
|
|
211
|
+
|
|
212
|
+
const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
|
|
213
|
+
const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
|
|
214
|
+
|
|
215
|
+
// Pole labels in four positions that cannot collide: x poles horizontal at
|
|
216
|
+
// the bottom corners; y poles rotated along the left margin (positive
|
|
217
|
+
// reading up toward the top, negative near the bottom) — the standard
|
|
218
|
+
// chart convention, wrapped to ≤2 short lines each.
|
|
219
|
+
const fsPole = 10;
|
|
220
|
+
const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
|
|
221
|
+
const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
|
|
222
|
+
const yLabel = (lines: string[], yEdge: number, anchorMode: "start" | "end") => {
|
|
223
|
+
const spans = lines
|
|
224
|
+
.map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
|
|
225
|
+
.join("");
|
|
226
|
+
return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
|
|
227
|
+
};
|
|
228
|
+
const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
|
|
229
|
+
const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
|
|
230
|
+
|
|
148
231
|
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${PAD - 10}">↑ ${e(ay.positivePole)}${ay.signed ? ` · ↓ ${e(ay.negativePole)}` : ""}</text>
|
|
232
|
+
<rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
|
|
233
|
+
${midX}${midY}
|
|
234
|
+
${xNeg}${xPos}
|
|
235
|
+
${yPos}${yNeg}
|
|
154
236
|
${dots}</svg>`;
|
|
155
237
|
}
|
|
156
238
|
|
|
@@ -168,18 +250,26 @@ function axisSectionsHtml(
|
|
|
168
250
|
// when every placeable vendor has one; LOUD count otherwise — never mix
|
|
169
251
|
// the two semantics on one chart.
|
|
170
252
|
const scale = computeScaleIndex(config);
|
|
253
|
+
const scaleByVendor = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor]));
|
|
171
254
|
const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
|
|
172
|
-
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;
|
|
173
260
|
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
174
|
-
const maxLoud = Math.max(1, ...loudCounts.values());
|
|
175
261
|
// Bubble areas stay proportional to the metric; dividing by the max just
|
|
176
262
|
// spends the full visual range without distorting any ratio.
|
|
177
263
|
const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
|
|
178
|
-
const sizeOf = (vendorId: string): number =>
|
|
179
|
-
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";
|
|
180
270
|
const sizeCaption = useScale
|
|
181
|
-
? `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)
|
|
182
|
-
: "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";
|
|
183
273
|
|
|
184
274
|
const breadthAxis: ScatterAxis & { id: string } = {
|
|
185
275
|
id: "breadth",
|
|
@@ -222,6 +312,7 @@ function axisSectionsHtml(
|
|
|
222
312
|
x: xs.get(vendorId) as number,
|
|
223
313
|
y: ys.get(vendorId) as number,
|
|
224
314
|
size: sizeOf(vendorId),
|
|
315
|
+
noScale: noScaleFor(vendorId),
|
|
225
316
|
}));
|
|
226
317
|
};
|
|
227
318
|
|
|
@@ -229,9 +320,100 @@ function axisSectionsHtml(
|
|
|
229
320
|
const axInfo = axisInfo.get(px) as ScatterAxis & { id: string };
|
|
230
321
|
const ayInfo = axisInfo.get(py) as ScatterAxis & { id: string };
|
|
231
322
|
const statusOf = (id: string) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
|
|
323
|
+
|
|
324
|
+
// Legend order doubles as bubble numbering: largest first, anchor bolded.
|
|
325
|
+
// The number inside each bubble resolves dense clusters that name labels
|
|
326
|
+
// never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
|
|
327
|
+
const points = pointsFor(px, py);
|
|
328
|
+
const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
|
|
329
|
+
const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
|
|
330
|
+
const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
|
|
331
|
+
const legendRows = legendOrder
|
|
332
|
+
.map((point) => {
|
|
333
|
+
const number = numberByVendor.get(point.vendorId);
|
|
334
|
+
const color = colorByVendor.get(point.vendorId) as string;
|
|
335
|
+
const isAnchor = point.vendorId === config.anchorVendor;
|
|
336
|
+
const share = scaleIndex.get(point.vendorId);
|
|
337
|
+
const measure = useScale
|
|
338
|
+
? typeof share === "number"
|
|
339
|
+
? `${(share * 100).toFixed(1)}%`
|
|
340
|
+
: "—"
|
|
341
|
+
: `${loudCounts.get(point.vendorId) ?? 0} loud`;
|
|
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>`;
|
|
343
|
+
})
|
|
344
|
+
.join("");
|
|
345
|
+
const legendMeasureHead = useScale ? "est. share" : "loud";
|
|
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
|
+
|
|
232
369
|
const strategicMap = `<section>
|
|
233
|
-
<h2
|
|
234
|
-
<figure
|
|
370
|
+
<h2>Strategic map: ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
371
|
+
<figure class="map">
|
|
372
|
+
<div class="map-row">
|
|
373
|
+
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
|
|
374
|
+
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
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>
|
|
235
417
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
236
418
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
237
419
|
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
@@ -255,7 +437,6 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
255
437
|
const anchor = config.anchorVendor;
|
|
256
438
|
const e = escapeHtml;
|
|
257
439
|
const axisHtml = axisSectionsHtml(config, set);
|
|
258
|
-
const appendixNo = axisHtml.report ? "04" : "03";
|
|
259
440
|
|
|
260
441
|
const matrixRows = model.orderedClaimIds
|
|
261
442
|
.map((claimId) => {
|
|
@@ -315,81 +496,86 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
315
496
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
316
497
|
<title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
|
|
317
498
|
<style>
|
|
318
|
-
:root { --
|
|
499
|
+
:root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
|
|
319
500
|
* { box-sizing:border-box; margin:0; }
|
|
320
|
-
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background
|
|
321
|
-
max-width:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
.
|
|
329
|
-
.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
|
|
333
|
-
border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
|
|
334
|
-
h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
|
|
335
|
-
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
|
|
336
|
-
.fcard { background:var(--paper); padding:18px 18px 14px; }
|
|
337
|
-
.fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
|
|
338
|
-
.fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
|
|
501
|
+
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
|
|
502
|
+
max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
|
|
503
|
+
.mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
504
|
+
header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
|
|
505
|
+
h1 { font-size:27px; font-weight:600; line-height:1.2; }
|
|
506
|
+
.meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
|
|
507
|
+
section { margin-top:44px; }
|
|
508
|
+
h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
|
|
509
|
+
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
|
|
510
|
+
.fcard { background:#fff; padding:14px 16px 12px; }
|
|
511
|
+
.fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
|
|
512
|
+
.fcard span { font-size:11px; color:var(--soft); }
|
|
339
513
|
.fcard.open b { color:var(--accent); }
|
|
340
|
-
.openlist { margin-top:
|
|
341
|
-
.openlist li { margin:
|
|
342
|
-
.openlist .why { color:var(--
|
|
343
|
-
.
|
|
344
|
-
.lg { display:inline-flex; align-items:center; gap:
|
|
514
|
+
.openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
|
|
515
|
+
.openlist li { margin:3px 0 3px 20px; }
|
|
516
|
+
.openlist .why { color:var(--soft); font-size:13px; }
|
|
517
|
+
.key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
|
|
518
|
+
.lg { display:inline-flex; align-items:center; gap:6px; }
|
|
345
519
|
table { border-collapse:collapse; width:100%; margin-top:6px; }
|
|
346
|
-
thead th { border-bottom:
|
|
347
|
-
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px;
|
|
348
|
-
text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
|
|
520
|
+
thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
|
|
521
|
+
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
|
|
349
522
|
th.vh.anchor-col span { color:var(--green); font-weight:700; }
|
|
350
|
-
tbody th { text-align:left; font-weight:400; padding:
|
|
351
|
-
.claim-cap { display:block; font-size:
|
|
352
|
-
.claim-meta { display:block; font-size:9.5px; color:var(--
|
|
523
|
+
tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
|
|
524
|
+
.claim-cap { display:block; font-size:14px; }
|
|
525
|
+
.claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
|
|
353
526
|
td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
|
|
354
|
-
td.cell.anchor-col { background:
|
|
527
|
+
td.cell.anchor-col { background:var(--faint); }
|
|
355
528
|
td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
|
|
356
|
-
.g { display:inline-block; width:
|
|
529
|
+
.g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
|
|
357
530
|
.g-loud { background:var(--ink); }
|
|
358
|
-
.g-quiet { box-shadow:inset 0 0 0 2px
|
|
359
|
-
.g-absent { background:radial-gradient(circle at center,
|
|
360
|
-
.g-unobservable { background:repeating-linear-gradient(45deg,
|
|
531
|
+
.g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
|
|
532
|
+
.g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
|
|
533
|
+
.g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
|
|
361
534
|
tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
362
|
-
.chip { font-size:9px;
|
|
363
|
-
.chip-open { color:var(--accent); } .chip-contested { color:#
|
|
364
|
-
.chip-owned { color:var(--green); } .chip-saturated { color:var(--
|
|
365
|
-
.ev { border-bottom:1px solid var(--line); padding:
|
|
366
|
-
.ev-head { font-size:10.5px;
|
|
367
|
-
.ev blockquote {
|
|
368
|
-
.ev-src { font-size:10px; color:var(--
|
|
369
|
-
figure { margin-top:
|
|
370
|
-
.
|
|
371
|
-
.
|
|
372
|
-
.
|
|
373
|
-
.
|
|
374
|
-
.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
535
|
+
.chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
|
|
536
|
+
.chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
|
|
537
|
+
.chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
|
|
538
|
+
.ev { border-bottom:1px solid var(--line); padding:10px 0; }
|
|
539
|
+
.ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
|
|
540
|
+
.ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
|
|
541
|
+
.ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
|
|
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; }
|
|
551
|
+
.map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
|
|
552
|
+
.map-row svg { flex:1 1 62%; min-width:0; }
|
|
553
|
+
.plot { fill:var(--faint); stroke:var(--line); }
|
|
554
|
+
.grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
|
|
555
|
+
.ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
556
|
+
table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
|
|
557
|
+
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; }
|
|
558
|
+
table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
|
|
559
|
+
table.legend td.num, table.legend th.num { text-align:right; }
|
|
560
|
+
table.legend tr.anchor-row td { font-weight:700; }
|
|
561
|
+
.swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
|
|
562
|
+
color:#fff; font-size:10.5px; font-weight:700; }
|
|
563
|
+
figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
|
|
564
|
+
footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
|
|
378
565
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
379
|
-
@media print { body { max-width:none; padding:0 8mm;
|
|
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; } }
|
|
567
|
+
@media (max-width:760px) { .map-row { display:block; } }
|
|
380
568
|
</style></head><body>
|
|
381
569
|
<header>
|
|
382
|
-
<
|
|
383
|
-
<h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
|
|
570
|
+
<h1>Market map — ${e(config.category.replace(/-/g, " "))}</h1>
|
|
384
571
|
<div class="meta">
|
|
385
|
-
<span>
|
|
386
|
-
<span>${config.vendors.length}
|
|
387
|
-
<span
|
|
572
|
+
<span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
|
|
573
|
+
<span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
|
|
574
|
+
<span>extractor: ${e(set.extractor)}</span>
|
|
388
575
|
</div>
|
|
389
|
-
<div class="stamp">Field Report</div>
|
|
390
576
|
</header>
|
|
391
577
|
<section>
|
|
392
|
-
<h2
|
|
578
|
+
<h2>Front summary</h2>
|
|
393
579
|
<div class="fronts">
|
|
394
580
|
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
395
581
|
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
@@ -399,8 +585,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
399
585
|
<ul class="openlist">${openList}</ul>
|
|
400
586
|
</section>
|
|
401
587
|
<section>
|
|
402
|
-
<h2
|
|
403
|
-
<div class="
|
|
588
|
+
<h2>Claim × vendor intensity matrix</h2>
|
|
589
|
+
<div class="key">
|
|
404
590
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
405
591
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
406
592
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
@@ -413,7 +599,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
413
599
|
</section>
|
|
414
600
|
${axisHtml.strategicMap}
|
|
415
601
|
<section>
|
|
416
|
-
<h2
|
|
602
|
+
<h2>Evidence appendix</h2>
|
|
417
603
|
${appendix}
|
|
418
604
|
</section>
|
|
419
605
|
<footer>
|