fullstackgtm 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/market.d.ts +14 -0
- package/dist/marketReport.js +183 -89
- package/dist/marketScale.d.ts +53 -24
- package/dist/marketScale.js +144 -44
- package/package.json +1 -1
- package/src/index.ts +9 -1
- package/src/market.ts +14 -0
- package/src/marketReport.ts +189 -89
- package/src/marketScale.ts +204 -64
package/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
|
|
|
@@ -172,10 +248,13 @@ function axisSectionsHtml(
|
|
|
172
248
|
const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
|
|
173
249
|
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
174
250
|
const maxLoud = Math.max(1, ...loudCounts.values());
|
|
251
|
+
// Bubble areas stay proportional to the metric; dividing by the max just
|
|
252
|
+
// spends the full visual range without distorting any ratio.
|
|
253
|
+
const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
|
|
175
254
|
const sizeOf = (vendorId: string): number =>
|
|
176
|
-
useScale ? (scaleIndex.get(vendorId) as number) : (loudCounts.get(vendorId) ?? 0) / maxLoud;
|
|
255
|
+
useScale ? (scaleIndex.get(vendorId) as number) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
|
|
177
256
|
const sizeCaption = useScale
|
|
178
|
-
? `Dot area ∝
|
|
257
|
+
? `Dot area ∝ estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads)`
|
|
179
258
|
: "Dot area ∝ LOUD count";
|
|
180
259
|
|
|
181
260
|
const breadthAxis: ScatterAxis & { id: string } = {
|
|
@@ -226,9 +305,34 @@ function axisSectionsHtml(
|
|
|
226
305
|
const axInfo = axisInfo.get(px) as ScatterAxis & { id: string };
|
|
227
306
|
const ayInfo = axisInfo.get(py) as ScatterAxis & { id: string };
|
|
228
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
|
+
|
|
229
329
|
const strategicMap = `<section>
|
|
230
|
-
<h2
|
|
231
|
-
<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>
|
|
232
336
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
233
337
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
234
338
|
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
@@ -252,7 +356,6 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
252
356
|
const anchor = config.anchorVendor;
|
|
253
357
|
const e = escapeHtml;
|
|
254
358
|
const axisHtml = axisSectionsHtml(config, set);
|
|
255
|
-
const appendixNo = axisHtml.report ? "04" : "03";
|
|
256
359
|
|
|
257
360
|
const matrixRows = model.orderedClaimIds
|
|
258
361
|
.map((claimId) => {
|
|
@@ -312,81 +415,78 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
312
415
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
313
416
|
<title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
|
|
314
417
|
<style>
|
|
315
|
-
:root { --
|
|
418
|
+
:root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
|
|
316
419
|
* { box-sizing:border-box; margin:0; }
|
|
317
|
-
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background
|
|
318
|
-
max-width:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
.
|
|
326
|
-
.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
|
|
330
|
-
border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
|
|
331
|
-
h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
|
|
332
|
-
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
|
|
333
|
-
.fcard { background:var(--paper); padding:18px 18px 14px; }
|
|
334
|
-
.fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
|
|
335
|
-
.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); }
|
|
336
432
|
.fcard.open b { color:var(--accent); }
|
|
337
|
-
.openlist { margin-top:
|
|
338
|
-
.openlist li { margin:
|
|
339
|
-
.openlist .why { color:var(--
|
|
340
|
-
.
|
|
341
|
-
.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; }
|
|
342
438
|
table { border-collapse:collapse; width:100%; margin-top:6px; }
|
|
343
|
-
thead th { border-bottom:
|
|
344
|
-
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px;
|
|
345
|
-
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; }
|
|
346
441
|
th.vh.anchor-col span { color:var(--green); font-weight:700; }
|
|
347
|
-
tbody th { text-align:left; font-weight:400; padding:
|
|
348
|
-
.claim-cap { display:block; font-size:
|
|
349
|
-
.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; }
|
|
350
445
|
td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
|
|
351
|
-
td.cell.anchor-col { background:
|
|
446
|
+
td.cell.anchor-col { background:var(--faint); }
|
|
352
447
|
td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
|
|
353
|
-
.g { display:inline-block; width:
|
|
448
|
+
.g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
|
|
354
449
|
.g-loud { background:var(--ink); }
|
|
355
|
-
.g-quiet { box-shadow:inset 0 0 0 2px
|
|
356
|
-
.g-absent { background:radial-gradient(circle at center,
|
|
357
|
-
.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); }
|
|
358
453
|
tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
359
|
-
.chip { font-size:9px;
|
|
360
|
-
.chip-open { color:var(--accent); } .chip-contested { color:#
|
|
361
|
-
.chip-owned { color:var(--green); } .chip-saturated { color:var(--
|
|
362
|
-
.ev { border-bottom:1px solid var(--line); padding:
|
|
363
|
-
.ev-head { font-size:10.5px;
|
|
364
|
-
.ev blockquote {
|
|
365
|
-
.ev-src { font-size:10px; color:var(--
|
|
366
|
-
figure { margin-top:
|
|
367
|
-
.
|
|
368
|
-
.
|
|
369
|
-
.
|
|
370
|
-
.
|
|
371
|
-
.
|
|
372
|
-
.
|
|
373
|
-
|
|
374
|
-
|
|
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);
|
|
375
476
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
376
|
-
@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; } }
|
|
377
479
|
</style></head><body>
|
|
378
480
|
<header>
|
|
379
|
-
<
|
|
380
|
-
<h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
|
|
481
|
+
<h1>Market map — ${e(config.category.replace(/-/g, " "))}</h1>
|
|
381
482
|
<div class="meta">
|
|
382
|
-
<span>
|
|
383
|
-
<span>${config.vendors.length}
|
|
384
|
-
<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>
|
|
385
486
|
</div>
|
|
386
|
-
<div class="stamp">Field Report</div>
|
|
387
487
|
</header>
|
|
388
488
|
<section>
|
|
389
|
-
<h2
|
|
489
|
+
<h2>Front summary</h2>
|
|
390
490
|
<div class="fronts">
|
|
391
491
|
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
392
492
|
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
@@ -396,8 +496,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
396
496
|
<ul class="openlist">${openList}</ul>
|
|
397
497
|
</section>
|
|
398
498
|
<section>
|
|
399
|
-
<h2
|
|
400
|
-
<div class="
|
|
499
|
+
<h2>Claim × vendor intensity matrix</h2>
|
|
500
|
+
<div class="key">
|
|
401
501
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
402
502
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
403
503
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
@@ -410,7 +510,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
410
510
|
</section>
|
|
411
511
|
${axisHtml.strategicMap}
|
|
412
512
|
<section>
|
|
413
|
-
<h2
|
|
513
|
+
<h2>Evidence appendix</h2>
|
|
414
514
|
${appendix}
|
|
415
515
|
</section>
|
|
416
516
|
<footer>
|