fullstackgtm 0.21.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/marketReport.js +178 -87
- package/package.json +1 -1
- package/src/marketReport.ts +184 -87
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.21.1] — 2026-06-12
|
|
9
|
+
|
|
10
|
+
Report design pass: analyst-grade restraint, legible dense scatters.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **De-slopped the field report**: removed the rotated stamp, paper-grain
|
|
15
|
+
background, parchment palette, letterspaced kickers, and editorial hero
|
|
16
|
+
voice. White page, hairline rules, plain headings — the design recedes,
|
|
17
|
+
the data reads.
|
|
18
|
+
- **Strategic map legibility**: bubbles are now numbered and colored
|
|
19
|
+
(Okabe–Ito colorblind-safe palette) with a legend table beside the chart
|
|
20
|
+
that doubles as the share table (number · color · vendor · est. share or
|
|
21
|
+
LOUD count, anchor bolded). Larger bubbles render first so overlapping
|
|
22
|
+
clusters stay readable; the number resolves what overlapping name labels
|
|
23
|
+
never could. Hover tooltips keep the names.
|
|
24
|
+
- **Axis pole labels can no longer collide**: wrapped to ≤2 short lines
|
|
25
|
+
(parentheticals dropped first), x poles at the bottom corners, y poles
|
|
26
|
+
rotated along the left margin — four positions, standard chart
|
|
27
|
+
convention.
|
|
28
|
+
|
|
8
29
|
## [0.21.0] — 2026-06-12
|
|
9
30
|
|
|
10
31
|
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,46 @@ function svgScatter(points, ax, ay, anchor, mini) {
|
|
|
92
146
|
};
|
|
93
147
|
const [xLo, xHi] = range(ax, points.map((p) => p.x));
|
|
94
148
|
const [yLo, yHi] = range(ay, points.map((p) => p.y));
|
|
95
|
-
const sx = (x) =>
|
|
96
|
-
const sy = (y) => H -
|
|
97
|
-
const fsLabel = mini ? 8.5 : 10.5;
|
|
98
|
-
const fsAx = mini ? 8 : 10;
|
|
149
|
+
const sx = (x) => PAD_X + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD_X);
|
|
150
|
+
const sy = (y) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
|
|
99
151
|
const e = escapeHtml;
|
|
100
|
-
|
|
152
|
+
// Big bubbles first so small ones stay clickable/visible on top.
|
|
153
|
+
const ordered = [...points].sort((a, b) => b.size - a.size);
|
|
154
|
+
const dots = ordered
|
|
101
155
|
.map((p) => {
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
156
|
+
const r = 7 + 24 * Math.sqrt(p.size);
|
|
157
|
+
const color = colorByVendor.get(p.vendorId) ?? "#717171";
|
|
158
|
+
const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
|
|
159
|
+
const number = numberByVendor.get(p.vendorId) ?? 0;
|
|
160
|
+
const fs = Math.max(10, Math.min(14, r * 0.9));
|
|
161
|
+
return (`<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}" fill="${color}" fill-opacity="0.88"${ring}>` +
|
|
162
|
+
`<title>${e(p.name)}</title></circle>` +
|
|
163
|
+
`<text x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) + fs * 0.36).toFixed(1)}" text-anchor="middle" ` +
|
|
164
|
+
`font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`);
|
|
107
165
|
})
|
|
108
166
|
.join("");
|
|
109
|
-
const midX = ax.signed ? `<line class="
|
|
110
|
-
const midY = ay.signed ? `<line class="
|
|
167
|
+
const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
|
|
168
|
+
const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
|
|
169
|
+
// Pole labels in four positions that cannot collide: x poles horizontal at
|
|
170
|
+
// the bottom corners; y poles rotated along the left margin (positive
|
|
171
|
+
// reading up toward the top, negative near the bottom) — the standard
|
|
172
|
+
// chart convention, wrapped to ≤2 short lines each.
|
|
173
|
+
const fsPole = 10;
|
|
174
|
+
const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
|
|
175
|
+
const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
|
|
176
|
+
const yLabel = (lines, yEdge, anchorMode) => {
|
|
177
|
+
const spans = lines
|
|
178
|
+
.map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
|
|
179
|
+
.join("");
|
|
180
|
+
return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
|
|
181
|
+
};
|
|
182
|
+
const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
|
|
183
|
+
const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
|
|
111
184
|
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
<text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${PAD - 10}">↑ ${e(ay.positivePole)}${ay.signed ? ` · ↓ ${e(ay.negativePole)}` : ""}</text>
|
|
185
|
+
<rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
|
|
186
|
+
${midX}${midY}
|
|
187
|
+
${xNeg}${xPos}
|
|
188
|
+
${yPos}${yNeg}
|
|
117
189
|
${dots}</svg>`;
|
|
118
190
|
}
|
|
119
191
|
function axisSectionsHtml(config, set) {
|
|
@@ -181,9 +253,32 @@ function axisSectionsHtml(config, set) {
|
|
|
181
253
|
const axInfo = axisInfo.get(px);
|
|
182
254
|
const ayInfo = axisInfo.get(py);
|
|
183
255
|
const statusOf = (id) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
|
|
256
|
+
// Legend order doubles as bubble numbering: largest first, anchor bolded.
|
|
257
|
+
// The number inside each bubble resolves dense clusters that name labels
|
|
258
|
+
// never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
|
|
259
|
+
const points = pointsFor(px, py);
|
|
260
|
+
const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
|
|
261
|
+
const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
|
|
262
|
+
const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
|
|
263
|
+
const legendRows = legendOrder
|
|
264
|
+
.map((point) => {
|
|
265
|
+
const number = numberByVendor.get(point.vendorId);
|
|
266
|
+
const color = colorByVendor.get(point.vendorId);
|
|
267
|
+
const isAnchor = point.vendorId === config.anchorVendor;
|
|
268
|
+
const measure = useScale
|
|
269
|
+
? `${((scaleIndex.get(point.vendorId) ?? 0) * 100).toFixed(1)}%`
|
|
270
|
+
: `${loudCounts.get(point.vendorId) ?? 0} loud`;
|
|
271
|
+
return `<tr${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " · anchor" : ""}</td><td class="num">${measure}</td></tr>`;
|
|
272
|
+
})
|
|
273
|
+
.join("");
|
|
274
|
+
const legendMeasureHead = useScale ? "est. share" : "loud";
|
|
184
275
|
const strategicMap = `<section>
|
|
185
|
-
<h2
|
|
186
|
-
<figure
|
|
276
|
+
<h2>Strategic map: ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
277
|
+
<figure class="map">
|
|
278
|
+
<div class="map-row">
|
|
279
|
+
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
|
|
280
|
+
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
281
|
+
</div>
|
|
187
282
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
188
283
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
189
284
|
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
@@ -206,7 +301,6 @@ export function marketMapToHtml(config, set) {
|
|
|
206
301
|
const anchor = config.anchorVendor;
|
|
207
302
|
const e = escapeHtml;
|
|
208
303
|
const axisHtml = axisSectionsHtml(config, set);
|
|
209
|
-
const appendixNo = axisHtml.report ? "04" : "03";
|
|
210
304
|
const matrixRows = model.orderedClaimIds
|
|
211
305
|
.map((claimId) => {
|
|
212
306
|
const claim = claimsById.get(claimId);
|
|
@@ -253,81 +347,78 @@ export function marketMapToHtml(config, set) {
|
|
|
253
347
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
254
348
|
<title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
|
|
255
349
|
<style>
|
|
256
|
-
:root { --
|
|
350
|
+
:root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
|
|
257
351
|
* { 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); }
|
|
352
|
+
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
|
|
353
|
+
max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
|
|
354
|
+
.mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
355
|
+
header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
|
|
356
|
+
h1 { font-size:27px; font-weight:600; line-height:1.2; }
|
|
357
|
+
.meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
|
|
358
|
+
section { margin-top:44px; }
|
|
359
|
+
h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
|
|
360
|
+
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
|
|
361
|
+
.fcard { background:#fff; padding:14px 16px 12px; }
|
|
362
|
+
.fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
|
|
363
|
+
.fcard span { font-size:11px; color:var(--soft); }
|
|
277
364
|
.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:
|
|
365
|
+
.openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
|
|
366
|
+
.openlist li { margin:3px 0 3px 20px; }
|
|
367
|
+
.openlist .why { color:var(--soft); font-size:13px; }
|
|
368
|
+
.key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
|
|
369
|
+
.lg { display:inline-flex; align-items:center; gap:6px; }
|
|
283
370
|
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; }
|
|
371
|
+
thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
|
|
372
|
+
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
|
|
287
373
|
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(--
|
|
374
|
+
tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
|
|
375
|
+
.claim-cap { display:block; font-size:14px; }
|
|
376
|
+
.claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
|
|
291
377
|
td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
|
|
292
|
-
td.cell.anchor-col { background:
|
|
378
|
+
td.cell.anchor-col { background:var(--faint); }
|
|
293
379
|
td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
|
|
294
|
-
.g { display:inline-block; width:
|
|
380
|
+
.g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
|
|
295
381
|
.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,
|
|
382
|
+
.g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
|
|
383
|
+
.g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
|
|
384
|
+
.g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
|
|
299
385
|
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
|
-
|
|
386
|
+
.chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
|
|
387
|
+
.chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
|
|
388
|
+
.chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
|
|
389
|
+
.ev { border-bottom:1px solid var(--line); padding:10px 0; }
|
|
390
|
+
.ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
|
|
391
|
+
.ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
|
|
392
|
+
.ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
|
|
393
|
+
figure.map { margin-top:16px; border:1px solid var(--line); }
|
|
394
|
+
.map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
|
|
395
|
+
.map-row svg { flex:1 1 62%; min-width:0; }
|
|
396
|
+
.plot { fill:var(--faint); stroke:var(--line); }
|
|
397
|
+
.grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
|
|
398
|
+
.ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
399
|
+
table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
|
|
400
|
+
table.legend thead th { font-size:10px; color:var(--soft); font-weight:600; border-bottom:1px solid var(--line); padding:2px 8px 5px 0; text-align:left; }
|
|
401
|
+
table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
|
|
402
|
+
table.legend td.num, table.legend th.num { text-align:right; }
|
|
403
|
+
table.legend tr.anchor-row td { font-weight:700; }
|
|
404
|
+
.swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
|
|
405
|
+
color:#fff; font-size:10.5px; font-weight:700; }
|
|
406
|
+
figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
|
|
407
|
+
footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
|
|
316
408
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
317
|
-
@media print { body { max-width:none; padding:0 8mm;
|
|
409
|
+
@media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
|
|
410
|
+
@media (max-width:760px) { .map-row { display:block; } }
|
|
318
411
|
</style></head><body>
|
|
319
412
|
<header>
|
|
320
|
-
<
|
|
321
|
-
<h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
|
|
413
|
+
<h1>Market map — ${e(config.category.replace(/-/g, " "))}</h1>
|
|
322
414
|
<div class="meta">
|
|
323
|
-
<span>
|
|
324
|
-
<span>${config.vendors.length}
|
|
325
|
-
<span
|
|
415
|
+
<span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
|
|
416
|
+
<span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
|
|
417
|
+
<span>extractor: ${e(set.extractor)}</span>
|
|
326
418
|
</div>
|
|
327
|
-
<div class="stamp">Field Report</div>
|
|
328
419
|
</header>
|
|
329
420
|
<section>
|
|
330
|
-
<h2
|
|
421
|
+
<h2>Front summary</h2>
|
|
331
422
|
<div class="fronts">
|
|
332
423
|
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
333
424
|
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
@@ -337,8 +428,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
337
428
|
<ul class="openlist">${openList}</ul>
|
|
338
429
|
</section>
|
|
339
430
|
<section>
|
|
340
|
-
<h2
|
|
341
|
-
<div class="
|
|
431
|
+
<h2>Claim × vendor intensity matrix</h2>
|
|
432
|
+
<div class="key">
|
|
342
433
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
343
434
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
344
435
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
@@ -351,7 +442,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
351
442
|
</section>
|
|
352
443
|
${axisHtml.strategicMap}
|
|
353
444
|
<section>
|
|
354
|
-
<h2
|
|
445
|
+
<h2>Evidence appendix</h2>
|
|
355
446
|
${appendix}
|
|
356
447
|
</section>
|
|
357
448
|
<footer>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
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
|
@@ -110,16 +110,70 @@ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet):
|
|
|
110
110
|
type ScatterPoint = { vendorId: string; name: string; x: number; y: number; size: number };
|
|
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,52 @@ 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.
|
|
189
|
+
const ordered = [...points].sort((a, b) => b.size - a.size);
|
|
190
|
+
const dots = ordered
|
|
136
191
|
.map((p) => {
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
192
|
+
const r = 7 + 24 * Math.sqrt(p.size);
|
|
193
|
+
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
|
+
const number = numberByVendor.get(p.vendorId) ?? 0;
|
|
196
|
+
const fs = Math.max(10, Math.min(14, r * 0.9));
|
|
140
197
|
return (
|
|
141
|
-
`<circle
|
|
142
|
-
`<
|
|
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>`
|
|
143
202
|
);
|
|
144
203
|
})
|
|
145
204
|
.join("");
|
|
146
|
-
|
|
147
|
-
const
|
|
205
|
+
|
|
206
|
+
const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
|
|
207
|
+
const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
|
|
208
|
+
|
|
209
|
+
// Pole labels in four positions that cannot collide: x poles horizontal at
|
|
210
|
+
// the bottom corners; y poles rotated along the left margin (positive
|
|
211
|
+
// reading up toward the top, negative near the bottom) — the standard
|
|
212
|
+
// chart convention, wrapped to ≤2 short lines each.
|
|
213
|
+
const fsPole = 10;
|
|
214
|
+
const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
|
|
215
|
+
const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
|
|
216
|
+
const yLabel = (lines: string[], yEdge: number, anchorMode: "start" | "end") => {
|
|
217
|
+
const spans = lines
|
|
218
|
+
.map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
|
|
219
|
+
.join("");
|
|
220
|
+
return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
|
|
221
|
+
};
|
|
222
|
+
const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
|
|
223
|
+
const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
|
|
224
|
+
|
|
148
225
|
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>
|
|
226
|
+
<rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
|
|
227
|
+
${midX}${midY}
|
|
228
|
+
${xNeg}${xPos}
|
|
229
|
+
${yPos}${yNeg}
|
|
154
230
|
${dots}</svg>`;
|
|
155
231
|
}
|
|
156
232
|
|
|
@@ -229,9 +305,34 @@ function axisSectionsHtml(
|
|
|
229
305
|
const axInfo = axisInfo.get(px) as ScatterAxis & { id: string };
|
|
230
306
|
const ayInfo = axisInfo.get(py) as ScatterAxis & { id: string };
|
|
231
307
|
const statusOf = (id: string) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
|
|
308
|
+
|
|
309
|
+
// Legend order doubles as bubble numbering: largest first, anchor bolded.
|
|
310
|
+
// The number inside each bubble resolves dense clusters that name labels
|
|
311
|
+
// never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
|
|
312
|
+
const points = pointsFor(px, py);
|
|
313
|
+
const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
|
|
314
|
+
const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
|
|
315
|
+
const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
|
|
316
|
+
const legendRows = legendOrder
|
|
317
|
+
.map((point) => {
|
|
318
|
+
const number = numberByVendor.get(point.vendorId);
|
|
319
|
+
const color = colorByVendor.get(point.vendorId) as string;
|
|
320
|
+
const isAnchor = point.vendorId === config.anchorVendor;
|
|
321
|
+
const measure = useScale
|
|
322
|
+
? `${(((scaleIndex.get(point.vendorId) as number) ?? 0) * 100).toFixed(1)}%`
|
|
323
|
+
: `${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>`;
|
|
325
|
+
})
|
|
326
|
+
.join("");
|
|
327
|
+
const legendMeasureHead = useScale ? "est. share" : "loud";
|
|
328
|
+
|
|
232
329
|
const strategicMap = `<section>
|
|
233
|
-
<h2
|
|
234
|
-
<figure
|
|
330
|
+
<h2>Strategic map: ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
331
|
+
<figure class="map">
|
|
332
|
+
<div class="map-row">
|
|
333
|
+
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
|
|
334
|
+
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
335
|
+
</div>
|
|
235
336
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
236
337
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
237
338
|
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
@@ -255,7 +356,6 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
255
356
|
const anchor = config.anchorVendor;
|
|
256
357
|
const e = escapeHtml;
|
|
257
358
|
const axisHtml = axisSectionsHtml(config, set);
|
|
258
|
-
const appendixNo = axisHtml.report ? "04" : "03";
|
|
259
359
|
|
|
260
360
|
const matrixRows = model.orderedClaimIds
|
|
261
361
|
.map((claimId) => {
|
|
@@ -315,81 +415,78 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
315
415
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
316
416
|
<title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
|
|
317
417
|
<style>
|
|
318
|
-
:root { --
|
|
418
|
+
:root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
|
|
319
419
|
* { 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); }
|
|
420
|
+
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
|
|
421
|
+
max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
|
|
422
|
+
.mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
423
|
+
header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
|
|
424
|
+
h1 { font-size:27px; font-weight:600; line-height:1.2; }
|
|
425
|
+
.meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
|
|
426
|
+
section { margin-top:44px; }
|
|
427
|
+
h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
|
|
428
|
+
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
|
|
429
|
+
.fcard { background:#fff; padding:14px 16px 12px; }
|
|
430
|
+
.fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
|
|
431
|
+
.fcard span { font-size:11px; color:var(--soft); }
|
|
339
432
|
.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:
|
|
433
|
+
.openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
|
|
434
|
+
.openlist li { margin:3px 0 3px 20px; }
|
|
435
|
+
.openlist .why { color:var(--soft); font-size:13px; }
|
|
436
|
+
.key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
|
|
437
|
+
.lg { display:inline-flex; align-items:center; gap:6px; }
|
|
345
438
|
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; }
|
|
439
|
+
thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
|
|
440
|
+
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
|
|
349
441
|
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(--
|
|
442
|
+
tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
|
|
443
|
+
.claim-cap { display:block; font-size:14px; }
|
|
444
|
+
.claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
|
|
353
445
|
td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
|
|
354
|
-
td.cell.anchor-col { background:
|
|
446
|
+
td.cell.anchor-col { background:var(--faint); }
|
|
355
447
|
td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
|
|
356
|
-
.g { display:inline-block; width:
|
|
448
|
+
.g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
|
|
357
449
|
.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,
|
|
450
|
+
.g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
|
|
451
|
+
.g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
|
|
452
|
+
.g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
|
|
361
453
|
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
|
-
|
|
454
|
+
.chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
|
|
455
|
+
.chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
|
|
456
|
+
.chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
|
|
457
|
+
.ev { border-bottom:1px solid var(--line); padding:10px 0; }
|
|
458
|
+
.ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
|
|
459
|
+
.ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
|
|
460
|
+
.ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
|
|
461
|
+
figure.map { margin-top:16px; border:1px solid var(--line); }
|
|
462
|
+
.map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
|
|
463
|
+
.map-row svg { flex:1 1 62%; min-width:0; }
|
|
464
|
+
.plot { fill:var(--faint); stroke:var(--line); }
|
|
465
|
+
.grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
|
|
466
|
+
.ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
|
|
467
|
+
table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
|
|
468
|
+
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; }
|
|
469
|
+
table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
|
|
470
|
+
table.legend td.num, table.legend th.num { text-align:right; }
|
|
471
|
+
table.legend tr.anchor-row td { font-weight:700; }
|
|
472
|
+
.swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
|
|
473
|
+
color:#fff; font-size:10.5px; font-weight:700; }
|
|
474
|
+
figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
|
|
475
|
+
footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
|
|
378
476
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
379
|
-
@media print { body { max-width:none; padding:0 8mm;
|
|
477
|
+
@media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
|
|
478
|
+
@media (max-width:760px) { .map-row { display:block; } }
|
|
380
479
|
</style></head><body>
|
|
381
480
|
<header>
|
|
382
|
-
<
|
|
383
|
-
<h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
|
|
481
|
+
<h1>Market map — ${e(config.category.replace(/-/g, " "))}</h1>
|
|
384
482
|
<div class="meta">
|
|
385
|
-
<span>
|
|
386
|
-
<span>${config.vendors.length}
|
|
387
|
-
<span
|
|
483
|
+
<span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
|
|
484
|
+
<span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
|
|
485
|
+
<span>extractor: ${e(set.extractor)}</span>
|
|
388
486
|
</div>
|
|
389
|
-
<div class="stamp">Field Report</div>
|
|
390
487
|
</header>
|
|
391
488
|
<section>
|
|
392
|
-
<h2
|
|
489
|
+
<h2>Front summary</h2>
|
|
393
490
|
<div class="fronts">
|
|
394
491
|
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
395
492
|
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
@@ -399,8 +496,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
399
496
|
<ul class="openlist">${openList}</ul>
|
|
400
497
|
</section>
|
|
401
498
|
<section>
|
|
402
|
-
<h2
|
|
403
|
-
<div class="
|
|
499
|
+
<h2>Claim × vendor intensity matrix</h2>
|
|
500
|
+
<div class="key">
|
|
404
501
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
405
502
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
406
503
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
@@ -413,7 +510,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
413
510
|
</section>
|
|
414
511
|
${axisHtml.strategicMap}
|
|
415
512
|
<section>
|
|
416
|
-
<h2
|
|
513
|
+
<h2>Evidence appendix</h2>
|
|
417
514
|
${appendix}
|
|
418
515
|
</section>
|
|
419
516
|
<footer>
|