plotleaf 0.1.0 → 0.2.0
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/README.md +21 -2
- package/dist/assets/charts.js +17 -17
- package/dist/assets/styles.css +173 -0
- package/dist/cli.js +323 -17
- package/examples/onboarding/funnel.json +30 -0
- package/examples/onboarding/plans.json +14 -0
- package/examples/onboarding/report.md +42 -0
- package/examples/onboarding/signups.json +50 -0
- package/package.json +11 -1
- package/schema/components.json +135 -0
- package/skills/plotleaf/SKILL.md +14 -2
package/dist/assets/styles.css
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
--series-fill-end: rgba(79, 178, 239, 0);
|
|
16
16
|
--tooltip: #ffffff;
|
|
17
17
|
--target: #969ba2;
|
|
18
|
+
--slice-1: #429ee4;
|
|
19
|
+
--slice-2: #64afa1;
|
|
20
|
+
--slice-3: #8696b8;
|
|
21
|
+
--slice-4: #d4a05b;
|
|
22
|
+
--slice-5: #7984be;
|
|
23
|
+
--slice-6: #90c7df;
|
|
24
|
+
--slice-7: #b39581;
|
|
25
|
+
--slice-8: #a4acb9;
|
|
18
26
|
font-family:
|
|
19
27
|
ui-sans-serif,
|
|
20
28
|
-apple-system,
|
|
@@ -42,6 +50,14 @@
|
|
|
42
50
|
--series-fill-end: rgba(72, 158, 224, 0);
|
|
43
51
|
--tooltip: #25272c;
|
|
44
52
|
--target: #878d97;
|
|
53
|
+
--slice-1: #65b5ed;
|
|
54
|
+
--slice-2: #80c5b6;
|
|
55
|
+
--slice-3: #a2b2d0;
|
|
56
|
+
--slice-4: #e1b575;
|
|
57
|
+
--slice-5: #a1a9de;
|
|
58
|
+
--slice-6: #a1d8eb;
|
|
59
|
+
--slice-7: #c5aa99;
|
|
60
|
+
--slice-8: #b7bfcd;
|
|
45
61
|
}
|
|
46
62
|
}
|
|
47
63
|
* {
|
|
@@ -251,6 +267,163 @@ code {
|
|
|
251
267
|
.chart-data caption {
|
|
252
268
|
font-size: 15px;
|
|
253
269
|
}
|
|
270
|
+
.funnel-stages {
|
|
271
|
+
list-style: none;
|
|
272
|
+
padding: 0;
|
|
273
|
+
margin: 0;
|
|
274
|
+
max-width: none;
|
|
275
|
+
display: grid;
|
|
276
|
+
gap: 16px;
|
|
277
|
+
}
|
|
278
|
+
.funnel-stage {
|
|
279
|
+
--stage-fill: var(--series);
|
|
280
|
+
margin: 0;
|
|
281
|
+
min-width: 0;
|
|
282
|
+
font-size: 16px;
|
|
283
|
+
line-height: 1.5;
|
|
284
|
+
font-variant-numeric: tabular-nums;
|
|
285
|
+
}
|
|
286
|
+
.funnel-stage[data-status="on-target"] {
|
|
287
|
+
--stage-fill: var(--positive);
|
|
288
|
+
}
|
|
289
|
+
.funnel-stage[data-status="below-target"] {
|
|
290
|
+
--stage-fill: var(--warning);
|
|
291
|
+
}
|
|
292
|
+
.funnel-stage[data-status="at-risk"] {
|
|
293
|
+
--stage-fill: var(--negative);
|
|
294
|
+
}
|
|
295
|
+
.funnel-heading,
|
|
296
|
+
.funnel-values {
|
|
297
|
+
display: flex;
|
|
298
|
+
flex-wrap: wrap;
|
|
299
|
+
gap: 2px 12px;
|
|
300
|
+
}
|
|
301
|
+
.funnel-heading {
|
|
302
|
+
justify-content: space-between;
|
|
303
|
+
gap: 4px 16px;
|
|
304
|
+
overflow-wrap: anywhere;
|
|
305
|
+
}
|
|
306
|
+
.funnel-values {
|
|
307
|
+
color: var(--muted);
|
|
308
|
+
}
|
|
309
|
+
.funnel-values strong {
|
|
310
|
+
font-weight: 550;
|
|
311
|
+
}
|
|
312
|
+
.funnel-stage meter {
|
|
313
|
+
display: block;
|
|
314
|
+
appearance: none;
|
|
315
|
+
width: 100%;
|
|
316
|
+
height: 10px;
|
|
317
|
+
margin-block-start: 8px;
|
|
318
|
+
border: 0;
|
|
319
|
+
border-radius: 999px;
|
|
320
|
+
background: var(--soft);
|
|
321
|
+
}
|
|
322
|
+
.funnel-stage meter::-webkit-meter-bar {
|
|
323
|
+
height: 10px;
|
|
324
|
+
border: 0;
|
|
325
|
+
border-radius: 999px;
|
|
326
|
+
background: var(--soft);
|
|
327
|
+
box-shadow: none;
|
|
328
|
+
}
|
|
329
|
+
.funnel-stage meter::-webkit-meter-optimum-value,
|
|
330
|
+
.funnel-stage meter::-webkit-meter-suboptimum-value,
|
|
331
|
+
.funnel-stage meter::-webkit-meter-even-less-good-value {
|
|
332
|
+
border-radius: 999px;
|
|
333
|
+
background: var(--stage-fill);
|
|
334
|
+
}
|
|
335
|
+
.funnel-stage meter::-moz-meter-bar {
|
|
336
|
+
border-radius: 999px;
|
|
337
|
+
background: var(--stage-fill);
|
|
338
|
+
}
|
|
339
|
+
.funnel-status {
|
|
340
|
+
display: inline-flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
gap: 6px;
|
|
343
|
+
margin-block-start: 5px;
|
|
344
|
+
color: var(--muted);
|
|
345
|
+
font-size: 12px;
|
|
346
|
+
}
|
|
347
|
+
.funnel-status::before {
|
|
348
|
+
content: "";
|
|
349
|
+
width: 6px;
|
|
350
|
+
height: 6px;
|
|
351
|
+
border-radius: 50%;
|
|
352
|
+
background: var(--stage-fill);
|
|
353
|
+
}
|
|
354
|
+
.donut-layout {
|
|
355
|
+
display: flex;
|
|
356
|
+
flex-wrap: wrap;
|
|
357
|
+
align-items: center;
|
|
358
|
+
justify-content: center;
|
|
359
|
+
gap: 12px 24px;
|
|
360
|
+
}
|
|
361
|
+
.donut-plot {
|
|
362
|
+
flex: 1 1 180px;
|
|
363
|
+
min-width: 0;
|
|
364
|
+
max-width: 300px;
|
|
365
|
+
height: 260px;
|
|
366
|
+
}
|
|
367
|
+
.donut-legend {
|
|
368
|
+
flex: 1 1 210px;
|
|
369
|
+
min-width: 0;
|
|
370
|
+
max-width: 500px;
|
|
371
|
+
list-style: none;
|
|
372
|
+
padding: 0;
|
|
373
|
+
margin: 0;
|
|
374
|
+
font-size: 14px;
|
|
375
|
+
line-height: 1.5;
|
|
376
|
+
font-variant-numeric: tabular-nums;
|
|
377
|
+
}
|
|
378
|
+
.donut-legend li {
|
|
379
|
+
display: grid;
|
|
380
|
+
grid-template-columns: minmax(0, 1fr) auto auto;
|
|
381
|
+
align-items: baseline;
|
|
382
|
+
gap: 8px 14px;
|
|
383
|
+
margin: 0;
|
|
384
|
+
padding-block: 10px;
|
|
385
|
+
}
|
|
386
|
+
.donut-key {
|
|
387
|
+
display: flex;
|
|
388
|
+
align-items: baseline;
|
|
389
|
+
gap: 9px;
|
|
390
|
+
overflow-wrap: anywhere;
|
|
391
|
+
}
|
|
392
|
+
.donut-swatch {
|
|
393
|
+
flex: 0 0 8px;
|
|
394
|
+
height: 8px;
|
|
395
|
+
border-radius: 50%;
|
|
396
|
+
}
|
|
397
|
+
.donut-swatch[data-slice="1"] {
|
|
398
|
+
background: var(--slice-1);
|
|
399
|
+
}
|
|
400
|
+
.donut-swatch[data-slice="2"] {
|
|
401
|
+
background: var(--slice-2);
|
|
402
|
+
}
|
|
403
|
+
.donut-swatch[data-slice="3"] {
|
|
404
|
+
background: var(--slice-3);
|
|
405
|
+
}
|
|
406
|
+
.donut-swatch[data-slice="4"] {
|
|
407
|
+
background: var(--slice-4);
|
|
408
|
+
}
|
|
409
|
+
.donut-swatch[data-slice="5"] {
|
|
410
|
+
background: var(--slice-5);
|
|
411
|
+
}
|
|
412
|
+
.donut-swatch[data-slice="6"] {
|
|
413
|
+
background: var(--slice-6);
|
|
414
|
+
}
|
|
415
|
+
.donut-swatch[data-slice="7"] {
|
|
416
|
+
background: var(--slice-7);
|
|
417
|
+
}
|
|
418
|
+
.donut-swatch[data-slice="8"] {
|
|
419
|
+
background: var(--slice-8);
|
|
420
|
+
}
|
|
421
|
+
.donut-value {
|
|
422
|
+
color: var(--ink);
|
|
423
|
+
}
|
|
424
|
+
.donut-share {
|
|
425
|
+
color: var(--muted);
|
|
426
|
+
}
|
|
254
427
|
.table-scroll {
|
|
255
428
|
max-width: 100%;
|
|
256
429
|
overflow: auto;
|
package/dist/cli.js
CHANGED
|
@@ -66,6 +66,14 @@ ${d.hint}` : ""}`).join(`
|
|
|
66
66
|
import path2 from "node:path";
|
|
67
67
|
import { open, realpath, stat } from "node:fs/promises";
|
|
68
68
|
|
|
69
|
+
// src/shared/payload.ts
|
|
70
|
+
var funnelStatuses = {
|
|
71
|
+
"on-target": "On target",
|
|
72
|
+
"below-target": "Below target",
|
|
73
|
+
"at-risk": "At risk",
|
|
74
|
+
neutral: "Neutral"
|
|
75
|
+
};
|
|
76
|
+
|
|
69
77
|
// src/shared/limits.ts
|
|
70
78
|
var limits = {
|
|
71
79
|
markdownBytes: 1024 ** 2,
|
|
@@ -74,6 +82,8 @@ var limits = {
|
|
|
74
82
|
datasets: 64,
|
|
75
83
|
rows: 1e5,
|
|
76
84
|
chartRows: 1e4,
|
|
85
|
+
donutSlices: 8,
|
|
86
|
+
funnelStages: 20,
|
|
77
87
|
tableRows: 1000,
|
|
78
88
|
tableColumns: 40
|
|
79
89
|
};
|
|
@@ -207,13 +217,15 @@ class DataResolver {
|
|
|
207
217
|
for (const block of blocks) {
|
|
208
218
|
if (block.type !== "component")
|
|
209
219
|
continue;
|
|
210
|
-
if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "table") {
|
|
220
|
+
if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "donut" || block.name === "funnel" || block.name === "table") {
|
|
211
221
|
const rows = await this.load(block.props.data, block);
|
|
212
222
|
if (rows) {
|
|
213
223
|
if (!rows.length)
|
|
214
224
|
this.diagnostics.add("W_DATA_EMPTY", `${block.props.data} is empty; a no-data state will be shown.`, block.position, "Field validation requires at least one record.", "warning");
|
|
215
225
|
if (block.name === "table")
|
|
216
226
|
this.resolveTable(block, rows);
|
|
227
|
+
else if (block.name === "donut" || block.name === "funnel")
|
|
228
|
+
this.resolveParts(block, rows);
|
|
217
229
|
else
|
|
218
230
|
this.resolveChart(block, rows);
|
|
219
231
|
}
|
|
@@ -265,6 +277,71 @@ class DataResolver {
|
|
|
265
277
|
}
|
|
266
278
|
block.rows = rows.map((row) => Object.fromEntries([...new Set([x, y])].map((key) => [key, row[key]])));
|
|
267
279
|
}
|
|
280
|
+
resolveParts(block, rows) {
|
|
281
|
+
const props = block.props;
|
|
282
|
+
const maximum = block.name === "donut" ? limits.donutSlices : limits.funnelStages;
|
|
283
|
+
const fields = block.name === "donut" ? [block.props.category, props.value] : [
|
|
284
|
+
block.props.stage,
|
|
285
|
+
props.value,
|
|
286
|
+
block.props.total,
|
|
287
|
+
block.props.share,
|
|
288
|
+
...block.props.status ? [block.props.status] : []
|
|
289
|
+
];
|
|
290
|
+
const fail = (code, message, hint) => {
|
|
291
|
+
this.diagnostics.add(code, `${props.data}: ${message}`, block.position, hint);
|
|
292
|
+
};
|
|
293
|
+
if (rows.length > maximum) {
|
|
294
|
+
fail("E_DATA_LIMIT", `${block.name} supports at most ${maximum} rows.`, "Prepare a smaller summary; plotleaf does not group or truncate data.");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (fields.some((field) => !this.field(rows, field, block)))
|
|
298
|
+
return;
|
|
299
|
+
const seen = new Set;
|
|
300
|
+
let sum = 0;
|
|
301
|
+
for (const [index, row] of rows.entries()) {
|
|
302
|
+
const name = row[fields[0]];
|
|
303
|
+
const value = row[props.value];
|
|
304
|
+
if (typeof name !== "string" || !name.trim() || name.length > 1000) {
|
|
305
|
+
fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(fields[0])} must be a nonblank string of at most 1000 characters.`, "Supply a short, explicit label for every category or stage.");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (seen.has(name)) {
|
|
309
|
+
fail("E_DATA_ORDER", `Row ${index + 1}: category/stage labels must be unique.`, "Prepare distinct labels; source order is preserved.");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
seen.add(name);
|
|
313
|
+
if (typeof value !== "number" || value < 0) {
|
|
314
|
+
fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(props.value)} must be a nonnegative number.`, "Use finite JSON numbers, not nulls or numeric strings.");
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (block.name === "donut") {
|
|
318
|
+
sum += value;
|
|
319
|
+
if (!Number.isFinite(sum)) {
|
|
320
|
+
fail("E_DATA_VALUE", "The donut total exceeds the finite numeric range.", "Prepare values in smaller units before rendering.");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
const total = row[block.props.total];
|
|
325
|
+
const share = row[block.props.share];
|
|
326
|
+
if (typeof total !== "number" || total <= 0 || value > total || typeof share !== "number" || share < 0 || share > 1) {
|
|
327
|
+
fail("E_DATA_VALUE", `Row ${index + 1}: total must be positive, value must not exceed total, and share must be a number from 0 to 1.`, "Supply each stage's denominator and prepared ratio explicitly; use an empty array when no baseline exists.");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (Math.abs(share - value / total) > 0.0005 + Number.EPSILON) {
|
|
331
|
+
fail("E_DATA_VALUE", `Row ${index + 1}: share does not match value / total.`, "Prepare a ratio consistent with the displayed values; rounding to 0.1 percentage point is supported.");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (block.props.status) {
|
|
335
|
+
const status = row[block.props.status];
|
|
336
|
+
if (typeof status !== "string" || !Object.hasOwn(funnelStatuses, status)) {
|
|
337
|
+
fail("E_DATA_VALUE", `Row ${index + 1}: status must be ${Object.keys(funnelStatuses).join(", ")}.`, "Supply business status explicitly, or omit the status property for neutral styling.");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
block.rows = rows.map((row) => Object.fromEntries(fields.map((field) => [field, row[field]])));
|
|
344
|
+
}
|
|
268
345
|
resolveTable(block, rows) {
|
|
269
346
|
if (rows.length > limits.tableRows) {
|
|
270
347
|
this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableRows} rows.`, block.position, "Provide a prepared summary table; rows are never silently truncated.");
|
|
@@ -330,7 +407,16 @@ var line = z.strictObject({
|
|
|
330
407
|
var registry = {
|
|
331
408
|
grid: {
|
|
332
409
|
forms: ["container"],
|
|
333
|
-
children: [
|
|
410
|
+
children: [
|
|
411
|
+
"metric",
|
|
412
|
+
"line",
|
|
413
|
+
"area",
|
|
414
|
+
"bar",
|
|
415
|
+
"donut",
|
|
416
|
+
"funnel",
|
|
417
|
+
"table",
|
|
418
|
+
"callout"
|
|
419
|
+
],
|
|
334
420
|
description: "A responsive row of one to four components.",
|
|
335
421
|
schema: z.strictObject({
|
|
336
422
|
columns: z.number().int().min(1).max(4).default(2)
|
|
@@ -374,6 +460,48 @@ var registry = {
|
|
|
374
460
|
schema: z.strictObject(chart),
|
|
375
461
|
example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
|
|
376
462
|
},
|
|
463
|
+
donut: {
|
|
464
|
+
forms: ["leaf"],
|
|
465
|
+
children: [],
|
|
466
|
+
description: "A part-to-whole donut with a readable legend and native data table.",
|
|
467
|
+
schema: z.strictObject({
|
|
468
|
+
data: text,
|
|
469
|
+
category: text,
|
|
470
|
+
value: text,
|
|
471
|
+
title: text,
|
|
472
|
+
format: format.default("number")
|
|
473
|
+
}),
|
|
474
|
+
dataRules: {
|
|
475
|
+
category: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
|
|
476
|
+
value: "Finite nonnegative numbers with a finite sum. Nulls and numeric strings are invalid. All-zero values show an empty state.",
|
|
477
|
+
maximumRows: limits.donutSlices
|
|
478
|
+
},
|
|
479
|
+
example: '::donut{data="./plans.json" category="plan" value="teams" title="Teams by plan"}'
|
|
480
|
+
},
|
|
481
|
+
funnel: {
|
|
482
|
+
forms: ["leaf"],
|
|
483
|
+
children: [],
|
|
484
|
+
description: "Ordered stage/progress rows with prepared values, denominators, shares and optional explicit status. Works without JavaScript.",
|
|
485
|
+
schema: z.strictObject({
|
|
486
|
+
data: text,
|
|
487
|
+
stage: text,
|
|
488
|
+
value: text,
|
|
489
|
+
total: text,
|
|
490
|
+
share: text,
|
|
491
|
+
status: text.optional(),
|
|
492
|
+
title: text,
|
|
493
|
+
format: format.default("number")
|
|
494
|
+
}),
|
|
495
|
+
dataRules: {
|
|
496
|
+
stage: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
|
|
497
|
+
value: "Finite nonnegative number no greater than its row's total.",
|
|
498
|
+
total: "Finite positive denominator supplied per row. No denominator is inferred from adjacent stages.",
|
|
499
|
+
share: "Prepared ratio from 0 to 1, matching value / total to within 0.0005 (rounding to 0.1 percentage point).",
|
|
500
|
+
status: Object.keys(funnelStatuses),
|
|
501
|
+
maximumRows: limits.funnelStages
|
|
502
|
+
},
|
|
503
|
+
example: '::funnel{data="./funnel.json" stage="stage" value="teams" total="baseline" share="share" status="status" title="Funnel by stage"}'
|
|
504
|
+
},
|
|
377
505
|
table: {
|
|
378
506
|
forms: ["leaf", "container"],
|
|
379
507
|
children: ["column"],
|
|
@@ -432,7 +560,8 @@ function componentSpec() {
|
|
|
432
560
|
forms: entry.forms,
|
|
433
561
|
children: entry.children,
|
|
434
562
|
props: z.toJSONSchema(entry.schema),
|
|
435
|
-
example: entry.example
|
|
563
|
+
example: entry.example,
|
|
564
|
+
..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
|
|
436
565
|
}
|
|
437
566
|
])),
|
|
438
567
|
rules: [
|
|
@@ -444,6 +573,8 @@ function componentSpec() {
|
|
|
444
573
|
"Dataset fields are literal own-property keys, not expressions.",
|
|
445
574
|
"Charts preserve source order; date/number x values must increase.",
|
|
446
575
|
"Line/area null y values create gaps; bar null y values are invalid.",
|
|
576
|
+
"Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
|
|
577
|
+
"Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
|
|
447
578
|
"Filters, maps, images, nested data and executable components are not supported in this release."
|
|
448
579
|
]
|
|
449
580
|
};
|
|
@@ -467,6 +598,11 @@ function decodeProps(name, attributes) {
|
|
|
467
598
|
};
|
|
468
599
|
const props = result.data;
|
|
469
600
|
const errors = [];
|
|
601
|
+
if (name === "donut" || name === "funnel") {
|
|
602
|
+
const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
|
|
603
|
+
if (new Set(fields).size !== fields.length)
|
|
604
|
+
errors.push(`${name} field properties must reference different dataset fields.`);
|
|
605
|
+
}
|
|
470
606
|
if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
|
|
471
607
|
errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
|
|
472
608
|
if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
|
|
@@ -732,7 +868,8 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
732
868
|
"figure",
|
|
733
869
|
"figcaption",
|
|
734
870
|
"button",
|
|
735
|
-
"caption"
|
|
871
|
+
"caption",
|
|
872
|
+
"meter"
|
|
736
873
|
],
|
|
737
874
|
attributes: {
|
|
738
875
|
...defaultSchema.attributes,
|
|
@@ -751,10 +888,16 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
751
888
|
"dataRaw",
|
|
752
889
|
"dataColumns",
|
|
753
890
|
"dataSentiment",
|
|
754
|
-
"dataTone"
|
|
891
|
+
"dataTone",
|
|
892
|
+
"dataStatus",
|
|
893
|
+
"dataSlice"
|
|
755
894
|
],
|
|
756
895
|
button: ["type", "disabled", "ariaLabel", "dataColumn"],
|
|
757
896
|
section: ["className", "ariaLabel"],
|
|
897
|
+
meter: ["min", "max", "value", "className", "ariaLabel"],
|
|
898
|
+
ul: ["className", "role"],
|
|
899
|
+
ol: ["start", "className", "role"],
|
|
900
|
+
li: ["className", "dataStatus"],
|
|
758
901
|
th: ["scope", "ariaSort", "className"],
|
|
759
902
|
td: ["className", "dataRaw"]
|
|
760
903
|
}
|
|
@@ -812,6 +955,15 @@ function table(rows, columns, title, id, sortable = true) {
|
|
|
812
955
|
])
|
|
813
956
|
]);
|
|
814
957
|
}
|
|
958
|
+
function dataDisclosure(rows, columns, title, id) {
|
|
959
|
+
return h("details", { className: ["chart-data"] }, [
|
|
960
|
+
h("summary", {}, [
|
|
961
|
+
t("View data"),
|
|
962
|
+
h("span", { className: ["visually-hidden"] }, [t(` for ${title}`)])
|
|
963
|
+
]),
|
|
964
|
+
table(rows, columns, title, `${id}-data`, false)
|
|
965
|
+
]);
|
|
966
|
+
}
|
|
815
967
|
function renderContent(report) {
|
|
816
968
|
let sequence = 0;
|
|
817
969
|
const payload = { charts: [] };
|
|
@@ -913,6 +1065,10 @@ function renderContent(report) {
|
|
|
913
1065
|
];
|
|
914
1066
|
case "column":
|
|
915
1067
|
return [];
|
|
1068
|
+
case "funnel":
|
|
1069
|
+
return renderFunnel(block, id);
|
|
1070
|
+
case "donut":
|
|
1071
|
+
return renderDonut(block, id);
|
|
916
1072
|
case "line":
|
|
917
1073
|
case "area":
|
|
918
1074
|
case "bar":
|
|
@@ -920,6 +1076,156 @@ function renderContent(report) {
|
|
|
920
1076
|
}
|
|
921
1077
|
});
|
|
922
1078
|
}
|
|
1079
|
+
function renderFunnel(block, id) {
|
|
1080
|
+
const { stage, value, total, share, status, title, format } = block.props;
|
|
1081
|
+
const rows = block.rows ?? [];
|
|
1082
|
+
const columns = [
|
|
1083
|
+
{ field: stage, label: label(stage), sign: "auto", semantics: "neutral" },
|
|
1084
|
+
{
|
|
1085
|
+
field: value,
|
|
1086
|
+
label: label(value),
|
|
1087
|
+
format,
|
|
1088
|
+
sign: "auto",
|
|
1089
|
+
semantics: "neutral"
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
field: total,
|
|
1093
|
+
label: label(total),
|
|
1094
|
+
format,
|
|
1095
|
+
sign: "auto",
|
|
1096
|
+
semantics: "neutral"
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
field: share,
|
|
1100
|
+
label: "Share",
|
|
1101
|
+
format: "percent",
|
|
1102
|
+
sign: "auto",
|
|
1103
|
+
semantics: "neutral"
|
|
1104
|
+
},
|
|
1105
|
+
...status ? [
|
|
1106
|
+
{
|
|
1107
|
+
field: status,
|
|
1108
|
+
label: "Status",
|
|
1109
|
+
sign: "auto",
|
|
1110
|
+
semantics: "neutral"
|
|
1111
|
+
}
|
|
1112
|
+
] : []
|
|
1113
|
+
];
|
|
1114
|
+
return [
|
|
1115
|
+
h("figure", { className: ["chart", "funnel"] }, [
|
|
1116
|
+
h("figcaption", { id: `${id}-title` }, [t(title)]),
|
|
1117
|
+
...rows.length ? [
|
|
1118
|
+
h("ol", { className: ["funnel-stages"], role: "list" }, rows.map((row) => {
|
|
1119
|
+
const state = status ? row[status] : "neutral";
|
|
1120
|
+
const rate = formatValue(row[share], "percent");
|
|
1121
|
+
const amount = formatValue(row[value], format, "auto", true);
|
|
1122
|
+
const denominator = formatValue(row[total], format, "auto", true);
|
|
1123
|
+
return h("li", { className: ["funnel-stage"], dataStatus: state }, [
|
|
1124
|
+
h("div", { className: ["funnel-heading"] }, [
|
|
1125
|
+
h("span", { className: ["funnel-label"] }, [
|
|
1126
|
+
t(row[stage])
|
|
1127
|
+
]),
|
|
1128
|
+
h("span", { className: ["funnel-values"] }, [
|
|
1129
|
+
h("strong", {}, [t(rate)]),
|
|
1130
|
+
h("span", {}, [t(`${amount} / ${denominator}`)])
|
|
1131
|
+
])
|
|
1132
|
+
]),
|
|
1133
|
+
h("meter", {
|
|
1134
|
+
min: "0",
|
|
1135
|
+
max: "1",
|
|
1136
|
+
value: String(row[share]),
|
|
1137
|
+
ariaLabel: `${row[stage]}: ${rate}, ${formatValue(row[value], format)} of ${formatValue(row[total], format)}${status ? `, ${funnelStatuses[state]}` : ""}`
|
|
1138
|
+
}, [t(rate)]),
|
|
1139
|
+
...status ? [
|
|
1140
|
+
h("span", { className: ["funnel-status"] }, [
|
|
1141
|
+
t(funnelStatuses[state])
|
|
1142
|
+
])
|
|
1143
|
+
] : []
|
|
1144
|
+
]);
|
|
1145
|
+
}))
|
|
1146
|
+
] : [
|
|
1147
|
+
h("p", { className: ["empty-state"] }, [
|
|
1148
|
+
t("No data to display.")
|
|
1149
|
+
])
|
|
1150
|
+
],
|
|
1151
|
+
...rows.length ? [
|
|
1152
|
+
dataDisclosure(rows.map((row) => status ? {
|
|
1153
|
+
...row,
|
|
1154
|
+
[status]: funnelStatuses[row[status]]
|
|
1155
|
+
} : row), columns, title, id)
|
|
1156
|
+
] : []
|
|
1157
|
+
])
|
|
1158
|
+
];
|
|
1159
|
+
}
|
|
1160
|
+
function renderDonut(block, id) {
|
|
1161
|
+
const { category, value, title, format } = block.props;
|
|
1162
|
+
const rows = block.rows ?? [];
|
|
1163
|
+
const total = rows.reduce((sum, row) => sum + row[value], 0);
|
|
1164
|
+
if (total > 0)
|
|
1165
|
+
payload.charts.push({
|
|
1166
|
+
id,
|
|
1167
|
+
kind: "donut",
|
|
1168
|
+
title,
|
|
1169
|
+
format,
|
|
1170
|
+
rows: rows.map((row) => [
|
|
1171
|
+
row[category],
|
|
1172
|
+
row[value]
|
|
1173
|
+
])
|
|
1174
|
+
});
|
|
1175
|
+
return [
|
|
1176
|
+
h("figure", { className: ["chart", "donut"] }, [
|
|
1177
|
+
h("figcaption", { id: `${id}-title` }, [t(title)]),
|
|
1178
|
+
...total > 0 ? [
|
|
1179
|
+
h("div", { className: ["donut-layout"] }, [
|
|
1180
|
+
h("div", {
|
|
1181
|
+
className: ["chart-plot", "donut-plot"],
|
|
1182
|
+
id,
|
|
1183
|
+
dataPlotleafChart: "",
|
|
1184
|
+
role: "img",
|
|
1185
|
+
ariaLabel: `${title}. Values and shares follow in the legend and data table.`
|
|
1186
|
+
}),
|
|
1187
|
+
h("ul", { className: ["donut-legend"], role: "list" }, rows.map((row, index) => h("li", {}, [
|
|
1188
|
+
h("span", { className: ["donut-key"] }, [
|
|
1189
|
+
h("span", {
|
|
1190
|
+
className: ["donut-swatch"],
|
|
1191
|
+
dataSlice: index + 1,
|
|
1192
|
+
ariaHidden: "true"
|
|
1193
|
+
}),
|
|
1194
|
+
h("span", {}, [t(row[category])])
|
|
1195
|
+
]),
|
|
1196
|
+
h("span", { className: ["donut-value"] }, [
|
|
1197
|
+
t(formatValue(row[value], format))
|
|
1198
|
+
]),
|
|
1199
|
+
h("span", { className: ["donut-share"] }, [
|
|
1200
|
+
t(formatValue(row[value] / total, "percent"))
|
|
1201
|
+
])
|
|
1202
|
+
])))
|
|
1203
|
+
])
|
|
1204
|
+
] : [
|
|
1205
|
+
h("p", { className: ["empty-state"] }, [
|
|
1206
|
+
t(rows.length ? "No non-zero values to display." : "No data to display.")
|
|
1207
|
+
])
|
|
1208
|
+
],
|
|
1209
|
+
...rows.length ? [
|
|
1210
|
+
dataDisclosure(rows, [
|
|
1211
|
+
{
|
|
1212
|
+
field: category,
|
|
1213
|
+
label: label(category),
|
|
1214
|
+
sign: "auto",
|
|
1215
|
+
semantics: "neutral"
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
field: value,
|
|
1219
|
+
label: label(value),
|
|
1220
|
+
format,
|
|
1221
|
+
sign: "auto",
|
|
1222
|
+
semantics: "neutral"
|
|
1223
|
+
}
|
|
1224
|
+
], title, id)
|
|
1225
|
+
] : []
|
|
1226
|
+
])
|
|
1227
|
+
];
|
|
1228
|
+
}
|
|
923
1229
|
function renderChart(block, id) {
|
|
924
1230
|
const { x, y, title, format } = block.props;
|
|
925
1231
|
const xType = block.name === "bar" ? "category" : block.props.xType;
|
|
@@ -964,17 +1270,7 @@ function renderContent(report) {
|
|
|
964
1270
|
t("No data to display.")
|
|
965
1271
|
])
|
|
966
1272
|
],
|
|
967
|
-
...rows.length ? [
|
|
968
|
-
h("details", { className: ["chart-data"] }, [
|
|
969
|
-
h("summary", {}, [
|
|
970
|
-
t("View data"),
|
|
971
|
-
h("span", { className: ["visually-hidden"] }, [
|
|
972
|
-
t(` for ${title}`)
|
|
973
|
-
])
|
|
974
|
-
]),
|
|
975
|
-
table(rows, columns, title, `${id}-data`, false)
|
|
976
|
-
])
|
|
977
|
-
] : []
|
|
1273
|
+
...rows.length ? [dataDisclosure(rows, columns, title, id)] : []
|
|
978
1274
|
])
|
|
979
1275
|
];
|
|
980
1276
|
}
|
|
@@ -1235,10 +1531,18 @@ data: ${revision}
|
|
|
1235
1531
|
// package.json
|
|
1236
1532
|
var package_default = {
|
|
1237
1533
|
name: "plotleaf",
|
|
1238
|
-
version: "0.
|
|
1534
|
+
version: "0.2.0",
|
|
1239
1535
|
description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
|
|
1240
1536
|
type: "module",
|
|
1241
1537
|
license: "MIT",
|
|
1538
|
+
repository: {
|
|
1539
|
+
type: "git",
|
|
1540
|
+
url: "git+https://github.com/LoueeD/plotleaf.git"
|
|
1541
|
+
},
|
|
1542
|
+
homepage: "https://github.com/LoueeD/plotleaf#readme",
|
|
1543
|
+
bugs: {
|
|
1544
|
+
url: "https://github.com/LoueeD/plotleaf/issues"
|
|
1545
|
+
},
|
|
1242
1546
|
bin: {
|
|
1243
1547
|
plotleaf: "./dist/cli.js"
|
|
1244
1548
|
},
|
|
@@ -1257,6 +1561,8 @@ var package_default = {
|
|
|
1257
1561
|
"examples/storefront/*.json",
|
|
1258
1562
|
"examples/adoption/*.md",
|
|
1259
1563
|
"examples/adoption/*.json",
|
|
1564
|
+
"examples/onboarding/*.md",
|
|
1565
|
+
"examples/onboarding/*.json",
|
|
1260
1566
|
"skills/plotleaf/SKILL.md",
|
|
1261
1567
|
"README.md",
|
|
1262
1568
|
"LICENSE",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"stage": "Visited",
|
|
4
|
+
"teams": 41200,
|
|
5
|
+
"baseline": 41200,
|
|
6
|
+
"share": 1.0,
|
|
7
|
+
"status": "on-target"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"stage": "Signed up",
|
|
11
|
+
"teams": 12480,
|
|
12
|
+
"baseline": 41200,
|
|
13
|
+
"share": 0.3029126213592233,
|
|
14
|
+
"status": "on-target"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"stage": "Activated",
|
|
18
|
+
"teams": 7612,
|
|
19
|
+
"baseline": 41200,
|
|
20
|
+
"share": 0.18475728155339805,
|
|
21
|
+
"status": "below-target"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"stage": "Converted",
|
|
25
|
+
"teams": 1284,
|
|
26
|
+
"baseline": 41200,
|
|
27
|
+
"share": 0.03116504854368932,
|
|
28
|
+
"status": "at-risk"
|
|
29
|
+
}
|
|
30
|
+
]
|