@slickfast/mcp 0.3.1 → 0.5.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/dist/index.js +1170 -6
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1270,6 +1270,966 @@ function applyPreset(spec) {
|
|
|
1270
1270
|
if (!p) return spec;
|
|
1271
1271
|
return { ...spec, width: spec.width != null ? spec.width : p[0], height: spec.height != null ? spec.height : p[1] };
|
|
1272
1272
|
}
|
|
1273
|
+
function renderCards(spec) {
|
|
1274
|
+
const cards = Array.isArray(spec.cards) ? spec.cards : [];
|
|
1275
|
+
const n = Math.max(1, cards.length);
|
|
1276
|
+
const title = spec.title || "";
|
|
1277
|
+
const forced = spec.gridColumns != null ? Number(spec.gridColumns) : typeof spec.columns === "number" ? spec.columns : null;
|
|
1278
|
+
const cols = Math.max(1, Math.min(forced || Math.min(n, 4), n));
|
|
1279
|
+
const rows = Math.ceil(n / cols);
|
|
1280
|
+
const PAD = 24, GAP = 18, CARD_W = 300, CARD_H = 148;
|
|
1281
|
+
const titleH = title ? 44 : 0;
|
|
1282
|
+
const W = spec.width || PAD * 2 + cols * CARD_W + (cols - 1) * GAP;
|
|
1283
|
+
const H = spec.height || PAD * 2 + titleH + rows * CARD_H + (rows - 1) * GAP;
|
|
1284
|
+
const bg = spec.background || "#ffffff";
|
|
1285
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1286
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1287
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1288
|
+
const font = resolveFont(spec);
|
|
1289
|
+
const watermark = spec.watermark !== false;
|
|
1290
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(n, 3));
|
|
1291
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1292
|
+
const labelCol = txt(spec, isDark ? "#94a3b8" : "#64748b");
|
|
1293
|
+
const valueCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1294
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1295
|
+
const surface = isDark ? "#1e293b" : "#ffffff";
|
|
1296
|
+
const border = isDark ? "#334155" : "#e2e8f0";
|
|
1297
|
+
const cellW = (W - PAD * 2 - GAP * (cols - 1)) / cols;
|
|
1298
|
+
const cellH = (H - PAD * 2 - titleH - GAP * (rows - 1)) / rows;
|
|
1299
|
+
const p = [];
|
|
1300
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1301
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1302
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1303
|
+
cards.forEach((c, i) => {
|
|
1304
|
+
const col = i % cols, row = Math.floor(i / cols);
|
|
1305
|
+
const x = PAD + col * (cellW + GAP);
|
|
1306
|
+
const y = PAD + titleH + row * (cellH + GAP);
|
|
1307
|
+
const accent = contrastFloor(c.color || palette[i % palette.length], bg, transparent);
|
|
1308
|
+
p.push(`<rect x="${r(x)}" y="${r(y)}" width="${r(cellW)}" height="${r(cellH)}" rx="14" fill="${transparent ? "none" : surface}" stroke="${border}" stroke-width="1"/>`);
|
|
1309
|
+
p.push(`<rect x="${r(x + cellW * 0.05)}" y="${r(y + cellH * 0.18)}" width="5" height="${r(cellH * 0.64)}" rx="2.5" fill="${accent}"/>`);
|
|
1310
|
+
const contentX = r(x + cellW * 0.115);
|
|
1311
|
+
const labelFs = fs + 1;
|
|
1312
|
+
const valFs = Math.max(20, Math.round(cellH * 0.235));
|
|
1313
|
+
const valueStr = (c.valuePrefix || "") + fmt(c.value) + (c.valueUnit ? " " + c.valueUnit : "");
|
|
1314
|
+
const cLabel = c.label || "";
|
|
1315
|
+
if (cLabel) p.push(`<text x="${contentX}" y="${r(y + cellH * 0.27)}" font-size="${labelFs}" ${wAttr(spec, 600)} fill="${labelCol}">${esc(cLabel)}</text>`);
|
|
1316
|
+
p.push(`<text x="${contentX}" y="${r(y + cellH * 0.58)}" font-size="${valFs}" ${wAttr(spec, 800)} fill="${valueCol}">${esc(valueStr)}</text>`);
|
|
1317
|
+
const hasDelta = c.delta !== void 0 && c.delta !== null;
|
|
1318
|
+
if (hasDelta) {
|
|
1319
|
+
const d = Number(c.delta) || 0;
|
|
1320
|
+
const goodDown = c.deltaGoodWhen === "down";
|
|
1321
|
+
const good = goodDown ? d < 0 : d > 0;
|
|
1322
|
+
const bad = goodDown ? d > 0 : d < 0;
|
|
1323
|
+
const arrow = d > 0 ? "\u25B2" : d < 0 ? "\u25BC" : "\u2013";
|
|
1324
|
+
const dText = arrow + " " + fmt(Math.abs(d)) + (c.deltaUnit || "%");
|
|
1325
|
+
const pillBg = good ? isDark ? "#064e3b" : "#ecfdf5" : bad ? isDark ? "#7f1d1d" : "#fef2f2" : isDark ? "#334155" : "#f1f5f9";
|
|
1326
|
+
const pillTx = good ? isDark ? "#6ee7b7" : "#059669" : bad ? isDark ? "#fca5a5" : "#dc2626" : labelCol;
|
|
1327
|
+
const pillFs = 12.5;
|
|
1328
|
+
const pillH = 24;
|
|
1329
|
+
const pillW = Math.round(dText.length * 7.3 + 18);
|
|
1330
|
+
const py = r(y + cellH * 0.7);
|
|
1331
|
+
p.push(`<rect x="${contentX}" y="${py}" width="${pillW}" height="${pillH}" rx="12" fill="${pillBg}"/>`);
|
|
1332
|
+
p.push(`<text x="${r(Number(contentX) + pillW / 2)}" y="${r(Number(py) + 16)}" text-anchor="middle" font-size="${pillFs}" ${wAttr(spec, 700)} fill="${pillTx}">${esc(dText)}</text>`);
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1336
|
+
p.push(`</svg>`);
|
|
1337
|
+
return p.join("\n");
|
|
1338
|
+
}
|
|
1339
|
+
function renderLayers(spec) {
|
|
1340
|
+
const layers = Array.isArray(spec.layers) ? spec.layers : [];
|
|
1341
|
+
const n = Math.max(1, layers.length);
|
|
1342
|
+
const title = spec.title || "";
|
|
1343
|
+
const bg = spec.background || "#ffffff";
|
|
1344
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1345
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1346
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1347
|
+
const font = resolveFont(spec);
|
|
1348
|
+
const watermark = spec.watermark !== false;
|
|
1349
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(n, 3));
|
|
1350
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1351
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1352
|
+
const PAD = 24, GAP = 10, BLOCK_H = 72;
|
|
1353
|
+
const topY = title ? 54 : 24;
|
|
1354
|
+
const W = spec.width || 600;
|
|
1355
|
+
const H = spec.height || topY + n * BLOCK_H + (n - 1) * GAP + 16;
|
|
1356
|
+
const blockW = W - PAD * 2;
|
|
1357
|
+
const blockH = (H - topY - 16 - GAP * (n - 1)) / n;
|
|
1358
|
+
const p = [];
|
|
1359
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1360
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1361
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1362
|
+
layers.forEach((layer, i) => {
|
|
1363
|
+
const y = topY + i * (blockH + GAP);
|
|
1364
|
+
const fill = layer.color || palette[i % palette.length];
|
|
1365
|
+
const tc = spec.textColor || contrastColor(fill);
|
|
1366
|
+
const cx = r(W / 2);
|
|
1367
|
+
const lTitle = layer.title || "";
|
|
1368
|
+
const lSub = layer.subtitle || "";
|
|
1369
|
+
p.push(`<rect x="${PAD}" y="${r(y)}" width="${r(blockW)}" height="${r(blockH)}" rx="12" fill="${fill}"/>`);
|
|
1370
|
+
if (lSub) {
|
|
1371
|
+
p.push(`<text x="${cx}" y="${r(y + blockH / 2 - 2)}" text-anchor="middle" font-size="${fs + 4}" ${wAttr(spec, 700)} fill="${tc}">${esc(lTitle)}</text>`);
|
|
1372
|
+
p.push(`<text x="${cx}" y="${r(y + blockH / 2 + 16)}" text-anchor="middle" font-size="${fs}"${wOpt(spec)} fill="${tc}" opacity="0.85">${esc(lSub)}</text>`);
|
|
1373
|
+
} else {
|
|
1374
|
+
p.push(`<text x="${cx}" y="${r(y + blockH / 2 + fs / 2)}" text-anchor="middle" font-size="${fs + 4}" ${wAttr(spec, 700)} fill="${tc}">${esc(lTitle)}</text>`);
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1378
|
+
p.push(`</svg>`);
|
|
1379
|
+
return p.join("\n");
|
|
1380
|
+
}
|
|
1381
|
+
function renderProgress(spec) {
|
|
1382
|
+
const bars = Array.isArray(spec.bars) ? spec.bars : [];
|
|
1383
|
+
const n = Math.max(1, bars.length);
|
|
1384
|
+
const title = spec.title || "";
|
|
1385
|
+
const bg = spec.background || "#ffffff";
|
|
1386
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1387
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1388
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1389
|
+
const font = resolveFont(spec);
|
|
1390
|
+
const watermark = spec.watermark !== false;
|
|
1391
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(n, 3));
|
|
1392
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1393
|
+
const labelCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1394
|
+
const valCol = txt(spec, isDark ? "#e2e8f0" : "#334155");
|
|
1395
|
+
const trackCol = isDark ? "#334155" : "#e2e8f0";
|
|
1396
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1397
|
+
const PAD = 24, ROW_H = 46;
|
|
1398
|
+
const topY = title ? 54 : 24;
|
|
1399
|
+
const W = spec.width || 600;
|
|
1400
|
+
const H = spec.height || topY + n * ROW_H + 8;
|
|
1401
|
+
const trackW = W - PAD * 2;
|
|
1402
|
+
const p = [];
|
|
1403
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1404
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1405
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1406
|
+
bars.forEach((b, i) => {
|
|
1407
|
+
const ry = topY + i * ROW_H;
|
|
1408
|
+
const val = Number(b.value) || 0;
|
|
1409
|
+
const hasTarget = b.target !== void 0 && b.target !== null;
|
|
1410
|
+
const target = Number(b.target) || 0;
|
|
1411
|
+
const unit = b.valueUnit || "";
|
|
1412
|
+
const ratio = hasTarget ? target > 0 ? val / target : 0 : val / 100;
|
|
1413
|
+
const cl = Math.max(0, Math.min(1, ratio));
|
|
1414
|
+
const valStr = hasTarget ? `${fmt(val)} / ${fmt(target)}${unit ? " " + unit : ""}` : `${fmt(val)}${unit || "%"}`;
|
|
1415
|
+
const fill = b.color || palette[i % palette.length];
|
|
1416
|
+
const labelY = ry + 13, barY = ry + 22, barH = 14;
|
|
1417
|
+
p.push(`<text x="${PAD}" y="${r(labelY)}" font-size="${fs}" ${wAttr(spec, 600)} fill="${labelCol}">${esc(b.label || "")}</text>`);
|
|
1418
|
+
p.push(`<text x="${W - PAD}" y="${r(labelY)}" text-anchor="end" font-size="${fs}"${wOpt(spec)} fill="${valCol}">${esc(valStr)}</text>`);
|
|
1419
|
+
p.push(`<rect x="${PAD}" y="${r(barY)}" width="${r(trackW)}" height="${barH}" rx="7" fill="${trackCol}"/>`);
|
|
1420
|
+
if (cl > 0) p.push(`<rect x="${PAD}" y="${r(barY)}" width="${r(Math.max(barH, cl * trackW))}" height="${barH}" rx="7" fill="${fill}"/>`);
|
|
1421
|
+
});
|
|
1422
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1423
|
+
p.push(`</svg>`);
|
|
1424
|
+
return p.join("\n");
|
|
1425
|
+
}
|
|
1426
|
+
function renderWaffle(spec) {
|
|
1427
|
+
const parts = Array.isArray(spec.parts) ? spec.parts : [];
|
|
1428
|
+
const title = spec.title || "";
|
|
1429
|
+
const bg = spec.background || "#ffffff";
|
|
1430
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1431
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1432
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1433
|
+
const font = resolveFont(spec);
|
|
1434
|
+
const watermark = spec.watermark !== false;
|
|
1435
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(parts.length, 3));
|
|
1436
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1437
|
+
const legendCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1438
|
+
const trackCol = isDark ? "#334155" : "#e2e8f0";
|
|
1439
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1440
|
+
const vals = parts.map((pt) => Math.max(0, Number(pt.value) || 0));
|
|
1441
|
+
const sum = vals.reduce((a, b) => a + b, 0);
|
|
1442
|
+
const scale = sum > 100 ? 100 / sum : 1;
|
|
1443
|
+
const exact = vals.map((v) => v * scale);
|
|
1444
|
+
const cells = exact.map(Math.floor);
|
|
1445
|
+
const targetFilled = Math.min(100, Math.round(sum));
|
|
1446
|
+
const rem = targetFilled - cells.reduce((a, b) => a + b, 0);
|
|
1447
|
+
const order = exact.map((v, i) => [i, v - Math.floor(v)]).sort((a, b) => b[1] - a[1]);
|
|
1448
|
+
for (let k = 0; k < rem && order.length; k++) cells[order[k % order.length][0]]++;
|
|
1449
|
+
const colors = [];
|
|
1450
|
+
parts.forEach((pt, i) => {
|
|
1451
|
+
const c = pt.color || palette[i % palette.length];
|
|
1452
|
+
for (let k = 0; k < cells[i]; k++) colors.push(c);
|
|
1453
|
+
});
|
|
1454
|
+
while (colors.length < 100) colors.push(trackCol);
|
|
1455
|
+
colors.length = 100;
|
|
1456
|
+
const PAD = 24, CELL = 26, GAP = 4;
|
|
1457
|
+
const topY = title ? 54 : 24;
|
|
1458
|
+
const gridSize = 10 * CELL + 9 * GAP;
|
|
1459
|
+
const gridX = PAD, gridY = topY;
|
|
1460
|
+
const legendX = gridX + gridSize + 28;
|
|
1461
|
+
const W = spec.width || 600;
|
|
1462
|
+
const H = spec.height || topY + gridSize + 16;
|
|
1463
|
+
const p = [];
|
|
1464
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1465
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1466
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1467
|
+
for (let k = 0; k < 100; k++) {
|
|
1468
|
+
const row = Math.floor(k / 10), col = k % 10;
|
|
1469
|
+
const x = gridX + col * (CELL + GAP), y = gridY + row * (CELL + GAP);
|
|
1470
|
+
p.push(`<rect x="${r(x)}" y="${r(y)}" width="${CELL}" height="${CELL}" rx="4" fill="${colors[k]}"/>`);
|
|
1471
|
+
}
|
|
1472
|
+
parts.forEach((pt, i) => {
|
|
1473
|
+
const ly = topY + 6 + i * 24;
|
|
1474
|
+
const c = pt.color || palette[i % palette.length];
|
|
1475
|
+
p.push(`<rect x="${legendX}" y="${r(ly)}" width="13" height="13" rx="3" fill="${c}"/>`);
|
|
1476
|
+
p.push(`<text x="${legendX + 20}" y="${r(ly + 11)}" font-size="${fs}"${wOpt(spec)} fill="${legendCol}">${esc((pt.label || "") + " " + fmt(vals[i]))}</text>`);
|
|
1477
|
+
});
|
|
1478
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1479
|
+
p.push(`</svg>`);
|
|
1480
|
+
return p.join("\n");
|
|
1481
|
+
}
|
|
1482
|
+
function renderHeatmap(spec) {
|
|
1483
|
+
const rows = Array.isArray(spec.rows) ? spec.rows : [];
|
|
1484
|
+
const cols = Array.isArray(spec.columns) ? spec.columns : [];
|
|
1485
|
+
const values = Array.isArray(spec.values) ? spec.values : [];
|
|
1486
|
+
const nr = Math.max(1, rows.length), nc = Math.max(1, cols.length);
|
|
1487
|
+
const title = spec.title || "";
|
|
1488
|
+
const bg = spec.background || "#ffffff";
|
|
1489
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1490
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1491
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1492
|
+
const font = resolveFont(spec);
|
|
1493
|
+
const watermark = spec.watermark !== false;
|
|
1494
|
+
const showValues = spec.showValues !== false;
|
|
1495
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", 3);
|
|
1496
|
+
const baseHsl = hexToHsl(spec.color || palette[0]);
|
|
1497
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1498
|
+
const labelCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1499
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1500
|
+
let min = Infinity, max = -Infinity;
|
|
1501
|
+
values.forEach((rv) => (rv || []).forEach((v) => {
|
|
1502
|
+
const x = Number(v) || 0;
|
|
1503
|
+
if (x < min) min = x;
|
|
1504
|
+
if (x > max) max = x;
|
|
1505
|
+
}));
|
|
1506
|
+
if (!isFinite(min)) {
|
|
1507
|
+
min = 0;
|
|
1508
|
+
max = 1;
|
|
1509
|
+
}
|
|
1510
|
+
const span = max - min || 1;
|
|
1511
|
+
const cellColor = (v) => hslToHex(baseHsl.h, Math.max(35, baseHsl.s), Math.max(0, Math.min(100, 92 - (v - min) / span * 52)));
|
|
1512
|
+
const PAD = 24, LEFT = 84, COLH = 24, CELL_H = 44;
|
|
1513
|
+
const topY = title ? 54 : 24;
|
|
1514
|
+
const W = spec.width || 640;
|
|
1515
|
+
const H = spec.height || topY + COLH + nr * CELL_H + 16;
|
|
1516
|
+
const plotLeft = PAD + LEFT;
|
|
1517
|
+
const plotW = W - PAD - plotLeft;
|
|
1518
|
+
const cellW = plotW / nc;
|
|
1519
|
+
const gridTop = topY + COLH;
|
|
1520
|
+
const p = [];
|
|
1521
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1522
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1523
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1524
|
+
cols.forEach((c, ci) => p.push(`<text x="${r(plotLeft + ci * cellW + cellW / 2)}" y="${r(gridTop - 8)}" text-anchor="middle" font-size="${fs - 1}"${wOpt(spec)} fill="${labelCol}">${esc(c)}</text>`));
|
|
1525
|
+
rows.forEach((rw, ri) => {
|
|
1526
|
+
const y = gridTop + ri * CELL_H;
|
|
1527
|
+
p.push(`<text x="${plotLeft - 10}" y="${r(y + CELL_H / 2 + 4)}" text-anchor="end" font-size="${fs - 1}"${wOpt(spec)} fill="${labelCol}">${esc(rw)}</text>`);
|
|
1528
|
+
cols.forEach((c, ci) => {
|
|
1529
|
+
const v = Number((values[ri] || [])[ci]) || 0;
|
|
1530
|
+
const col = cellColor(v);
|
|
1531
|
+
const x = plotLeft + ci * cellW;
|
|
1532
|
+
p.push(`<rect x="${r(x + 1)}" y="${r(y + 1)}" width="${r(cellW - 2)}" height="${CELL_H - 2}" rx="4" fill="${col}"/>`);
|
|
1533
|
+
if (showValues) p.push(`<text x="${r(x + cellW / 2)}" y="${r(y + CELL_H / 2 + 4)}" text-anchor="middle" font-size="${fs - 1}"${wOpt(spec)} fill="${spec.textColor || contrastColor(col)}">${esc(fmt(v))}</text>`);
|
|
1534
|
+
});
|
|
1535
|
+
});
|
|
1536
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1537
|
+
p.push(`</svg>`);
|
|
1538
|
+
return p.join("\n");
|
|
1539
|
+
}
|
|
1540
|
+
function trapezoidPath(cx, yTop, topW, yBot, botW) {
|
|
1541
|
+
return `M${r(cx - topW / 2)},${r(yTop)} L${r(cx + topW / 2)},${r(yTop)} L${r(cx + botW / 2)},${r(yBot)} L${r(cx - botW / 2)},${r(yBot)} Z`;
|
|
1542
|
+
}
|
|
1543
|
+
function renderFunnel(spec) {
|
|
1544
|
+
const stages = Array.isArray(spec.stages) ? spec.stages : [];
|
|
1545
|
+
const n = Math.max(1, stages.length);
|
|
1546
|
+
const title = spec.title || "";
|
|
1547
|
+
const bg = spec.background || "#ffffff";
|
|
1548
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1549
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1550
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1551
|
+
const font = resolveFont(spec);
|
|
1552
|
+
const watermark = spec.watermark !== false;
|
|
1553
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(n, 3));
|
|
1554
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1555
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1556
|
+
const u = spec.valueUnit ? " " + spec.valueUnit : "";
|
|
1557
|
+
const PAD = 24, GAP = 6, BAND_H = 64;
|
|
1558
|
+
const topY = title ? 54 : 24;
|
|
1559
|
+
const W = spec.width || 600;
|
|
1560
|
+
const H = spec.height || topY + n * BAND_H + (n - 1) * GAP + 16;
|
|
1561
|
+
const plotW = W - PAD * 2;
|
|
1562
|
+
const cx = W / 2;
|
|
1563
|
+
const vals = stages.map((s) => Math.max(0, Number(s.value) || 0));
|
|
1564
|
+
const maxV = Math.max(1, ...vals);
|
|
1565
|
+
const top = vals[0] || 0;
|
|
1566
|
+
const p = [];
|
|
1567
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1568
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1569
|
+
if (title) p.push(`<text x="${r(cx)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1570
|
+
stages.forEach((s, i) => {
|
|
1571
|
+
const yTop = topY + i * (BAND_H + GAP), yBot = yTop + BAND_H;
|
|
1572
|
+
const wTop = vals[i] / maxV * plotW;
|
|
1573
|
+
const wBot = (i < n - 1 ? vals[i + 1] : vals[i]) / maxV * plotW;
|
|
1574
|
+
const fill = s.color || palette[i % palette.length];
|
|
1575
|
+
const tc = spec.textColor || contrastColor(fill);
|
|
1576
|
+
const my = (yTop + yBot) / 2;
|
|
1577
|
+
const pct = top > 0 ? Math.round(vals[i] / top * 100) : 0;
|
|
1578
|
+
p.push(`<path d="${trapezoidPath(cx, yTop, wTop, yBot, wBot)}" fill="${fill}"/>`);
|
|
1579
|
+
p.push(`<text x="${r(cx)}" y="${r(my - 2)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${tc}">${esc(s.label || "")}</text>`);
|
|
1580
|
+
p.push(`<text x="${r(cx)}" y="${r(my + 15)}" text-anchor="middle" font-size="${fs - 1}"${wOpt(spec)} fill="${tc}">${esc(fmt(vals[i]) + u + " \xB7 " + pct + "%")}</text>`);
|
|
1581
|
+
});
|
|
1582
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1583
|
+
p.push(`</svg>`);
|
|
1584
|
+
return p.join("\n");
|
|
1585
|
+
}
|
|
1586
|
+
function renderPyramid(spec) {
|
|
1587
|
+
const levels = Array.isArray(spec.levels) ? spec.levels : [];
|
|
1588
|
+
const n = Math.max(1, levels.length);
|
|
1589
|
+
const title = spec.title || "";
|
|
1590
|
+
const bg = spec.background || "#ffffff";
|
|
1591
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1592
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1593
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1594
|
+
const font = resolveFont(spec);
|
|
1595
|
+
const watermark = spec.watermark !== false;
|
|
1596
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(n, 3));
|
|
1597
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1598
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1599
|
+
const PAD = 24;
|
|
1600
|
+
const topY = title ? 54 : 24;
|
|
1601
|
+
const W = spec.width || 600;
|
|
1602
|
+
const totalH = spec.height ? spec.height - topY - 16 : 300;
|
|
1603
|
+
const H = spec.height || topY + totalH + 16;
|
|
1604
|
+
const baseW = W - PAD * 2;
|
|
1605
|
+
const apexY = topY, cx = W / 2, bandH = totalH / n;
|
|
1606
|
+
const widthAt = (y) => (y - apexY) / totalH * baseW;
|
|
1607
|
+
const p = [];
|
|
1608
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1609
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1610
|
+
if (title) p.push(`<text x="${r(cx)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1611
|
+
levels.forEach((lv, i) => {
|
|
1612
|
+
const yTop = apexY + i * bandH, yBot = apexY + (i + 1) * bandH;
|
|
1613
|
+
const fill = lv.color || palette[i % palette.length];
|
|
1614
|
+
const tc = spec.textColor || contrastColor(fill);
|
|
1615
|
+
const hasVal = lv.value !== void 0 && lv.value !== null;
|
|
1616
|
+
const lbl = (lv.title || lv.label || "") + (hasVal ? " \xB7 " + fmt(Number(lv.value) || 0) : "");
|
|
1617
|
+
p.push(`<path d="${trapezoidPath(cx, yTop, widthAt(yTop), yBot, widthAt(yBot))}" fill="${fill}"/>`);
|
|
1618
|
+
p.push(`<text x="${r(cx)}" y="${r((yTop + yBot) / 2 + 4)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${tc}">${esc(lbl)}</text>`);
|
|
1619
|
+
});
|
|
1620
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1621
|
+
p.push(`</svg>`);
|
|
1622
|
+
return p.join("\n");
|
|
1623
|
+
}
|
|
1624
|
+
function renderQuadrant(spec) {
|
|
1625
|
+
const items = Array.isArray(spec.items) ? spec.items : [];
|
|
1626
|
+
const title = spec.title || "";
|
|
1627
|
+
const bg = spec.background || "#ffffff";
|
|
1628
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1629
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1630
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1631
|
+
const font = resolveFont(spec);
|
|
1632
|
+
const watermark = spec.watermark !== false;
|
|
1633
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(items.length, 3));
|
|
1634
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1635
|
+
const labelCol = txt(spec, isDark ? "#e2e8f0" : "#334155");
|
|
1636
|
+
const axisCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1637
|
+
const gridCol = isDark ? "#334155" : "#e2e8f0";
|
|
1638
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1639
|
+
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0));
|
|
1640
|
+
const PAD = 24;
|
|
1641
|
+
const topY = title ? 54 : 30;
|
|
1642
|
+
const W = spec.width || 560, H = spec.height || 540;
|
|
1643
|
+
const plotTop = topY + 18;
|
|
1644
|
+
const S = Math.min(W - PAD * 2, H - plotTop - 46);
|
|
1645
|
+
const plotX = (W - S) / 2, plotBottom = plotTop + S;
|
|
1646
|
+
const p = [];
|
|
1647
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1648
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1649
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1650
|
+
p.push(`<rect x="${r(plotX)}" y="${r(plotTop)}" width="${r(S)}" height="${r(S)}" rx="8" fill="none" stroke="${gridCol}" stroke-width="1"/>`);
|
|
1651
|
+
p.push(`<line x1="${r(plotX + S / 2)}" y1="${r(plotTop)}" x2="${r(plotX + S / 2)}" y2="${r(plotBottom)}" stroke="${gridCol}" stroke-width="1"/>`);
|
|
1652
|
+
p.push(`<line x1="${r(plotX)}" y1="${r(plotTop + S / 2)}" x2="${r(plotX + S)}" y2="${r(plotTop + S / 2)}" stroke="${gridCol}" stroke-width="1"/>`);
|
|
1653
|
+
if (spec.yAxis) p.push(`<text x="${r(plotX)}" y="${r(plotTop - 8)}" font-size="${fs}" ${wAttr(spec, 600)} fill="${axisCol}">${esc(spec.yAxis)} \u2192</text>`);
|
|
1654
|
+
if (spec.xAxis) p.push(`<text x="${r(W / 2)}" y="${r(plotBottom + 28)}" text-anchor="middle" font-size="${fs}" ${wAttr(spec, 600)} fill="${axisCol}">${esc(spec.xAxis)} \u2192</text>`);
|
|
1655
|
+
items.forEach((it, i) => {
|
|
1656
|
+
const x = plotX + clamp01(it.x) * S;
|
|
1657
|
+
const y = plotTop + (1 - clamp01(it.y)) * S;
|
|
1658
|
+
const c = it.color || palette[i % palette.length];
|
|
1659
|
+
const rightHalf = clamp01(it.x) > 0.82;
|
|
1660
|
+
p.push(`<circle cx="${r(x)}" cy="${r(y)}" r="6" fill="${c}"/>`);
|
|
1661
|
+
p.push(`<text x="${r(rightHalf ? x - 10 : x + 10)}" y="${r(y + 4)}" text-anchor="${rightHalf ? "end" : "start"}" font-size="${fs}"${wOpt(spec)} fill="${labelCol}">${esc(it.label || "")}</text>`);
|
|
1662
|
+
});
|
|
1663
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1664
|
+
p.push(`</svg>`);
|
|
1665
|
+
return p.join("\n");
|
|
1666
|
+
}
|
|
1667
|
+
function renderTimeline(spec) {
|
|
1668
|
+
const events = Array.isArray(spec.events) ? spec.events : [];
|
|
1669
|
+
const n = Math.max(1, events.length);
|
|
1670
|
+
const title = spec.title || "";
|
|
1671
|
+
const bg = spec.background || "#ffffff";
|
|
1672
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1673
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1674
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1675
|
+
const font = resolveFont(spec);
|
|
1676
|
+
const watermark = spec.watermark !== false;
|
|
1677
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(n, 3));
|
|
1678
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1679
|
+
const labelTextCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1680
|
+
const dateCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1681
|
+
const lineCol = isDark ? "#475569" : "#cbd5e1";
|
|
1682
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1683
|
+
const haloCol = transparent ? "#ffffff" : bg;
|
|
1684
|
+
const PAD = 44;
|
|
1685
|
+
const topY = title ? 54 : 24;
|
|
1686
|
+
const W = spec.width || 720, H = spec.height || 220;
|
|
1687
|
+
const lineY = topY + (H - topY) / 2;
|
|
1688
|
+
const plotW = W - PAD * 2;
|
|
1689
|
+
const xOf = (i) => n === 1 ? W / 2 : PAD + i * (plotW / (n - 1));
|
|
1690
|
+
const p = [];
|
|
1691
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1692
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1693
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1694
|
+
p.push(`<line x1="${PAD}" y1="${r(lineY)}" x2="${W - PAD}" y2="${r(lineY)}" stroke="${lineCol}" stroke-width="2"/>`);
|
|
1695
|
+
events.forEach((e, i) => {
|
|
1696
|
+
const x = xOf(i), c = e.color || palette[i % palette.length];
|
|
1697
|
+
const above = i % 2 === 0;
|
|
1698
|
+
p.push(`<circle cx="${r(x)}" cy="${r(lineY)}" r="6" fill="${c}" stroke="${haloCol}" stroke-width="3"/>`);
|
|
1699
|
+
if (above) {
|
|
1700
|
+
p.push(`<text x="${r(x)}" y="${r(lineY - 28)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${labelTextCol}">${esc(e.label || "")}</text>`);
|
|
1701
|
+
if (e.date) p.push(`<text x="${r(x)}" y="${r(lineY - 13)}" text-anchor="middle" font-size="${fs - 1}"${wOpt(spec)} fill="${dateCol}">${esc(e.date)}</text>`);
|
|
1702
|
+
} else {
|
|
1703
|
+
if (e.date) p.push(`<text x="${r(x)}" y="${r(lineY + 20)}" text-anchor="middle" font-size="${fs - 1}"${wOpt(spec)} fill="${dateCol}">${esc(e.date)}</text>`);
|
|
1704
|
+
p.push(`<text x="${r(x)}" y="${r(lineY + 37)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${labelTextCol}">${esc(e.label || "")}</text>`);
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1708
|
+
p.push(`</svg>`);
|
|
1709
|
+
return p.join("\n");
|
|
1710
|
+
}
|
|
1711
|
+
function renderVenn(spec) {
|
|
1712
|
+
const sets = Array.isArray(spec.sets) ? spec.sets : [];
|
|
1713
|
+
const ns = sets.length;
|
|
1714
|
+
const title = spec.title || "";
|
|
1715
|
+
const bg = spec.background || "#ffffff";
|
|
1716
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1717
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1718
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1719
|
+
const font = resolveFont(spec);
|
|
1720
|
+
const watermark = spec.watermark !== false;
|
|
1721
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(ns, 3));
|
|
1722
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1723
|
+
const textCol = txt(spec, isDark ? "#f1f5f9" : "#1a1a1a");
|
|
1724
|
+
const labelCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1725
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1726
|
+
const topY = title ? 54 : 24;
|
|
1727
|
+
const W = spec.width || 600, H = spec.height || 460;
|
|
1728
|
+
const val = (i) => fmt(Number(sets[i] && sets[i].value) || 0);
|
|
1729
|
+
const p = [];
|
|
1730
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1731
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1732
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1733
|
+
if (ns <= 2) {
|
|
1734
|
+
const R = 120, cy = topY + 40 + R;
|
|
1735
|
+
const cx1 = W / 2 - (ns === 2 ? 70 : 0), cx2 = W / 2 + 70;
|
|
1736
|
+
p.push(`<circle cx="${r(cx1)}" cy="${r(cy)}" r="${R}" fill="${sets[0] && sets[0].color || palette[0]}" fill-opacity="0.5"/>`);
|
|
1737
|
+
p.push(`<text x="${r(cx1)}" y="${r(cy - R - 12)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${labelCol}">${esc(sets[0] && sets[0].label || "")}</text>`);
|
|
1738
|
+
if (ns === 2) {
|
|
1739
|
+
p.push(`<circle cx="${r(cx2)}" cy="${r(cy)}" r="${R}" fill="${sets[1] && sets[1].color || palette[1]}" fill-opacity="0.5"/>`);
|
|
1740
|
+
p.push(`<text x="${r(cx2)}" y="${r(cy - R - 12)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${labelCol}">${esc(sets[1] && sets[1].label || "")}</text>`);
|
|
1741
|
+
p.push(`<text x="${r(cx1 - R * 0.45)}" y="${r(cy + 4)}" text-anchor="middle" font-size="${fs + 2}" ${wAttr(spec, 700)} fill="${textCol}">${val(0)}</text>`);
|
|
1742
|
+
p.push(`<text x="${r(cx2 + R * 0.45)}" y="${r(cy + 4)}" text-anchor="middle" font-size="${fs + 2}" ${wAttr(spec, 700)} fill="${textCol}">${val(1)}</text>`);
|
|
1743
|
+
if (spec.overlap !== void 0 && spec.overlap !== null) p.push(`<text x="${r(W / 2)}" y="${r(cy + 4)}" text-anchor="middle" font-size="${fs + 2}" ${wAttr(spec, 700)} fill="${textCol}">${esc(fmt(Number(spec.overlap) || 0))}</text>`);
|
|
1744
|
+
} else if (ns === 1) {
|
|
1745
|
+
p.push(`<text x="${r(cx1)}" y="${r(cy + 4)}" text-anchor="middle" font-size="${fs + 2}" ${wAttr(spec, 700)} fill="${textCol}">${val(0)}</text>`);
|
|
1746
|
+
}
|
|
1747
|
+
} else {
|
|
1748
|
+
const R = 100, cx = W / 2, cyTop = topY + 28 + R * 0.6;
|
|
1749
|
+
const c = [[cx, cyTop], [cx - R * 0.78, cyTop + R * 0.98], [cx + R * 0.78, cyTop + R * 0.98]];
|
|
1750
|
+
for (let i = 0; i < 3; i++) p.push(`<circle cx="${r(c[i][0])}" cy="${r(c[i][1])}" r="${R}" fill="${sets[i] && sets[i].color || palette[i]}" fill-opacity="0.45"/>`);
|
|
1751
|
+
p.push(`<text x="${r(c[0][0])}" y="${r(c[0][1] - R - 8)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${labelCol}">${esc((sets[0].label || "") + " " + val(0))}</text>`);
|
|
1752
|
+
p.push(`<text x="${r(c[1][0] - R * 0.4)}" y="${r(c[1][1] + R + 18)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${labelCol}">${esc((sets[1].label || "") + " " + val(1))}</text>`);
|
|
1753
|
+
p.push(`<text x="${r(c[2][0] + R * 0.4)}" y="${r(c[2][1] + R + 18)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${labelCol}">${esc((sets[2].label || "") + " " + val(2))}</text>`);
|
|
1754
|
+
}
|
|
1755
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1756
|
+
p.push(`</svg>`);
|
|
1757
|
+
return p.join("\n");
|
|
1758
|
+
}
|
|
1759
|
+
function glyphCheck(cx, cy, s, col) {
|
|
1760
|
+
return `<path d="M${r(cx - s * 0.5)},${r(cy + s * 0.02)} L${r(cx - s * 0.12)},${r(cy + s * 0.42)} L${r(cx + s * 0.55)},${r(cy - s * 0.45)}" fill="none" stroke="${col}" stroke-width="${r(s * 0.26)}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
1761
|
+
}
|
|
1762
|
+
function glyphCross(cx, cy, s, col) {
|
|
1763
|
+
return `<path d="M${r(cx - s * 0.4)},${r(cy - s * 0.4)} L${r(cx + s * 0.4)},${r(cy + s * 0.4)} M${r(cx + s * 0.4)},${r(cy - s * 0.4)} L${r(cx - s * 0.4)},${r(cy + s * 0.4)}" fill="none" stroke="${col}" stroke-width="${r(s * 0.24)}" stroke-linecap="round"/>`;
|
|
1764
|
+
}
|
|
1765
|
+
function glyphDot(cx, cy, s, col) {
|
|
1766
|
+
return `<circle cx="${r(cx)}" cy="${r(cy)}" r="${r(s * 0.36)}" fill="${col}"/>`;
|
|
1767
|
+
}
|
|
1768
|
+
function glyphRing(cx, cy, s, col) {
|
|
1769
|
+
return `<circle cx="${r(cx)}" cy="${r(cy)}" r="${r(s * 0.4)}" fill="none" stroke="${col}" stroke-width="${r(s * 0.18)}"/>`;
|
|
1770
|
+
}
|
|
1771
|
+
function glyphDash(cx, cy, s, col) {
|
|
1772
|
+
return `<line x1="${r(cx - s * 0.4)}" y1="${r(cy)}" x2="${r(cx + s * 0.4)}" y2="${r(cy)}" stroke="${col}" stroke-width="${r(s * 0.2)}" stroke-linecap="round"/>`;
|
|
1773
|
+
}
|
|
1774
|
+
function personGlyph(cx, topY, h, col) {
|
|
1775
|
+
const headR = h * 0.22, headCy = topY + headR;
|
|
1776
|
+
const bodyTop = headCy + headR * 1.2, bodyW = h * 0.64, bodyBot = topY + h;
|
|
1777
|
+
return `<circle cx="${r(cx)}" cy="${r(headCy)}" r="${r(headR)}" fill="${col}"/><path d="M${r(cx - bodyW / 2)},${r(bodyBot)} Q${r(cx - bodyW / 2)},${r(bodyTop)} ${r(cx)},${r(bodyTop)} Q${r(cx + bodyW / 2)},${r(bodyTop)} ${r(cx + bodyW / 2)},${r(bodyBot)} Z" fill="${col}"/>`;
|
|
1778
|
+
}
|
|
1779
|
+
function statusCellSvg(val, cx, cy, s, fs, textCol) {
|
|
1780
|
+
if (val === true) return glyphCheck(cx, cy, s, "#059669");
|
|
1781
|
+
if (val === false) return glyphCross(cx, cy, s, "#94a3b8");
|
|
1782
|
+
const t = String(val == null ? "" : val).trim().toLowerCase();
|
|
1783
|
+
if (["yes", "y", "true", "\u2713", "done", "included"].includes(t)) return glyphCheck(cx, cy, s, "#059669");
|
|
1784
|
+
if (["no", "n", "false", "\u2717", "x"].includes(t)) return glyphCross(cx, cy, s, "#94a3b8");
|
|
1785
|
+
if (["partial", "~", "half", "some", "limited"].includes(t)) return glyphDot(cx, cy, s, "#d97706");
|
|
1786
|
+
if (["", "-", "\u2013", "na", "n/a"].includes(t)) return glyphDash(cx, cy, s, "#cbd5e1");
|
|
1787
|
+
return `<text x="${r(cx)}" y="${r(cy + fs * 0.35)}" text-anchor="middle" font-size="${fs}" fill="${textCol}">${esc(String(val))}</text>`;
|
|
1788
|
+
}
|
|
1789
|
+
function renderMatrix(spec) {
|
|
1790
|
+
const columns = Array.isArray(spec.columns) ? spec.columns : [];
|
|
1791
|
+
const rows = Array.isArray(spec.rows) ? spec.rows : [];
|
|
1792
|
+
const nc = Math.max(1, columns.length);
|
|
1793
|
+
const title = spec.title || "";
|
|
1794
|
+
const bg = spec.background || "#ffffff";
|
|
1795
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1796
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1797
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1798
|
+
const font = resolveFont(spec);
|
|
1799
|
+
const watermark = spec.watermark !== false;
|
|
1800
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1801
|
+
const labelCol = txt(spec, isDark ? "#e2e8f0" : "#334155");
|
|
1802
|
+
const headCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1803
|
+
const lineCol = isDark ? "#334155" : "#e2e8f0";
|
|
1804
|
+
const zebra = isDark ? "#1e293b" : "#f8fafc";
|
|
1805
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1806
|
+
const PAD = 24;
|
|
1807
|
+
const topY = title ? 54 : 24;
|
|
1808
|
+
const LEFTW = spec.labelWidth ? Number(spec.labelWidth) : 200;
|
|
1809
|
+
const HEAD = 38, ROWH = 40;
|
|
1810
|
+
const W = spec.width || 680;
|
|
1811
|
+
const H = spec.height || topY + HEAD + rows.length * ROWH + 16;
|
|
1812
|
+
const plotLeft = PAD + LEFTW;
|
|
1813
|
+
const cellW = (W - PAD - plotLeft) / nc;
|
|
1814
|
+
const p = [];
|
|
1815
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1816
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1817
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1818
|
+
columns.forEach((c, ci) => p.push(`<text x="${r(plotLeft + ci * cellW + cellW / 2)}" y="${r(topY + HEAD - 14)}" text-anchor="middle" font-size="${fs}" ${wAttr(spec, 700)} fill="${headCol}">${esc(c)}</text>`));
|
|
1819
|
+
p.push(`<line x1="${PAD}" y1="${r(topY + HEAD)}" x2="${W - PAD}" y2="${r(topY + HEAD)}" stroke="${lineCol}" stroke-width="1"/>`);
|
|
1820
|
+
rows.forEach((row, ri) => {
|
|
1821
|
+
const y = topY + HEAD + ri * ROWH;
|
|
1822
|
+
if (ri % 2 === 1) p.push(`<rect x="${PAD}" y="${r(y)}" width="${r(W - PAD * 2)}" height="${ROWH}" fill="${zebra}"/>`);
|
|
1823
|
+
p.push(`<text x="${PAD}" y="${r(y + ROWH / 2 + 4)}" font-size="${fs}"${wOpt(spec)} fill="${labelCol}">${esc(row.label || "")}</text>`);
|
|
1824
|
+
const cells = Array.isArray(row.cells) ? row.cells : [];
|
|
1825
|
+
columns.forEach((c, ci) => {
|
|
1826
|
+
p.push(statusCellSvg(cells[ci], plotLeft + ci * cellW + cellW / 2, y + ROWH / 2, fs * 0.85, fs, labelCol));
|
|
1827
|
+
});
|
|
1828
|
+
});
|
|
1829
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1830
|
+
p.push(`</svg>`);
|
|
1831
|
+
return p.join("\n");
|
|
1832
|
+
}
|
|
1833
|
+
function renderChecklist(spec) {
|
|
1834
|
+
const items = Array.isArray(spec.items) ? spec.items : [];
|
|
1835
|
+
const n = Math.max(1, items.length);
|
|
1836
|
+
const title = spec.title || "";
|
|
1837
|
+
const bg = spec.background || "#ffffff";
|
|
1838
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1839
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1840
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1841
|
+
const font = resolveFont(spec);
|
|
1842
|
+
const watermark = spec.watermark !== false;
|
|
1843
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1844
|
+
const labelCol = txt(spec, isDark ? "#e2e8f0" : "#334155");
|
|
1845
|
+
const doneCol = isDark ? "#64748b" : "#94a3b8";
|
|
1846
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1847
|
+
const PAD = 24, ROWH = 38;
|
|
1848
|
+
const topY = title ? 54 : 24;
|
|
1849
|
+
const W = spec.width || 520;
|
|
1850
|
+
const H = spec.height || topY + n * ROWH + 12;
|
|
1851
|
+
const s = fs * 1, gx = PAD + 11;
|
|
1852
|
+
const p = [];
|
|
1853
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1854
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1855
|
+
if (title) p.push(`<text x="${PAD}" y="33" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1856
|
+
items.forEach((it, i) => {
|
|
1857
|
+
const cy = topY + i * ROWH + ROWH / 2;
|
|
1858
|
+
const st = String(it.status || "").trim().toLowerCase();
|
|
1859
|
+
let glyph, lc = labelCol;
|
|
1860
|
+
if (["done", "complete", "completed", "yes", "true"].includes(st)) {
|
|
1861
|
+
glyph = glyphCheck(gx, cy, s, "#059669");
|
|
1862
|
+
lc = doneCol;
|
|
1863
|
+
} else if (["blocked", "fail", "failed", "no", "cancelled"].includes(st)) {
|
|
1864
|
+
glyph = glyphCross(gx, cy, s, "#dc2626");
|
|
1865
|
+
} else if (["partial", "progress", "in progress", "wip", "doing"].includes(st)) {
|
|
1866
|
+
glyph = glyphDot(gx, cy, s, "#d97706");
|
|
1867
|
+
} else {
|
|
1868
|
+
glyph = glyphRing(gx, cy, s, "#cbd5e1");
|
|
1869
|
+
}
|
|
1870
|
+
p.push(glyph);
|
|
1871
|
+
p.push(`<text x="${r(gx + s + 14)}" y="${r(cy + fs * 0.35)}" font-size="${fs + 1}"${wOpt(spec)} fill="${lc}">${esc(it.label || "")}</text>`);
|
|
1872
|
+
});
|
|
1873
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1874
|
+
p.push(`</svg>`);
|
|
1875
|
+
return p.join("\n");
|
|
1876
|
+
}
|
|
1877
|
+
function renderIconArray(spec) {
|
|
1878
|
+
const total = Math.max(1, Math.round(Number(spec.total) || 10));
|
|
1879
|
+
const filled = Math.max(0, Math.min(total, Math.round(Number(spec.filled) || 0)));
|
|
1880
|
+
const title = spec.title || "";
|
|
1881
|
+
const bg = spec.background || "#ffffff";
|
|
1882
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1883
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1884
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1885
|
+
const font = resolveFont(spec);
|
|
1886
|
+
const watermark = spec.watermark !== false;
|
|
1887
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1888
|
+
const fillCol = spec.color || resolveFlatPalette(spec.palette || "Clean Corporate", 1)[0];
|
|
1889
|
+
const emptyCol = isDark ? "#334155" : "#e2e8f0";
|
|
1890
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1891
|
+
const PAD = 24, ICON = 30, GAP = 8;
|
|
1892
|
+
const topY = title ? 54 : 24;
|
|
1893
|
+
const perRow = Math.max(1, spec.perRow ? Number(spec.perRow) : Math.min(total, 10));
|
|
1894
|
+
const rowsN = Math.ceil(total / perRow);
|
|
1895
|
+
const W = spec.width || PAD * 2 + perRow * ICON + (perRow - 1) * GAP;
|
|
1896
|
+
const H = spec.height || topY + rowsN * (ICON + GAP) + 12;
|
|
1897
|
+
const p = [];
|
|
1898
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1899
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1900
|
+
if (title) p.push(`<text x="${PAD}" y="33" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1901
|
+
for (let k = 0; k < total; k++) {
|
|
1902
|
+
const row = Math.floor(k / perRow), col = k % perRow;
|
|
1903
|
+
const x = PAD + col * (ICON + GAP), y = topY + row * (ICON + GAP);
|
|
1904
|
+
p.push(personGlyph(x + ICON / 2, y, ICON, k < filled ? fillCol : emptyCol));
|
|
1905
|
+
}
|
|
1906
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1907
|
+
p.push(`</svg>`);
|
|
1908
|
+
return p.join("\n");
|
|
1909
|
+
}
|
|
1910
|
+
function connectorLine(x1, x2, y, col) {
|
|
1911
|
+
const ah = 5;
|
|
1912
|
+
return `<line x1="${r(x1)}" y1="${r(y)}" x2="${r(x2 - ah)}" y2="${r(y)}" stroke="${col}" stroke-width="2"/><path d="M${r(x2 - ah - 1)},${r(y - ah)} L${r(x2)},${r(y)} L${r(x2 - ah - 1)},${r(y + ah)}" fill="none" stroke="${col}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
1913
|
+
}
|
|
1914
|
+
function renderSteps(spec) {
|
|
1915
|
+
const steps = Array.isArray(spec.steps) ? spec.steps : [];
|
|
1916
|
+
const n = Math.max(1, steps.length);
|
|
1917
|
+
const title = spec.title || "";
|
|
1918
|
+
const bg = spec.background || "#ffffff";
|
|
1919
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1920
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1921
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1922
|
+
const font = resolveFont(spec);
|
|
1923
|
+
const watermark = spec.watermark !== false;
|
|
1924
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(n, 3));
|
|
1925
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1926
|
+
const labelCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1927
|
+
const descCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1928
|
+
const connCol = isDark ? "#475569" : "#cbd5e1";
|
|
1929
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1930
|
+
const PAD = 52, R = 22;
|
|
1931
|
+
const topY = title ? 54 : 24;
|
|
1932
|
+
const W = spec.width || 720;
|
|
1933
|
+
const H = spec.height || topY + 150;
|
|
1934
|
+
const cy = topY + 36 + R;
|
|
1935
|
+
const xOf = (i) => n === 1 ? W / 2 : PAD + R + i * ((W - 2 * (PAD + R)) / (n - 1));
|
|
1936
|
+
const p = [];
|
|
1937
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1938
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1939
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1940
|
+
for (let i = 0; i < n - 1; i++) p.push(connectorLine(xOf(i) + R + 6, xOf(i + 1) - R - 6, cy, connCol));
|
|
1941
|
+
steps.forEach((st, i) => {
|
|
1942
|
+
const x = xOf(i), fill = st.color || palette[i % palette.length], tc = spec.textColor || contrastColor(fill);
|
|
1943
|
+
p.push(`<circle cx="${r(x)}" cy="${r(cy)}" r="${R}" fill="${fill}"/>`);
|
|
1944
|
+
p.push(`<text x="${r(x)}" y="${r(cy + 5)}" text-anchor="middle" font-size="${fs + 3}" ${wAttr(spec, 800)} fill="${tc}">${i + 1}</text>`);
|
|
1945
|
+
p.push(`<text x="${r(x)}" y="${r(cy + R + 24)}" text-anchor="middle" font-size="${fs + 1}" ${wAttr(spec, 700)} fill="${labelCol}">${esc(st.label || "")}</text>`);
|
|
1946
|
+
if (st.description) p.push(`<text x="${r(x)}" y="${r(cy + R + 42)}" text-anchor="middle" font-size="${fs - 1}"${wOpt(spec)} fill="${descCol}">${esc(st.description)}</text>`);
|
|
1947
|
+
});
|
|
1948
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1949
|
+
p.push(`</svg>`);
|
|
1950
|
+
return p.join("\n");
|
|
1951
|
+
}
|
|
1952
|
+
function renderTable(spec) {
|
|
1953
|
+
const columns = Array.isArray(spec.columns) ? spec.columns : [];
|
|
1954
|
+
const rows = Array.isArray(spec.rows) ? spec.rows : [];
|
|
1955
|
+
const nc = Math.max(1, columns.length);
|
|
1956
|
+
const title = spec.title || "";
|
|
1957
|
+
const bg = spec.background || "#ffffff";
|
|
1958
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
1959
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
1960
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
1961
|
+
const font = resolveFont(spec);
|
|
1962
|
+
const watermark = spec.watermark !== false;
|
|
1963
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
1964
|
+
const headCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
1965
|
+
const cellCol = txt(spec, isDark ? "#e2e8f0" : "#334155");
|
|
1966
|
+
const lineCol = isDark ? "#334155" : "#e2e8f0";
|
|
1967
|
+
const zebra = isDark ? "#1e293b" : "#f8fafc";
|
|
1968
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
1969
|
+
const PAD = 24, HEAD = 38, ROWH = 36;
|
|
1970
|
+
const topY = title ? 54 : 24;
|
|
1971
|
+
const W = spec.width || 680;
|
|
1972
|
+
const H = spec.height || topY + HEAD + rows.length * ROWH + 16;
|
|
1973
|
+
const colW = (W - PAD * 2) / nc;
|
|
1974
|
+
const isNum = (v) => typeof v === "number" || typeof v === "string" && v.trim() !== "" && !isNaN(Number(v));
|
|
1975
|
+
const cellX = (ci, right) => right ? PAD + ci * colW + colW - 10 : PAD + ci * colW + 4;
|
|
1976
|
+
const p = [];
|
|
1977
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
1978
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
1979
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
1980
|
+
columns.forEach((c, ci) => {
|
|
1981
|
+
const right = ci > 0;
|
|
1982
|
+
p.push(`<text x="${r(cellX(ci, right))}" y="${r(topY + HEAD - 14)}" ${right ? 'text-anchor="end" ' : ""}font-size="${fs}" ${wAttr(spec, 700)} fill="${headCol}">${esc(c)}</text>`);
|
|
1983
|
+
});
|
|
1984
|
+
p.push(`<line x1="${PAD}" y1="${r(topY + HEAD)}" x2="${W - PAD}" y2="${r(topY + HEAD)}" stroke="${lineCol}" stroke-width="1"/>`);
|
|
1985
|
+
rows.forEach((row, ri) => {
|
|
1986
|
+
const y = topY + HEAD + ri * ROWH;
|
|
1987
|
+
if (ri % 2 === 1) p.push(`<rect x="${PAD}" y="${r(y)}" width="${r(W - PAD * 2)}" height="${ROWH}" fill="${zebra}"/>`);
|
|
1988
|
+
const cells = Array.isArray(row) ? row : [];
|
|
1989
|
+
for (let ci = 0; ci < nc; ci++) {
|
|
1990
|
+
const v = cells[ci];
|
|
1991
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
1992
|
+
const num = ci > 0 && isNum(v);
|
|
1993
|
+
const out = num ? fmt(Number(v)) : String(v);
|
|
1994
|
+
p.push(`<text x="${r(cellX(ci, num))}" y="${r(y + ROWH / 2 + 4)}" ${num ? 'text-anchor="end" ' : ""}font-size="${fs}"${wOpt(spec)} fill="${cellCol}">${esc(out)}</text>`);
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
1998
|
+
p.push(`</svg>`);
|
|
1999
|
+
return p.join("\n");
|
|
2000
|
+
}
|
|
2001
|
+
function renderGauge(spec) {
|
|
2002
|
+
const value = Number(spec.value) || 0;
|
|
2003
|
+
const min = spec.min != null ? Number(spec.min) : 0;
|
|
2004
|
+
const max = spec.max != null ? Number(spec.max) : 100;
|
|
2005
|
+
const t = Math.max(0, Math.min(1, (value - min) / (max - min || 1)));
|
|
2006
|
+
const title = spec.title || "";
|
|
2007
|
+
const bg = spec.background || "#ffffff";
|
|
2008
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
2009
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
2010
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
2011
|
+
const font = resolveFont(spec);
|
|
2012
|
+
const watermark = spec.watermark !== false;
|
|
2013
|
+
const accent = contrastFloor(spec.color || resolveFlatPalette(spec.palette || "Clean Corporate", 1)[0], bg, transparent);
|
|
2014
|
+
const track = isDark ? "#334155" : "#e2e8f0";
|
|
2015
|
+
const valueCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
2016
|
+
const labelCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
2017
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
2018
|
+
const PAD = 24;
|
|
2019
|
+
const topY = title ? 50 : 20;
|
|
2020
|
+
const W = spec.width || 340, H = spec.height || 230;
|
|
2021
|
+
const rad = Math.min((W - PAD * 2) / 2, H - topY - 50);
|
|
2022
|
+
const cx = W / 2, cy = topY + rad + 6;
|
|
2023
|
+
const innerR = rad * 0.72;
|
|
2024
|
+
const u = spec.valueUnit ? " " + spec.valueUnit : "";
|
|
2025
|
+
const p = [];
|
|
2026
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
2027
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
2028
|
+
if (title) p.push(`<text x="${r(cx)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${valueCol}">${esc(title)}</text>`);
|
|
2029
|
+
p.push(`<path d="${arcPath(cx, cy, rad, Math.PI, 2 * Math.PI, innerR)}" fill="${track}"/>`);
|
|
2030
|
+
if (t > 0) p.push(`<path d="${arcPath(cx, cy, rad, Math.PI, Math.PI + t * Math.PI, innerR)}" fill="${accent}"/>`);
|
|
2031
|
+
p.push(`<text x="${r(cx)}" y="${r(cy - 6)}" text-anchor="middle" font-size="${r(rad * 0.42)}" ${wAttr(spec, 800)} fill="${valueCol}">${esc(fmt(value) + u)}</text>`);
|
|
2032
|
+
if (spec.label) p.push(`<text x="${r(cx)}" y="${r(cy + 18)}" text-anchor="middle" font-size="${fs}"${wOpt(spec)} fill="${labelCol}">${esc(spec.label)}</text>`);
|
|
2033
|
+
p.push(`<text x="${r(cx - (rad + innerR) / 2)}" y="${r(cy + 16)}" text-anchor="middle" font-size="${fs - 2}" fill="${faint}">${esc(fmt(min))}</text>`);
|
|
2034
|
+
p.push(`<text x="${r(cx + (rad + innerR) / 2)}" y="${r(cy + 16)}" text-anchor="middle" font-size="${fs - 2}" fill="${faint}">${esc(fmt(max))}</text>`);
|
|
2035
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
2036
|
+
p.push(`</svg>`);
|
|
2037
|
+
return p.join("\n");
|
|
2038
|
+
}
|
|
2039
|
+
function renderBullet(spec) {
|
|
2040
|
+
const bars = Array.isArray(spec.bars) ? spec.bars : [];
|
|
2041
|
+
const n = Math.max(1, bars.length);
|
|
2042
|
+
const title = spec.title || "";
|
|
2043
|
+
const bg = spec.background || "#ffffff";
|
|
2044
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
2045
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
2046
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
2047
|
+
const font = resolveFont(spec);
|
|
2048
|
+
const watermark = spec.watermark !== false;
|
|
2049
|
+
const measureCol = contrastFloor(resolveFlatPalette(spec.palette || "Clean Corporate", 1)[0], bg, transparent);
|
|
2050
|
+
const labelCol = txt(spec, isDark ? "#cbd5e1" : "#475569");
|
|
2051
|
+
const valCol = txt(spec, isDark ? "#e2e8f0" : "#334155");
|
|
2052
|
+
const tickCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
2053
|
+
const bandCols = isDark ? ["#1e293b", "#334155", "#475569"] : ["#eef2f6", "#dbe2ea", "#c3cedb"];
|
|
2054
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
2055
|
+
const PAD = 24, ROW_H = 50;
|
|
2056
|
+
const topY = title ? 54 : 24;
|
|
2057
|
+
const W = spec.width || 600;
|
|
2058
|
+
const H = spec.height || topY + n * ROW_H + 8;
|
|
2059
|
+
const trackW = W - PAD * 2;
|
|
2060
|
+
const p = [];
|
|
2061
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
2062
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
2063
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${tickCol}">${esc(title)}</text>`);
|
|
2064
|
+
bars.forEach((b, i) => {
|
|
2065
|
+
const ry = topY + i * ROW_H;
|
|
2066
|
+
const val = Number(b.value) || 0, target = b.target != null ? Number(b.target) : null;
|
|
2067
|
+
const bands = Array.isArray(b.bands) ? b.bands.map(Number) : [];
|
|
2068
|
+
const max = b.max != null ? Number(b.max) : Math.max(val, target || 0, ...bands) * 1.1 || 1;
|
|
2069
|
+
const sx = (v) => PAD + Math.max(0, Math.min(1, v / max)) * trackW;
|
|
2070
|
+
const barY = ry + 22, barH = 16;
|
|
2071
|
+
p.push(`<text x="${PAD}" y="${r(ry + 13)}" font-size="${fs}" ${wAttr(spec, 600)} fill="${labelCol}">${esc(b.label || "")}</text>`);
|
|
2072
|
+
p.push(`<text x="${W - PAD}" y="${r(ry + 13)}" text-anchor="end" font-size="${fs}"${wOpt(spec)} fill="${valCol}">${esc(fmt(val))}${target != null ? " / " + esc(fmt(target)) : ""}</text>`);
|
|
2073
|
+
const edges = [0, ...bands, max];
|
|
2074
|
+
for (let k = 0; k < edges.length - 1; k++) {
|
|
2075
|
+
const x0 = sx(edges[k]), x1 = sx(edges[k + 1]);
|
|
2076
|
+
p.push(`<rect x="${r(x0)}" y="${r(barY)}" width="${r(Math.max(0, x1 - x0))}" height="${barH}" fill="${bandCols[Math.min(k, bandCols.length - 1)]}"/>`);
|
|
2077
|
+
}
|
|
2078
|
+
p.push(`<rect x="${PAD}" y="${r(barY + barH / 2 - 4)}" width="${r(sx(val) - PAD)}" height="8" rx="2" fill="${b.color || measureCol}"/>`);
|
|
2079
|
+
if (target != null) p.push(`<line x1="${r(sx(target))}" y1="${r(barY - 4)}" x2="${r(sx(target))}" y2="${r(barY + barH + 4)}" stroke="${tickCol}" stroke-width="2.5"/>`);
|
|
2080
|
+
});
|
|
2081
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
2082
|
+
p.push(`</svg>`);
|
|
2083
|
+
return p.join("\n");
|
|
2084
|
+
}
|
|
2085
|
+
function renderCalendar(spec) {
|
|
2086
|
+
const days = spec.days && typeof spec.days === "object" && !Array.isArray(spec.days) ? spec.days : {};
|
|
2087
|
+
const year = spec.year != null ? Number(spec.year) : 2025;
|
|
2088
|
+
const title = spec.title || "";
|
|
2089
|
+
const bg = spec.background || "#ffffff";
|
|
2090
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
2091
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
2092
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
2093
|
+
const font = resolveFont(spec);
|
|
2094
|
+
const watermark = spec.watermark !== false;
|
|
2095
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
2096
|
+
const labelCol = txt(spec, isDark ? "#cbd5e1" : "#64748b");
|
|
2097
|
+
const empty = isDark ? "#1e293b" : "#ebedf0";
|
|
2098
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
2099
|
+
const baseHsl = hexToHsl(spec.color || resolveFlatPalette(spec.palette || "Clean Corporate", 1)[0]);
|
|
2100
|
+
const isLeap = year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
|
|
2101
|
+
const mdays = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
2102
|
+
const totalDays = isLeap ? 366 : 365;
|
|
2103
|
+
const Y = year - 1, K = Y % 100, J = Math.floor(Y / 100);
|
|
2104
|
+
const h = (1 + Math.floor(13 * 14 / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) + 5 * J) % 7;
|
|
2105
|
+
const startDow = (h + 6) % 7;
|
|
2106
|
+
const doyOfMonth = (mo) => {
|
|
2107
|
+
let d = 0;
|
|
2108
|
+
for (let i = 0; i < mo; i++) d += mdays[i];
|
|
2109
|
+
return d;
|
|
2110
|
+
};
|
|
2111
|
+
const valByDoy = {};
|
|
2112
|
+
let maxV = 0;
|
|
2113
|
+
for (const key in days) {
|
|
2114
|
+
const parts = String(key).split("-").map(Number);
|
|
2115
|
+
const mo = parts.length >= 3 ? parts[1] : parts[0];
|
|
2116
|
+
const da = parts.length >= 3 ? parts[2] : parts[1];
|
|
2117
|
+
if (!mo || !da) continue;
|
|
2118
|
+
const doy = doyOfMonth(mo - 1) + (da - 1);
|
|
2119
|
+
const v = Number(days[key]) || 0;
|
|
2120
|
+
valByDoy[doy] = v;
|
|
2121
|
+
if (v > maxV) maxV = v;
|
|
2122
|
+
}
|
|
2123
|
+
const cellColor = (v) => v <= 0 ? empty : hslToHex(baseHsl.h, Math.max(35, baseHsl.s), Math.max(0, Math.min(100, 86 - (maxV > 0 ? v / maxV : 0) * 50)));
|
|
2124
|
+
const PAD = 24, CELL = 11, GAP = 3, LEFTW = 30, TOPH = 18;
|
|
2125
|
+
const topY = title ? 50 : 20;
|
|
2126
|
+
const gridX = PAD + LEFTW, gridY = topY + TOPH;
|
|
2127
|
+
const weeks = Math.ceil((startDow + totalDays) / 7);
|
|
2128
|
+
const W = spec.width || gridX + weeks * (CELL + GAP) + PAD;
|
|
2129
|
+
const H = spec.height || gridY + 7 * (CELL + GAP) + 16;
|
|
2130
|
+
const p = [];
|
|
2131
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
2132
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
2133
|
+
if (title) p.push(`<text x="${PAD}" y="33" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
2134
|
+
const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
2135
|
+
for (let mo = 0; mo < 12; mo++) {
|
|
2136
|
+
const col = Math.floor((startDow + doyOfMonth(mo)) / 7);
|
|
2137
|
+
p.push(`<text x="${r(gridX + col * (CELL + GAP))}" y="${r(topY + TOPH - 6)}" font-size="${fs - 3}" fill="${labelCol}">${MON[mo]}</text>`);
|
|
2138
|
+
}
|
|
2139
|
+
[["Mon", 1], ["Wed", 3], ["Fri", 5]].forEach(([lbl, row]) => p.push(`<text x="${r(PAD)}" y="${r(gridY + row * (CELL + GAP) + CELL)}" font-size="${fs - 3}" fill="${labelCol}">${lbl}</text>`));
|
|
2140
|
+
for (let doy = 0; doy < totalDays; doy++) {
|
|
2141
|
+
const idx = startDow + doy, col = Math.floor(idx / 7), row = idx % 7;
|
|
2142
|
+
const x = gridX + col * (CELL + GAP), y = gridY + row * (CELL + GAP);
|
|
2143
|
+
p.push(`<rect x="${r(x)}" y="${r(y)}" width="${CELL}" height="${CELL}" rx="2" fill="${cellColor(valByDoy[doy] || 0)}"/>`);
|
|
2144
|
+
}
|
|
2145
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
2146
|
+
p.push(`</svg>`);
|
|
2147
|
+
return p.join("\n");
|
|
2148
|
+
}
|
|
2149
|
+
function renderLeaderboard(spec) {
|
|
2150
|
+
const raw = Array.isArray(spec.items) ? spec.items : [];
|
|
2151
|
+
const items = raw.map((it) => ({ label: it.label || "", value: Number(it.value) || 0, color: it.color })).sort((a, b) => b.value - a.value);
|
|
2152
|
+
const n = Math.max(1, items.length);
|
|
2153
|
+
const title = spec.title || "";
|
|
2154
|
+
const bg = spec.background || "#ffffff";
|
|
2155
|
+
const transparent = bg === "transparent" || bg === "none";
|
|
2156
|
+
const isDark = !transparent && getLuminance(bg) < 0.35;
|
|
2157
|
+
const fs = spec.fontSize ? Number(spec.fontSize) : 13;
|
|
2158
|
+
const font = resolveFont(spec);
|
|
2159
|
+
const watermark = spec.watermark !== false;
|
|
2160
|
+
const palette = resolveFlatPalette(spec.palette || "Clean Corporate", Math.max(n, 3));
|
|
2161
|
+
const titleCol = txt(spec, isDark ? "#f1f5f9" : "#0f172a");
|
|
2162
|
+
const labelCol = txt(spec, isDark ? "#e2e8f0" : "#334155");
|
|
2163
|
+
const rankCol = isDark ? "#64748b" : "#94a3b8";
|
|
2164
|
+
const track = isDark ? "#1e293b" : "#f1f5f9";
|
|
2165
|
+
const valCol = txt(spec, isDark ? "#e2e8f0" : "#334155");
|
|
2166
|
+
const faint = isDark ? "#475569" : "#94a3b8";
|
|
2167
|
+
const u = spec.valueUnit ? " " + spec.valueUnit : "";
|
|
2168
|
+
const PAD = 24, ROW_H = 40, RANKW = 30, LABELW = 140, VALW = 70;
|
|
2169
|
+
const topY = title ? 54 : 24;
|
|
2170
|
+
const W = spec.width || 560;
|
|
2171
|
+
const H = spec.height || topY + n * ROW_H + 8;
|
|
2172
|
+
const barLeft = PAD + RANKW + LABELW, barRight = W - PAD - VALW;
|
|
2173
|
+
const maxV = Math.max(1, ...items.map((s) => s.value));
|
|
2174
|
+
const p = [];
|
|
2175
|
+
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${esc(font)}">`);
|
|
2176
|
+
if (!transparent) p.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`);
|
|
2177
|
+
if (title) p.push(`<text x="${r(W / 2)}" y="33" text-anchor="middle" font-size="${fs + 5}" ${wAttr(spec, 700)} fill="${titleCol}">${esc(title)}</text>`);
|
|
2178
|
+
items.forEach((it, i) => {
|
|
2179
|
+
const cy = topY + i * ROW_H + ROW_H / 2;
|
|
2180
|
+
const fill = it.color || palette[i % palette.length];
|
|
2181
|
+
const bw = it.value / maxV * (barRight - barLeft);
|
|
2182
|
+
p.push(`<text x="${PAD}" y="${r(cy + 5)}" font-size="${fs + 1}" ${wAttr(spec, 800)} fill="${rankCol}">${i + 1}</text>`);
|
|
2183
|
+
p.push(`<text x="${r(PAD + RANKW)}" y="${r(cy + 5)}" font-size="${fs}" ${wAttr(spec, 600)} fill="${labelCol}">${esc(it.label)}</text>`);
|
|
2184
|
+
p.push(`<rect x="${r(barLeft)}" y="${r(cy - 8)}" width="${r(barRight - barLeft)}" height="16" rx="8" fill="${track}"/>`);
|
|
2185
|
+
if (bw > 0) p.push(`<rect x="${r(barLeft)}" y="${r(cy - 8)}" width="${r(Math.max(16, bw))}" height="16" rx="8" fill="${fill}"/>`);
|
|
2186
|
+
p.push(`<text x="${W - PAD}" y="${r(cy + 5)}" text-anchor="end" font-size="${fs}" ${wAttr(spec, 700)} fill="${valCol}">${esc(fmt(it.value) + u)}</text>`);
|
|
2187
|
+
});
|
|
2188
|
+
if (watermark) p.push(`<text x="${W - 10}" y="${H - 9}" text-anchor="end" font-size="9" fill="${faint}" opacity="0.6">slickfast.com</text>`);
|
|
2189
|
+
p.push(`</svg>`);
|
|
2190
|
+
return p.join("\n");
|
|
2191
|
+
}
|
|
2192
|
+
var TYPES = [
|
|
2193
|
+
{ type: "bar", family: "comparison", needsData: true, summary: "vertical bar chart" },
|
|
2194
|
+
{ type: "grouped", family: "comparison", needsData: true, summary: "clustered bars (one per series per category)" },
|
|
2195
|
+
{ type: "stacked", family: "composition", needsData: true, summary: "stacked bars (segments within each bar)" },
|
|
2196
|
+
{ type: "stacked100", family: "composition", needsData: true, summary: "100% stacked bars (each bar = 100%)" },
|
|
2197
|
+
{ type: "stackedh", family: "composition", needsData: true, summary: "horizontal stacked bars" },
|
|
2198
|
+
{ type: "horizontal", family: "comparison", needsData: true, summary: "horizontal bars (single series)" },
|
|
2199
|
+
{ type: "lollipop", family: "comparison", needsData: true, summary: "lollipop chart" },
|
|
2200
|
+
{ type: "diverging", family: "deviation", needsData: true, summary: "diverging bars around a zero line" },
|
|
2201
|
+
{ type: "line", family: "trend", needsData: true, summary: "line chart" },
|
|
2202
|
+
{ type: "smooth", family: "trend", needsData: true, summary: "smooth (curved) line" },
|
|
2203
|
+
{ type: "area", family: "trend", needsData: true, summary: "area chart (filled line)" },
|
|
2204
|
+
{ type: "stepped", family: "trend", needsData: true, summary: "stepped line" },
|
|
2205
|
+
{ type: "stackedArea", family: "trend", needsData: true, summary: "stacked area bands" },
|
|
2206
|
+
{ type: "difference", family: "deviation", needsData: true, summary: "two lines with the gap shaded" },
|
|
2207
|
+
{ type: "slope", family: "change", needsData: true, summary: "slopegraph (first vs last value)" },
|
|
2208
|
+
{ type: "pie", family: "composition", needsData: true, summary: "pie chart" },
|
|
2209
|
+
{ type: "donut", family: "composition", needsData: true, summary: "donut (pie with a center hole + total)" },
|
|
2210
|
+
{ type: "pieofpie", family: "composition", needsData: false, dataKey: "pies", summary: "nested drill-down pies (pie-of-pie)" },
|
|
2211
|
+
{ type: "kpi", family: "single-value", needsData: false, summary: "single metric tile (label + value + delta)" },
|
|
2212
|
+
{ type: "cards", family: "layout", needsData: false, dataKey: "cards", summary: "a row/grid of stat cards (multi-KPI strip)" },
|
|
2213
|
+
{ type: "layers", family: "layout", needsData: false, dataKey: "layers", summary: "labeled box-stack / layer diagram (e.g. a tech stack)" },
|
|
2214
|
+
{ type: "progress", family: "layout", needsData: false, dataKey: "bars", summary: "labeled progress / bullet bars toward a target" },
|
|
2215
|
+
{ type: "waffle", family: "layout", needsData: false, dataKey: "parts", summary: "waffle / dot grid (10\xD710, part-of-whole)" },
|
|
2216
|
+
{ type: "heatmap", family: "layout", needsData: false, summary: "heatmap grid (rows \xD7 columns, value \u2192 color)" },
|
|
2217
|
+
{ type: "funnel", family: "layout", needsData: false, dataKey: "stages", summary: "funnel \u2014 stages narrowing top\u2192bottom" },
|
|
2218
|
+
{ type: "pyramid", family: "layout", needsData: false, dataKey: "levels", summary: "pyramid \u2014 hierarchy levels" },
|
|
2219
|
+
{ type: "quadrant", family: "layout", needsData: false, dataKey: "items", summary: "2\xD72 matrix \u2014 items placed by two axes" },
|
|
2220
|
+
{ type: "timeline", family: "layout", needsData: false, dataKey: "events", summary: "linear timeline \u2014 events along one line" },
|
|
2221
|
+
{ type: "venn", family: "layout", needsData: false, dataKey: "sets", summary: "venn \u2014 2\u20133 overlapping sets with counts" },
|
|
2222
|
+
{ type: "matrix", family: "layout", needsData: false, dataKey: "rows", summary: "comparison / feature matrix (\u2713/\u2717/dot cells)" },
|
|
2223
|
+
{ type: "checklist", family: "layout", needsData: false, dataKey: "items", summary: "checklist / status list (done/pending/blocked)" },
|
|
2224
|
+
{ type: "iconarray", family: "layout", needsData: false, summary: "icon array / pictogram (N icons, M filled)" },
|
|
2225
|
+
{ type: "steps", family: "layout", needsData: false, dataKey: "steps", summary: "step / process row (numbered linear flow)" },
|
|
2226
|
+
{ type: "table", family: "layout", needsData: false, dataKey: "rows", summary: "data table (rows \xD7 columns of text / values)" },
|
|
2227
|
+
{ type: "gauge", family: "layout", needsData: false, summary: "radial gauge / dial (single value on a scale)" },
|
|
2228
|
+
{ type: "bullet", family: "layout", needsData: false, dataKey: "bars", summary: "bullet graph (measure vs target on banded scale)" },
|
|
2229
|
+
{ type: "calendar", family: "layout", needsData: false, dataKey: "days", summary: "calendar heatmap (year contribution grid)" },
|
|
2230
|
+
{ type: "leaderboard", family: "layout", needsData: false, dataKey: "items", summary: "leaderboard (ranked rows: rank + bar + value)" }
|
|
2231
|
+
];
|
|
2232
|
+
var TYPE_NAMES = TYPES.map((t) => t.type);
|
|
1273
2233
|
function renderSpec(spec) {
|
|
1274
2234
|
spec = applyPreset(spec);
|
|
1275
2235
|
switch (spec.type) {
|
|
@@ -1306,11 +2266,91 @@ function renderSpec(spec) {
|
|
|
1306
2266
|
return renderPieOfPie(spec);
|
|
1307
2267
|
case "kpi":
|
|
1308
2268
|
return renderKpi(spec);
|
|
2269
|
+
case "cards":
|
|
2270
|
+
return renderCards(spec);
|
|
2271
|
+
case "layers":
|
|
2272
|
+
return renderLayers(spec);
|
|
2273
|
+
case "progress":
|
|
2274
|
+
return renderProgress(spec);
|
|
2275
|
+
case "waffle":
|
|
2276
|
+
return renderWaffle(spec);
|
|
2277
|
+
case "heatmap":
|
|
2278
|
+
return renderHeatmap(spec);
|
|
2279
|
+
case "funnel":
|
|
2280
|
+
return renderFunnel(spec);
|
|
2281
|
+
case "pyramid":
|
|
2282
|
+
return renderPyramid(spec);
|
|
2283
|
+
case "quadrant":
|
|
2284
|
+
return renderQuadrant(spec);
|
|
2285
|
+
case "timeline":
|
|
2286
|
+
return renderTimeline(spec);
|
|
2287
|
+
case "venn":
|
|
2288
|
+
return renderVenn(spec);
|
|
2289
|
+
case "matrix":
|
|
2290
|
+
return renderMatrix(spec);
|
|
2291
|
+
case "checklist":
|
|
2292
|
+
return renderChecklist(spec);
|
|
2293
|
+
case "iconarray":
|
|
2294
|
+
return renderIconArray(spec);
|
|
2295
|
+
case "steps":
|
|
2296
|
+
return renderSteps(spec);
|
|
2297
|
+
case "table":
|
|
2298
|
+
return renderTable(spec);
|
|
2299
|
+
case "gauge":
|
|
2300
|
+
return renderGauge(spec);
|
|
2301
|
+
case "bullet":
|
|
2302
|
+
return renderBullet(spec);
|
|
2303
|
+
case "calendar":
|
|
2304
|
+
return renderCalendar(spec);
|
|
2305
|
+
case "leaderboard":
|
|
2306
|
+
return renderLeaderboard(spec);
|
|
1309
2307
|
default:
|
|
1310
2308
|
throw new Error('render-core: unknown chart type "' + spec.type + '"');
|
|
1311
2309
|
}
|
|
1312
2310
|
}
|
|
1313
2311
|
|
|
2312
|
+
// ../../packages/render-core/examples.mjs
|
|
2313
|
+
var EXAMPLES = {
|
|
2314
|
+
bar: { type: "bar", data: { labels: ["A", "B", "C"], series: [{ values: [10, 20, 15] }] } },
|
|
2315
|
+
grouped: { type: "grouped", data: { labels: ["Q1", "Q2"], series: [{ name: "2023", values: [42, 55] }, { name: "2024", values: [52, 48] }] } },
|
|
2316
|
+
stacked: { type: "stacked", data: { labels: ["Q1", "Q2"], series: [{ name: "Core", values: [30, 35] }, { name: "Add-ons", values: [18, 20] }] } },
|
|
2317
|
+
stacked100: { type: "stacked100", data: { labels: ["Q1", "Q2"], series: [{ name: "Core", values: [30, 35] }, { name: "Add-ons", values: [18, 20] }] } },
|
|
2318
|
+
stackedh: { type: "stackedh", data: { labels: ["Eng", "Sales"], series: [{ name: "Salaries", values: [120, 90] }, { name: "Tools", values: [40, 25] }] } },
|
|
2319
|
+
horizontal: { type: "horizontal", data: { labels: ["US", "India"], series: [{ values: [820, 610] }] } },
|
|
2320
|
+
lollipop: { type: "lollipop", data: { labels: ["A", "B", "C"], series: [{ values: [78, 64, 52] }] } },
|
|
2321
|
+
diverging: { type: "diverging", data: { labels: ["A", "B"], series: [{ name: "Agree", values: [62, 48] }, { name: "Disagree", values: [38, 52] }] } },
|
|
2322
|
+
line: { type: "line", data: { labels: ["Jan", "Feb", "Mar"], series: [{ name: "Users", values: [120, 190, 170] }] } },
|
|
2323
|
+
smooth: { type: "smooth", data: { labels: ["Jan", "Feb", "Mar"], series: [{ values: [120, 190, 170] }] } },
|
|
2324
|
+
area: { type: "area", data: { labels: ["Jan", "Feb", "Mar"], series: [{ values: [120, 190, 170] }] } },
|
|
2325
|
+
stepped: { type: "stepped", data: { labels: ["Jan", "Feb", "Mar"], series: [{ values: [120, 190, 170] }] } },
|
|
2326
|
+
stackedArea: { type: "stackedArea", data: { labels: ["Q1", "Q2"], series: [{ name: "A", values: [30, 35] }, { name: "B", values: [18, 20] }] } },
|
|
2327
|
+
difference: { type: "difference", data: { labels: ["Jan", "Feb", "Mar"], series: [{ name: "Plan", values: [50, 60, 70] }, { name: "Actual", values: [48, 65, 62] }] } },
|
|
2328
|
+
slope: { type: "slope", data: { labels: ["2023", "2024"], series: [{ name: "A", values: [40, 60] }, { name: "B", values: [55, 50] }] } },
|
|
2329
|
+
pie: { type: "pie", data: { labels: ["A", "B", "C"], series: [{ values: [45, 30, 25] }] } },
|
|
2330
|
+
donut: { type: "donut", data: { labels: ["A", "B", "C"], series: [{ values: [45, 30, 25] }] } },
|
|
2331
|
+
pieofpie: { type: "pieofpie", pies: [{ labels: ["Enterprise", "SMB", "Other"], values: [60, 30, 10] }, { labels: ["US", "EU", "APAC"], values: [35, 15, 10] }] },
|
|
2332
|
+
kpi: { type: "kpi", label: "MRR", value: 128400, valuePrefix: "$", delta: 12.4 },
|
|
2333
|
+
cards: { type: "cards", cards: [{ label: "MRR", value: 128400, valuePrefix: "$", delta: 12.4 }, { label: "Active users", value: 8210, delta: 3.1 }] },
|
|
2334
|
+
layers: { type: "layers", layers: [{ title: "Application", subtitle: "React + TypeScript" }, { title: "API", subtitle: "Node + Hono" }] },
|
|
2335
|
+
progress: { type: "progress", bars: [{ label: "Q1 revenue", value: 82, target: 100 }, { label: "Uptime", value: 99.2, valueUnit: "%" }] },
|
|
2336
|
+
waffle: { type: "waffle", parts: [{ label: "Enterprise", value: 45 }, { label: "SMB", value: 30 }, { label: "Other", value: 15 }] },
|
|
2337
|
+
heatmap: { type: "heatmap", rows: ["Mon", "Tue"], columns: ["9a", "12p", "3p"], values: [[2, 5, 8], [1, 4, 9]] },
|
|
2338
|
+
funnel: { type: "funnel", stages: [{ label: "Visitors", value: 12e3 }, { label: "Signups", value: 4200 }, { label: "Paid", value: 640 }] },
|
|
2339
|
+
pyramid: { type: "pyramid", levels: [{ title: "Vision" }, { title: "Strategy" }, { title: "Execution" }] },
|
|
2340
|
+
quadrant: { type: "quadrant", xAxis: "Effort", yAxis: "Impact", items: [{ label: "Quick win", x: 0.2, y: 0.8 }, { label: "Big bet", x: 0.8, y: 0.9 }] },
|
|
2341
|
+
timeline: { type: "timeline", events: [{ date: "Q1", label: "Launch" }, { date: "Q2", label: "Series A" }] },
|
|
2342
|
+
venn: { type: "venn", sets: [{ label: "Design", value: 120 }, { label: "Engineering", value: 160 }], overlap: 40 },
|
|
2343
|
+
matrix: { type: "matrix", columns: ["Free", "Pro"], rows: [{ label: "SSO", cells: [false, true] }, { label: "API access", cells: [true, true] }] },
|
|
2344
|
+
checklist: { type: "checklist", items: [{ label: "Domain transferred", status: "done" }, { label: "Billing live", status: "pending" }] },
|
|
2345
|
+
iconarray: { type: "iconarray", total: 10, filled: 7 },
|
|
2346
|
+
steps: { type: "steps", steps: [{ label: "Sign up" }, { label: "Connect data" }, { label: "Share" }] },
|
|
2347
|
+
table: { type: "table", columns: ["Region", "Q1", "Q2"], rows: [["North", 420, 510], ["South", 310, 290]] },
|
|
2348
|
+
gauge: { type: "gauge", label: "CPU load", value: 72, valueUnit: "%" },
|
|
2349
|
+
bullet: { type: "bullet", bars: [{ label: "Revenue", value: 275, target: 250, max: 300, bands: [150, 225] }] },
|
|
2350
|
+
calendar: { type: "calendar", year: 2025, days: { "2025-01-06": 3, "2025-07-21": 8 } },
|
|
2351
|
+
leaderboard: { type: "leaderboard", items: [{ label: "North", value: 530 }, { label: "East", value: 610 }] }
|
|
2352
|
+
};
|
|
2353
|
+
|
|
1314
2354
|
// ../../packages/raster/raster.mjs
|
|
1315
2355
|
import { Resvg } from "@resvg/resvg-js";
|
|
1316
2356
|
function svgToPng(svg, opts = {}) {
|
|
@@ -1320,8 +2360,9 @@ function svgToPng(svg, opts = {}) {
|
|
|
1320
2360
|
}
|
|
1321
2361
|
|
|
1322
2362
|
// server.mjs
|
|
2363
|
+
var NO_DATA_TYPES = TYPES.filter((t) => !t.needsData).map((t) => t.type);
|
|
1323
2364
|
var here = dirname(fileURLToPath(import.meta.url));
|
|
1324
|
-
var CHART_SPEC_MD = true ? '# ChartSpec \u2014 the render-core contract (the API/MCP input)\n\nOne JSON object (a **spec**) goes in; one **SVG string** comes out, via\n`renderSpec(spec)`. This document is the contract an agent (or the MCP tool) follows\nto produce a chart. It is written agent-first: **a spec with only `type` + `data`\nrenders a complete, good-looking chart** \u2014 every other field is an optional override.\n\n```js\nimport { renderSpec } from \'./render-core.mjs\';\nconst svg = renderSpec({ type: \'bar\', data: { labels: [\'A\',\'B\',\'C\'], series: [{ values: [10, 20, 15] }] } });\n```\n\n## When to use this \u2014 and when to fall back\n\n**Prefer `render_chart` for any supported type** (see "Chart types" below) over\nhand-writing plotting code (matplotlib, plotly, chart.js). It\'s faster, deterministic,\nand good-looking with just `{type, data}`. Pass `outputPath` when the user wants the\nfile saved to disk.\n\n**Fall back to your own plotting only for what this engine does not do yet:**\n- **Chart types not listed below** \u2014 scatter, bubble, heatmap, treemap, radar, sankey,\n geographic maps, candlestick, gantt.\n- **Reference / target / threshold lines, log scales, secondary (dual) axes, and point\n annotations / callouts** \u2014 on the roadmap, not available today.\n- **Interactivity, animation, hover tooltips** \u2014 output is a static SVG/PNG.\n\n## Universal fields (every chart type honors these)\n\n| field | type | default | what it does |\n|---|---|---|---|\n| `type` | string | \u2014 (required) | which chart: `"bar"`, `"line"`, `"kpi"` (more coming) |\n| `title` | string | `""` | heading drawn at the top |\n| `width` | number | per type | SVG width in px |\n| `height` | number | per type | SVG height in px |\n| `preset` / `ratio` | string | \u2014 | aspect-ratio preset \u2192 sets width/height (explicit `width`/`height` win). `Share Card` 1.91:1 (1200\xD7630, link/OG cards), `Wide` 16:9 (1280\xD7720), `Square` 1:1 (1080\xD71080), `Portrait` 4:5 (1080\xD71350), `Tall` 9:16 (1080\xD71920), `Classic` 4:3 (1200\xD7900). `ratio` also accepts the bare ratio string (`"16:9"`, `"9:16"`, \u2026) |\n| `background` | string | `"#ffffff"` | any hex color, or `"transparent"` for no fill (overlays/exports) |\n| `palette` | string | `"Clean Corporate"` | named palette: `Clean Corporate`, `Pastel`, `Vibrant`, `Monochrome`, `Cyberpunk`, `Analogous Shift`. Pick **`Monochrome`** when the chart will be **printed in black & white / on a laser printer** \u2014 it\'s a single-hue grey ramp that stays legible without color (other palettes can render as indistinct greys when printed). |\n| `font` | string | `"Inter"` | a named font: `Inter`, `System`, `Serif`, `Mono`, `Rounded`, `Condensed` |\n| `fontFamily` | string | \u2014 | a raw CSS font stack (overrides `font`; full control / future custom fonts) |\n| `fontSize` | number | `13` | base text size; other text scales from it |\n| `textColor` | string | auto | force a color for the **neutral** text (title, axis, category, value, legend, pie slice labels). Omit it and the engine auto-picks black/white for contrast. Semantic colors (green \u25B2 / red \u25BC deltas & gaps) stay meaningful. |\n| `bold` | boolean | `false` | thicken every text element |\n| `fontWeight` | string | \u2014 | exact weight for all text (`"400"`\u2013`"900"` or `"bold"`); overrides `bold` |\n| `valueUnit` | string | `""` | appended to values, e.g. `"$"`, `"%"`, `"ms"` |\n| `showValues` | boolean | `true` | draw the numeric labels |\n| `watermark` | boolean | `true` | tasteful "slickfast.com" mark. **Set `false` for the chart sites / paid output.** |\n\n**Readability is automatic:** text colors are chosen from the background\'s luminance\n(via palette-core), so labels stay legible on light *or* dark backgrounds.\n\n## Data shape\n\n```json\n"data": {\n "labels": ["North", "South", "East"],\n "series": [\n { "name": "Revenue", "values": [420, 310, 530], "colors": ["#2563eb", "#7c3aed", "#059669"] }\n ]\n}\n```\n\n- `labels` \u2014 the categories (x-axis).\n- `series[].values` \u2014 the numbers, aligned to `labels`.\n- `series[].colors` \u2014 **optional** explicit per-item colors. If omitted, colors come\n from `palette`. (Used when a user has customized individual bars.)\n- `series[].name` \u2014 optional label for the series.\n\n## Chart types\n\n### `bar` \u2014 vertical bar chart\nDefault size `800 \xD7 450`. Uses `data.series[0]`. Each bar a palette color (or its\nexplicit `colors[i]`), top-rounded, with value + category labels and "nice" axis numbers.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `showTotal` | boolean | `true` | "Total: N" badge, top-right |\n\nMinimal: `{ "type": "bar", "data": { "labels": ["A","B"], "series": [{ "values": [10,20] }] } }`\n\n### `grouped` \u2014 clustered bar chart (one bar per series in each category)\nDefault size `800 \xD7 450`. The `data.labels` are the **groups** (categories); each\n`data.series` entry is a bar that repeats inside every group. **Color = series\nidentity:** one solid palette color per series (A=palette[0], B=palette[mid], extras\nspread), the same color in every group \u2014 never rainbow-within-a-series. A centered\nlegend names the series. Best read with \u2264 4 series.\n\nMinimal: `{ "type": "grouped", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"2023","values":[42,55] }, { "name":"2024","values":[52,48] }] } }`\n\n### `stacked` \u2014 stacked bar chart (segments stack within each category)\nDefault size `800 \xD7 450`. Each `data.labels` entry is a bar; each `data.series` entry\nis a **segment** stacked inside every bar, with a distinct palette color used\nconsistently across all bars. Inside each segment, two labels: the **name pinned to the\ntop**, the **value to the bottom** (contrast-aware text \u2014 dark on light fills, white on\ndark). Thin segments hide the name but keep the value. Only the top segment\'s outer edge\nis rounded; joins stay flush. A centered legend names the segments.\n\nMinimal: `{ "type": "stacked", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stacked100` \u2014 100% stacked bar (each bar normalized to 100%)\nSame as `stacked`, but every bar fills the full height and each segment shows its\n**share of that bar** as an integer percent. Percentages use largest-remainder\nrounding so every bar sums to **exactly 100** (never 99/101). Best for comparing\n*mix* across categories rather than absolute totals.\n\nMinimal: `{ "type": "stacked100", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stackedh` \u2014 horizontal stacked bar\nSame data model as `stacked`, transposed: bars run left\u2192right, one row per\n`data.labels` entry, segments stack horizontally. The last segment rounds its right\nedge; joins stay flush. Segment name + value sit centered in each segment (value-only\nwhen narrow); a centered legend names the segments. Good when category labels are long.\n\nMinimal: `{ "type": "stackedh", "data": { "labels": ["Eng","Sales"], "series": [{ "name":"Salaries","values":[120,90] }, { "name":"Tools","values":[40,25] }] } }`\n\n### `horizontal` \u2014 horizontal bar (single series)\nDefault size `800 \xD7 450`. One row per `data.labels` entry; each bar its own palette\ncolor by index (or its explicit `colors[i]`), value at the bar end, "Total: N" badge\ntop-right. Best when labels are long or there are many categories.\n\nMinimal: `{ "type": "horizontal", "data": { "labels": ["US","India"], "series": [{ "values": [820,610] }] } }`\n\n### `diverging` \u2014 diverging bar (around a zero line)\nDefault size `800 \xD7 450`. **Two modes, chosen by series count:**\n- **Two series** \u2192 opposing bars: **Series A forced up, Series B forced down** (both\n abs-valued), one solid color each (A=palette[0], B=palette[mid]); a centered legend\n names both. Great for sentiment (agree/disagree), inflow/outflow.\n- **One series** \u2192 classic **signed** diverging: each value plotted by its own sign\n (**positive up, negative down**), a single color, a single-name legend, and signed\n value labels (e.g. `-3` below the zero line).\n\nThe zero line is emphasized; down-bar labels sit below their bars.\n\nMinimal (two-series): `{ "type": "diverging", "data": { "labels": ["A","B"], "series": [{ "name":"Agree","values":[62,48] }, { "name":"Disagree","values":[38,52] }] } }`\n\nMinimal (signed): `{ "type": "diverging", "data": { "labels": ["Q1","Q2","Q3"], "series": [{ "name":"Net flow","values":[5,-3,8] }] } }`\n\n### `lollipop` \u2014 lollipop chart (single series)\nDefault size `800 \xD7 450`. One stem + dot per `data.labels` entry, each its own palette\ncolor by index (or explicit `colors[i]`), value above the dot, category below, "Total: N"\nbadge top-right. A lighter-weight alternative to `bar` when the bars would feel heavy.\n\nMinimal: `{ "type": "lollipop", "data": { "labels": ["A","B","C"], "series": [{ "values": [78,64,52] }] } }`\n\n### `line` \u2014 line chart (single or multi-series)\nDefault size `800 \xD7 450`. One line per `data.series` entry, each a palette color\n(or its own `color`), with points and a centered legend when there\'s more than one\nseries. "Nice" axis numbers; points span edge-to-edge.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `curve` | string | `"straight"` | line shape: `"straight"`, `"smooth"` (curved), `"stepped"` |\n| `area` | boolean | `false` | fill the region under each line (translucent) |\n| `showPoints` | boolean | `true` | dots at each data point |\n| `showValues` | boolean | `false` | numbers above each point (off by default \u2014 lines get crowded) |\n| series `color` | string | palette | per-line color override |\n\n**Type shortcuts:** `type: "smooth"`, `"area"`, `"stepped"` map to `line` with the\nmatching option set \u2014 `{type:"area"}` \u2261 `{type:"line", area:true}`. Also:\n- `type: "stackedArea"` \u2014 every series stacks cumulatively, filled bands + legend.\n- `type: "difference"` \u2014 exactly two series; shades the gap between them and labels\n the gap value at each point (green where series 1 leads, red where it trails).\n- `type: "slope"` \u2014 a slopegraph: each series\' first vs last value, connected by a\n straight line, with the end value + change labeled (green up / red down).\n\nMinimal: `{ "type": "line", "data": { "labels": ["Jan","Feb","Mar"], "series": [{ "name": "Users", "values": [120,190,170] }] } }`\n\n### `pie` / `donut` \u2014 proportion of a whole\nDefault size `640 \xD7 420`. One series; each label+value is a slice, colored from the\npalette (or explicit `colors`). Percentage labels sit inside slices \u2265 6%; a legend\n(label \xB7 value) runs down the right. `donut` adds a center hole with the **total** in\nthe middle.\n\nMinimal: `{ "type": "pie", "data": { "labels": ["A","B","C"], "series": [{ "values": [45,30,25] }] } }`\n\n### `pieofpie` \u2014 nested drill-down pies (pie-of-pie, pie-of-pie-of-pie, N-tier)\nDefault size `920 \xD7 460`. A row of pies where each pie\'s **first slice (the "bridge")**\nexplodes toward the next pie and breaks down into it, joined by two connector lines.\n2 pies = pie-of-pie, 3 = pie-of-pie-of-pie, N supported; each pie is 75% the size of the\none before. Donut by default. Each pie gets a **`name \xB7 value` legend beneath it**\n(honoring `valuePrefix`/`valueUnit`) so slices are identifiable; the slices themselves\nkeep their `%` labels.\n\nUses **`pies`** (not `data`): an array of `{ title?, labels[], values[], colors?, palette? }`.\nSlice index 0 of each pie is the bridge into the next pie.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `pies` | array | \u2014 (required) | the pies; pie[i] slice 0 drills into pie[i+1] |\n| `donut` | boolean | `true` | center hole on each pie (`false` = solid pies) |\n| `cascade` | boolean | `true` | child pies shade from the parent bridge hue; `false` = flat palette per pie |\n| `palette` | string | `"Clean Corporate"` | a **nested theme** (below) \u2014 drives the whole cascade |\n\n**Nested themes** (premium color cascades, set via `palette`):\n- *Inspired tiers* (hand-tuned per-pie triads): `Analogous Shift` (showcase \u2014 a spectrum\n walk), `Retro Editorial`, `Classic Triadic`, `Sorbet Pastel`.\n- *Generative* (children derived from the root hue): `Modern Corporate`, `Nordic Earth`,\n `Cyberpunk Glow`, `Sequential Rainbow`, plus classics `Clean Corporate`, `Pastel`,\n `Vibrant`, `Monochrome`. (Authoring new ones: `palette-core/AUTHORING-PALETTES.md`.)\n\nMinimal: `{ "type": "pieofpie", "palette": "Analogous Shift", "pies": [ { "labels": ["Enterprise","SMB","Other"], "values": [60,30,10] }, { "labels": ["US","EU","APAC"], "values": [35,15,10] } ] }`\n\n### `kpi` \u2014 single metric tile (the exec-snapshot building block)\nDefault size `340 \xD7 180`. A card: label + big value + colored delta pill + palette accent.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `label` | string | `""` | the metric name (top) |\n| `value` | number | \u2014 | the big number |\n| `valuePrefix` | string | `""` | e.g. `"$"` before the value |\n| `delta` | number | none | the change; renders a pill \u2014 \u25B2 if positive, \u25BC if negative |\n| `deltaUnit` | string | `"%"` | unit on the delta |\n| `deltaGoodWhen` | string | `"up"` | which direction is GOOD (green). Set `"down"` for lower-is-better metrics (churn, latency, cost) \u2014 the arrow still shows real direction, only the color flips |\n| `sparkline` | number[] | none | a minimalist trend line along the bottom (e.g. the last N periods) \u2014 no axes/labels, colored to match the delta (green good / red bad). Needs \u2265 2 points; **landscape tile only**. Omit it and the tile is byte-identical. |\n\nMinimal: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }`\n\nWith a sparkline: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4, "sparkline": [98,104,101,112,118,121,128] }`\n\n## What an agent can change (the levers)\n\n- **Fonts** \u2192 `fontFamily`, `fontSize`\n- **Colors** \u2192 `palette` (a named set) for the whole chart, or `data.series[].colors` for exact per-item control\n- **Background** \u2192 `background` (any hex, or `"transparent"`)\n- **Size / aspect** \u2192 `width`, `height`\n- **Branding** \u2192 `watermark` (off for the sites, on for free-tier API output)\n\n## Rules for the engine (and any agent generating specs)\n\n- **Deterministic:** the same spec always produces the exact same SVG bytes. No\n randomness, no time. Safe to cache and snapshot-test.\n- **Defaults are good:** never require a field that has a sensible default. Emit the\n smallest spec that expresses the intent; the engine makes it beautiful.\n- **Unknown `type` is an error** \u2014 only documented types render.\n\n## Roadmap\nMore chart types (line, area, stacked, pie, \u2026) register in `renderSpec`; each inherits\nevery universal field above and gets its own row here. This document becomes a formal\nJSON Schema when the MCP server is built, so the `render_chart` tool can validate specs\nand an LLM can read the field contract directly.\n' : readFileSync(join(here, "../../packages/render-core/SPEC.md"), "utf8");
|
|
2365
|
+
var CHART_SPEC_MD = true ? '# ChartSpec \u2014 the render-core contract (the API/MCP input)\n\nOne JSON object (a **spec**) goes in; one **SVG string** comes out, via\n`renderSpec(spec)`. This document is the contract an agent (or the MCP tool) follows\nto produce a chart. It is written agent-first: **a spec with only `type` + `data`\nrenders a complete, good-looking chart** \u2014 every other field is an optional override.\n\n```js\nimport { renderSpec } from \'./render-core.mjs\';\nconst svg = renderSpec({ type: \'bar\', data: { labels: [\'A\',\'B\',\'C\'], series: [{ values: [10, 20, 15] }] } });\n```\n\n## When to use this \u2014 and when to fall back\n\n**Prefer `render_chart` for any supported type** (see "Chart types" below) over\nhand-writing plotting code (matplotlib, plotly, chart.js). It\'s faster, deterministic,\nand good-looking with just `{type, data}`. Pass `outputPath` when the user wants the\nfile saved to disk.\n\n**Fall back to your own plotting only for what this engine does not do yet:**\n- **Chart types not listed below** \u2014 scatter, bubble, heatmap, treemap, radar, sankey,\n geographic maps, candlestick, gantt.\n- **Reference / target / threshold lines, log scales, secondary (dual) axes, and point\n annotations / callouts** \u2014 on the roadmap, not available today.\n- **Interactivity, animation, hover tooltips** \u2014 output is a static SVG/PNG.\n\n## Universal fields (every chart type honors these)\n\n| field | type | default | what it does |\n|---|---|---|---|\n| `type` | string | \u2014 (required) | which chart: `"bar"`, `"line"`, `"kpi"` (more coming) |\n| `title` | string | `""` | heading drawn at the top |\n| `width` | number | per type | SVG width in px |\n| `height` | number | per type | SVG height in px |\n| `preset` / `ratio` | string | \u2014 | aspect-ratio preset \u2192 sets width/height (explicit `width`/`height` win). `Share Card` 1.91:1 (1200\xD7630, link/OG cards), `Wide` 16:9 (1280\xD7720), `Square` 1:1 (1080\xD71080), `Portrait` 4:5 (1080\xD71350), `Tall` 9:16 (1080\xD71920), `Classic` 4:3 (1200\xD7900). `ratio` also accepts the bare ratio string (`"16:9"`, `"9:16"`, \u2026) |\n| `background` | string | `"#ffffff"` | any hex color, or `"transparent"` for no fill (overlays/exports) |\n| `palette` | string | `"Clean Corporate"` | named palette: `Clean Corporate`, `Pastel`, `Vibrant`, `Monochrome`, `Cyberpunk`, `Analogous Shift`. Pick **`Monochrome`** when the chart will be **printed in black & white / on a laser printer** \u2014 it\'s a single-hue grey ramp that stays legible without color (other palettes can render as indistinct greys when printed). |\n| `font` | string | `"Inter"` | a named font: `Inter`, `System`, `Serif`, `Mono`, `Rounded`, `Condensed` |\n| `fontFamily` | string | \u2014 | a raw CSS font stack (overrides `font`; full control / future custom fonts) |\n| `fontSize` | number | `13` | base text size; other text scales from it |\n| `textColor` | string | auto | force a color for the **neutral** text (title, axis, category, value, legend, pie slice labels). Omit it and the engine auto-picks black/white for contrast. Semantic colors (green \u25B2 / red \u25BC deltas & gaps) stay meaningful. |\n| `bold` | boolean | `false` | thicken every text element |\n| `fontWeight` | string | \u2014 | exact weight for all text (`"400"`\u2013`"900"` or `"bold"`); overrides `bold` |\n| `valueUnit` | string | `""` | appended to values, e.g. `"$"`, `"%"`, `"ms"` |\n| `showValues` | boolean | `true` | draw the numeric labels |\n| `watermark` | boolean | `true` | tasteful "slickfast.com" mark. **Set `false` for the chart sites / paid output.** |\n\n**Readability is automatic:** text colors are chosen from the background\'s luminance\n(via palette-core), so labels stay legible on light *or* dark backgrounds.\n\n## Data shape\n\n```json\n"data": {\n "labels": ["North", "South", "East"],\n "series": [\n { "name": "Revenue", "values": [420, 310, 530], "colors": ["#2563eb", "#7c3aed", "#059669"] }\n ]\n}\n```\n\n- `labels` \u2014 the categories (x-axis).\n- `series[].values` \u2014 the numbers, aligned to `labels`.\n- `series[].colors` \u2014 **optional** explicit per-item colors. If omitted, colors come\n from `palette`. (Used when a user has customized individual bars.)\n- `series[].name` \u2014 optional label for the series.\n\n## Chart types\n\n### `bar` \u2014 vertical bar chart\nDefault size `800 \xD7 450`. Uses `data.series[0]`. Each bar a palette color (or its\nexplicit `colors[i]`), top-rounded, with value + category labels and "nice" axis numbers.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `showTotal` | boolean | `true` | "Total: N" badge, top-right |\n\nMinimal: `{ "type": "bar", "data": { "labels": ["A","B"], "series": [{ "values": [10,20] }] } }`\n\n### `grouped` \u2014 clustered bar chart (one bar per series in each category)\nDefault size `800 \xD7 450`. The `data.labels` are the **groups** (categories); each\n`data.series` entry is a bar that repeats inside every group. **Color = series\nidentity:** one solid palette color per series (A=palette[0], B=palette[mid], extras\nspread), the same color in every group \u2014 never rainbow-within-a-series. A centered\nlegend names the series. Best read with \u2264 4 series.\n\nMinimal: `{ "type": "grouped", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"2023","values":[42,55] }, { "name":"2024","values":[52,48] }] } }`\n\n### `stacked` \u2014 stacked bar chart (segments stack within each category)\nDefault size `800 \xD7 450`. Each `data.labels` entry is a bar; each `data.series` entry\nis a **segment** stacked inside every bar, with a distinct palette color used\nconsistently across all bars. Inside each segment, two labels: the **name pinned to the\ntop**, the **value to the bottom** (contrast-aware text \u2014 dark on light fills, white on\ndark). Thin segments hide the name but keep the value. Only the top segment\'s outer edge\nis rounded; joins stay flush. A centered legend names the segments.\n\nMinimal: `{ "type": "stacked", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stacked100` \u2014 100% stacked bar (each bar normalized to 100%)\nSame as `stacked`, but every bar fills the full height and each segment shows its\n**share of that bar** as an integer percent. Percentages use largest-remainder\nrounding so every bar sums to **exactly 100** (never 99/101). Best for comparing\n*mix* across categories rather than absolute totals.\n\nMinimal: `{ "type": "stacked100", "data": { "labels": ["Q1","Q2"], "series": [{ "name":"Core","values":[30,35] }, { "name":"Add-ons","values":[18,20] }] } }`\n\n### `stackedh` \u2014 horizontal stacked bar\nSame data model as `stacked`, transposed: bars run left\u2192right, one row per\n`data.labels` entry, segments stack horizontally. The last segment rounds its right\nedge; joins stay flush. Segment name + value sit centered in each segment (value-only\nwhen narrow); a centered legend names the segments. Good when category labels are long.\n\nMinimal: `{ "type": "stackedh", "data": { "labels": ["Eng","Sales"], "series": [{ "name":"Salaries","values":[120,90] }, { "name":"Tools","values":[40,25] }] } }`\n\n### `horizontal` \u2014 horizontal bar (single series)\nDefault size `800 \xD7 450`. One row per `data.labels` entry; each bar its own palette\ncolor by index (or its explicit `colors[i]`), value at the bar end, "Total: N" badge\ntop-right. Best when labels are long or there are many categories.\n\nMinimal: `{ "type": "horizontal", "data": { "labels": ["US","India"], "series": [{ "values": [820,610] }] } }`\n\n### `diverging` \u2014 diverging bar (around a zero line)\nDefault size `800 \xD7 450`. **Two modes, chosen by series count:**\n- **Two series** \u2192 opposing bars: **Series A forced up, Series B forced down** (both\n abs-valued), one solid color each (A=palette[0], B=palette[mid]); a centered legend\n names both. Great for sentiment (agree/disagree), inflow/outflow.\n- **One series** \u2192 classic **signed** diverging: each value plotted by its own sign\n (**positive up, negative down**), a single color, a single-name legend, and signed\n value labels (e.g. `-3` below the zero line).\n\nThe zero line is emphasized; down-bar labels sit below their bars.\n\nMinimal (two-series): `{ "type": "diverging", "data": { "labels": ["A","B"], "series": [{ "name":"Agree","values":[62,48] }, { "name":"Disagree","values":[38,52] }] } }`\n\nMinimal (signed): `{ "type": "diverging", "data": { "labels": ["Q1","Q2","Q3"], "series": [{ "name":"Net flow","values":[5,-3,8] }] } }`\n\n### `lollipop` \u2014 lollipop chart (single series)\nDefault size `800 \xD7 450`. One stem + dot per `data.labels` entry, each its own palette\ncolor by index (or explicit `colors[i]`), value above the dot, category below, "Total: N"\nbadge top-right. A lighter-weight alternative to `bar` when the bars would feel heavy.\n\nMinimal: `{ "type": "lollipop", "data": { "labels": ["A","B","C"], "series": [{ "values": [78,64,52] }] } }`\n\n### `line` \u2014 line chart (single or multi-series)\nDefault size `800 \xD7 450`. One line per `data.series` entry, each a palette color\n(or its own `color`), with points and a centered legend when there\'s more than one\nseries. "Nice" axis numbers; points span edge-to-edge.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `curve` | string | `"straight"` | line shape: `"straight"`, `"smooth"` (curved), `"stepped"` |\n| `area` | boolean | `false` | fill the region under each line (translucent) |\n| `showPoints` | boolean | `true` | dots at each data point |\n| `showValues` | boolean | `false` | numbers above each point (off by default \u2014 lines get crowded) |\n| series `color` | string | palette | per-line color override |\n\n**Type shortcuts:** `type: "smooth"`, `"area"`, `"stepped"` map to `line` with the\nmatching option set \u2014 `{type:"area"}` \u2261 `{type:"line", area:true}`. Also:\n- `type: "stackedArea"` \u2014 every series stacks cumulatively, filled bands + legend.\n- `type: "difference"` \u2014 exactly two series; shades the gap between them and labels\n the gap value at each point (green where series 1 leads, red where it trails).\n- `type: "slope"` \u2014 a slopegraph: each series\' first vs last value, connected by a\n straight line, with the end value + change labeled (green up / red down).\n\nMinimal: `{ "type": "line", "data": { "labels": ["Jan","Feb","Mar"], "series": [{ "name": "Users", "values": [120,190,170] }] } }`\n\n### `pie` / `donut` \u2014 proportion of a whole\nDefault size `640 \xD7 420`. One series; each label+value is a slice, colored from the\npalette (or explicit `colors`). Percentage labels sit inside slices \u2265 6%; a legend\n(label \xB7 value) runs down the right. `donut` adds a center hole with the **total** in\nthe middle.\n\nMinimal: `{ "type": "pie", "data": { "labels": ["A","B","C"], "series": [{ "values": [45,30,25] }] } }`\n\n### `pieofpie` \u2014 nested drill-down pies (pie-of-pie, pie-of-pie-of-pie, N-tier)\nDefault size `920 \xD7 460`. A row of pies where each pie\'s **first slice (the "bridge")**\nexplodes toward the next pie and breaks down into it, joined by two connector lines.\n2 pies = pie-of-pie, 3 = pie-of-pie-of-pie, N supported; each pie is 75% the size of the\none before. Donut by default. Each pie gets a **`name \xB7 value` legend beneath it**\n(honoring `valuePrefix`/`valueUnit`) so slices are identifiable; the slices themselves\nkeep their `%` labels.\n\nUses **`pies`** (not `data`): an array of `{ title?, labels[], values[], colors?, palette? }`.\nSlice index 0 of each pie is the bridge into the next pie.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `pies` | array | \u2014 (required) | the pies; pie[i] slice 0 drills into pie[i+1] |\n| `donut` | boolean | `true` | center hole on each pie (`false` = solid pies) |\n| `cascade` | boolean | `true` | child pies shade from the parent bridge hue; `false` = flat palette per pie |\n| `palette` | string | `"Clean Corporate"` | a **nested theme** (below) \u2014 drives the whole cascade |\n\n**Nested themes** (premium color cascades, set via `palette`):\n- *Inspired tiers* (hand-tuned per-pie triads): `Analogous Shift` (showcase \u2014 a spectrum\n walk), `Retro Editorial`, `Classic Triadic`, `Sorbet Pastel`.\n- *Generative* (children derived from the root hue): `Modern Corporate`, `Nordic Earth`,\n `Cyberpunk Glow`, `Sequential Rainbow`, plus classics `Clean Corporate`, `Pastel`,\n `Vibrant`, `Monochrome`. (Authoring new ones: `palette-core/AUTHORING-PALETTES.md`.)\n\nMinimal: `{ "type": "pieofpie", "palette": "Analogous Shift", "pies": [ { "labels": ["Enterprise","SMB","Other"], "values": [60,30,10] }, { "labels": ["US","EU","APAC"], "values": [35,15,10] } ] }`\n\n### `kpi` \u2014 single metric tile (the exec-snapshot building block)\nDefault size `340 \xD7 180`. A card: label + big value + colored delta pill + palette accent.\n\n| extra field | type | default | does |\n|---|---|---|---|\n| `label` | string | `""` | the metric name (top) |\n| `value` | number | \u2014 | the big number |\n| `valuePrefix` | string | `""` | e.g. `"$"` before the value |\n| `delta` | number | none | the change; renders a pill \u2014 \u25B2 if positive, \u25BC if negative |\n| `deltaUnit` | string | `"%"` | unit on the delta |\n| `deltaGoodWhen` | string | `"up"` | which direction is GOOD (green). Set `"down"` for lower-is-better metrics (churn, latency, cost) \u2014 the arrow still shows real direction, only the color flips |\n| `sparkline` | number[] | none | a minimalist trend line along the bottom (e.g. the last N periods) \u2014 no axes/labels, colored to match the delta (green good / red bad). Needs \u2265 2 points; **landscape tile only**. Omit it and the tile is byte-identical. |\n\nMinimal: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }`\n\nWith a sparkline: `{ "type": "kpi", "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4, "sparkline": [98,104,101,112,118,121,128] }`\n\n### `cards` \u2014 a row/grid of stat cards (the multi-KPI dashboard strip)\nA set of KPI tiles in one image \u2014 for an exec snapshot of several metrics at once.\nUses its own **`cards`** array (not `data`): each entry is one card, reusing the **same\nfields as `kpi`**. Each card\'s accent cycles the palette by index; the delta pill follows\nthe same good/bad coloring (\u25B2/\u25BC shows real direction, color shows good-or-bad). Layout is a\n**bounded computed grid**: by default a single horizontal strip, wrapping to a grid once there\nare more than 4 cards. Size auto-fits the grid unless you set `width`/`height`.\n\n| field | type | default | does |\n|---|---|---|---|\n| `cards` | array | \u2014 (required) | the tiles; each: `label`, `value`, `valuePrefix`, `valueUnit`, `delta`, `deltaUnit`, `deltaGoodWhen` (same meaning as `kpi`) |\n| `gridColumns` | number | min(cards, 4) | force the number of columns; cards wrap into rows |\n\nMinimal: `{ "type": "cards", "cards": [ { "label": "MRR", "value": 128400, "valuePrefix": "$", "delta": 12.4 }, { "label": "Active users", "value": 8210, "delta": 3.1 }, { "label": "Churn", "value": 2.4, "valueUnit": "%", "delta": 0.6, "deltaGoodWhen": "down" } ] }`\n\n### `layers` \u2014 labeled box-stack / layer diagram\nA vertical stack of labeled blocks \u2014 a structure/architecture picture (e.g. a tech\nstack, a hierarchy of tiers), **not** a stacked bar (no values, no axis). Uses its own\n**`layers`** array (not `data`): each entry is `{ title, subtitle? }`, drawn top\u2192bottom\nas an equal-height rounded block. Each block fills a distinct palette color, cycling the\npalette; the title (and optional subtitle) sit centered with **contrast-aware text**\n(dark on light fills, white on dark). Inherits every universal field.\n\n| field | type | default | does |\n|---|---|---|---|\n| `layers` | array | \u2014 (required) | the blocks, top to bottom; each: `title`, optional `subtitle` |\n\nMinimal: `{ "type": "layers", "layers": [ { "title": "Application", "subtitle": "React + TypeScript" }, { "title": "API", "subtitle": "Node + Hono" }, { "title": "Engine", "subtitle": "render-core (pure SVG)" }, { "title": "Infra", "subtitle": "Railway + Docker" } ] }`\n\n### `progress` \u2014 labeled progress / bullet bars\nLabeled horizontal bars filling toward a target \u2014 for "how far along" readouts. Uses\nits own **`bars`** array: each `{ label, value, target?, valueUnit? }`. The track end IS\nthe target, so the fill = `value / target`; the label shows `value / target`. With **no\n`target`**, `value` is read as a percent (0\u2013100) and the track end is 100%. Each bar a\npalette color by index. Inherits every universal field.\n\n| field | type | default | does |\n|---|---|---|---|\n| `bars` | array | \u2014 (required) | the bars; each: `label`, `value`, optional `target`, optional `valueUnit` |\n\nMinimal: `{ "type": "progress", "bars": [ { "label": "Q1 revenue", "value": 82, "target": 100 }, { "label": "Signups", "value": 1240, "target": 2000 }, { "label": "Uptime", "value": 99.2, "valueUnit": "%" } ] }`\n\n### `waffle` \u2014 waffle / dot grid\nA 10\xD710 grid of cells showing part-of-whole \u2014 friendlier than a pie for "% complete" or\na simple mix. Uses its own **`parts`** array `{ label, value, color? }`: parts fill the\n100 cells proportionally (largest-remainder rounding keeps the count exact). If the parts\nsum to **less than 100**, the remainder are **empty track cells** \u2014 so **one part** is a\n"% filled" gauge, and **several parts** is categorical part-to-whole. A `label \xB7 value`\nlegend sits beside the grid.\n\n| field | type | default | does |\n|---|---|---|---|\n| `parts` | array | \u2014 (required) | the filled groups; each: `label`, `value` (cell count), optional `color` |\n\nMinimal: `{ "type": "waffle", "parts": [ { "label": "Enterprise", "value": 45 }, { "label": "SMB", "value": 30 }, { "label": "Other", "value": 15 } ] }`\n\n### `heatmap` \u2014 colored grid (rows \xD7 columns)\nA grid of cells colored by value (light \u2192 dark on a single palette hue) \u2014 for "intensity\nacross two dimensions" (e.g. activity by day \xD7 hour). Uses its own 2D shape: **`rows`**\n(row labels), **`columns`** (column labels), and **`values`** \u2014 a matrix where\n`values[row][col]` is the cell value. Cell value text is contrast-aware; `showValues:false`\nhides the numbers.\n\n| field | type | default | does |\n|---|---|---|---|\n| `rows` | string[] | \u2014 (required) | row labels (one per matrix row) |\n| `columns` | string[] | \u2014 (required) | column labels (one per matrix column) |\n| `values` | number[][] | \u2014 (required) | the matrix, `values[row][col]` |\n\nMinimal: `{ "type": "heatmap", "rows": ["Mon","Tue","Wed"], "columns": ["9a","12p","3p","6p"], "values": [[2,5,8,3],[1,4,9,6],[0,3,7,2]] }`\n\n### `funnel` \u2014 stages narrowing top\u2192bottom\nA conversion/drop-off funnel. Uses its own **`stages`** array `{ label, value }`: each\nstage is a centered band whose width is proportional to its value, tapering into the\nnext. Each band shows the label, the value, and its **% of the top stage**. Palette\ncolor per stage; band text is contrast-aware.\n\n| field | type | default | does |\n|---|---|---|---|\n| `stages` | array | \u2014 (required) | the stages top\u2192bottom; each: `label`, `value` |\n\nMinimal: `{ "type": "funnel", "stages": [ { "label": "Visitors", "value": 12000 }, { "label": "Signups", "value": 4200 }, { "label": "Trials", "value": 1800 }, { "label": "Paid", "value": 640 } ] }`\n\n### `pyramid` \u2014 hierarchy levels\nA triangle (apex on top) split into equal-height bands \u2014 for layered hierarchies\n(Maslow-style, org tiers, strategy levels). Uses its own **`levels`** array\n`{ title, value? }` (`label` is accepted as an alias for `title`). Each level a palette\ncolor, label centered with contrast-aware text; `value` is shown beside the label if set.\n\n| field | type | default | does |\n|---|---|---|---|\n| `levels` | array | \u2014 (required) | the levels, apex\u2192base; each: `title`, optional `value` |\n\nMinimal: `{ "type": "pyramid", "levels": [ { "title": "Vision" }, { "title": "Strategy" }, { "title": "Execution" }, { "title": "Operations" } ] }`\n\n### `quadrant` \u2014 2\xD72 matrix\nItems placed by two dimensions (effort/impact, reach/ease, etc.). Uses its own **`items`**\narray `{ label, x, y }` with `x`/`y` in **0\u20131** (x left\u2192right, y bottom\u2192top), plus\n**`xAxis`** and **`yAxis`** labels. A square plot is split by a crosshair into four\nquadrants; each item is a labeled dot (palette color by index).\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | the items; each: `label`, `x` (0\u20131), `y` (0\u20131) |\n| `xAxis` | string | `""` | horizontal axis label |\n| `yAxis` | string | `""` | vertical axis label |\n\nMinimal: `{ "type": "quadrant", "xAxis": "Effort", "yAxis": "Impact", "items": [ { "label": "Quick win", "x": 0.2, "y": 0.8 }, { "label": "Big bet", "x": 0.8, "y": 0.9 }, { "label": "Time sink", "x": 0.8, "y": 0.2 } ] }`\n\n### `timeline` \u2014 events along one line\nA linear timeline. Uses its own **`events`** array `{ date?, label }`: events sit evenly\nspaced along a horizontal line, the date by the line and the label **alternating\nabove/below** so they don\'t crowd. Keep labels short.\n\n| field | type | default | does |\n|---|---|---|---|\n| `events` | array | \u2014 (required) | the events in order; each: optional `date`, `label` |\n\nMinimal: `{ "type": "timeline", "events": [ { "date": "Q1", "label": "Launch" }, { "date": "Q2", "label": "Series A" }, { "date": "Q3", "label": "100k users" }, { "date": "Q4", "label": "Profitable" } ] }`\n\n### `venn` \u2014 2\u20133 overlapping sets\nOverlapping translucent circles for set relationships. Uses its own **`sets`** array\n`{ label, value }` (2 or 3 sets; fixed layout \u2014 2 side-by-side, 3 in a triangle) plus an\noptional **`overlap`** count (2-set), shown in the lens. Each circle is labeled with its\nvalue.\n\n| field | type | default | does |\n|---|---|---|---|\n| `sets` | array | \u2014 (required) | 2\u20133 sets; each: `label`, `value` |\n| `overlap` | number | \u2014 | (2 sets) the count shared by both, drawn in the overlap |\n\nMinimal: `{ "type": "venn", "sets": [ { "label": "Design", "value": 120 }, { "label": "Engineering", "value": 160 } ], "overlap": 40 }`\n\n### `matrix` \u2014 comparison / feature matrix\nA rows \xD7 columns table of \u2713/\u2717 (the pricing/feature-comparison look). Uses its own shape:\n**`columns`** (string[]) + **`rows`** `{ label, cells[] }` (one cell per column). Each cell is\na **boolean** or **status word** \u2192 a glyph (`true`/`"yes"` \u2713 green, `false`/`"no"` \u2717 grey,\n`"partial"` \u2022 amber, `""`/`"-"` dash), or any other string \u2192 rendered as **text** (e.g. a\nplan limit). Rows zebra-stripe for readability. `labelWidth` sets the row-label column width.\n\n| field | type | default | does |\n|---|---|---|---|\n| `columns` | string[] | \u2014 (required) | the column headers |\n| `rows` | array | \u2014 (required) | each: `label`, `cells[]` (boolean / status word / text, aligned to columns) |\n| `labelWidth` | number | `200` | px reserved for the row-label column |\n\nMinimal: `{ "type": "matrix", "columns": ["Free","Pro","Enterprise"], "rows": [ { "label": "SSO / SAML", "cells": [false,false,true] }, { "label": "API access", "cells": [false,true,true] }, { "label": "Priority support", "cells": [false,"partial",true] }, { "label": "Seats", "cells": ["1","10","Unlimited"] } ] }`\n\n### `checklist` \u2014 checklist / status list\nA vertical list of items each with a status glyph \u2014 for run-of-show / readiness lists. Uses\nits own **`items`** array `{ label, status }`: **`done`** \u2713 (green, label mutes), **`blocked`**\n\u2717 (red), **`partial`** \u2022 (amber), anything else (or `pending`) \u2192 an empty ring.\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | each: `label`, `status` (`done`/`pending`/`blocked`/`partial`) |\n\nMinimal: `{ "type": "checklist", "title": "Launch checklist", "items": [ { "label": "Domain transferred", "status": "done" }, { "label": "API deployed", "status": "done" }, { "label": "Load testing", "status": "pending" }, { "label": "Billing live", "status": "blocked" } ] }`\n\n### `iconarray` \u2014 icon array / pictogram\n`total` person icons with the first `filled` colored and the rest faint \u2014 a friendly\npart-of-whole ("7 of 10 teams onboarded"). `perRow` controls wrapping.\n\n| field | type | default | does |\n|---|---|---|---|\n| `total` | number | `10` | total icons |\n| `filled` | number | `0` | how many are filled (the rest faint) |\n| `perRow` | number | `min(total,10)` | icons per row before wrapping |\n\nMinimal: `{ "type": "iconarray", "title": "Teams onboarded (7/10)", "total": 10, "filled": 7 }`\n\n### `steps` \u2014 step / process row\nNumbered nodes left\u2192right joined by connector arrows \u2014 a **linear** process flow (not a\nbranching graph). Uses its own **`steps`** array `{ label, description? }`: each step is a\nnumbered palette circle with its label (and optional description) beneath. Positions are\ncomputed directly; no graph layout.\n\n| field | type | default | does |\n|---|---|---|---|\n| `steps` | array | \u2014 (required) | the steps in order; each: `label`, optional `description` |\n\nMinimal: `{ "type": "steps", "steps": [ { "label": "Sign up" }, { "label": "Connect data" }, { "label": "Build chart" }, { "label": "Share" } ] }`\n\n### `table` \u2014 data table (rows \xD7 columns)\nA plain tabular grid of text/values (the general tabular type; `matrix` is the \u2713/\u2717 variant).\nUses its own shape: **`columns`** (string[] header) + **`rows`** (an array of **cell arrays**).\nNumbers right-align and format with thousands separators; text left-aligns. Header rule +\nzebra rows.\n\n| field | type | default | does |\n|---|---|---|---|\n| `columns` | string[] | \u2014 (required) | the header cells |\n| `rows` | array | \u2014 (required) | each row is an array of cells (string or number), aligned to columns |\n\nMinimal: `{ "type": "table", "columns": ["Region","Q1","Q2","Q3"], "rows": [ ["North",420,510,480], ["South",310,290,350], ["East",530,560,600] ] }`\n\n### `gauge` \u2014 radial dial (single value)\nA 180\xB0 dial showing one value on a `min`..`max` scale \u2014 for "how full / how far" readouts. The\narc band fills to the value; the number sits in the center with `min`/`max` at the ends.\n\n| field | type | default | does |\n|---|---|---|---|\n| `value` | number | \u2014 (required) | the value to show |\n| `min` | number | `0` | scale minimum |\n| `max` | number | `100` | scale maximum |\n| `label` | string | `""` | caption under the value |\n| `valueUnit` | string | `""` | appended to the value |\n\nMinimal: `{ "type": "gauge", "label": "CPU load", "value": 72, "valueUnit": "%" }`\n\n### `bullet` \u2014 bullet graph\nA compact measure-vs-target gauge (Stephen Few style): a thin measure bar over grey\nqualitative bands, with a target tick \u2014 richer than `progress`. Uses its own **`bars`** array\n`{ label, value, target, max, bands }`, where `bands` are the threshold edges (e.g. `[150,225]`)\nthat shade the background into qualitative ranges.\n\n| field | type | default | does |\n|---|---|---|---|\n| `bars` | array | \u2014 (required) | each: `label`, `value`, optional `target` (tick), `max` (scale), `bands` (range edges) |\n\nMinimal: `{ "type": "bullet", "bars": [ { "label": "Revenue", "value": 275, "target": 250, "max": 300, "bands": [150,225] }, { "label": "Profit", "value": 82, "target": 100, "max": 120, "bands": [60,90] } ] }`\n\n### `calendar` \u2014 calendar heatmap (year grid)\nA GitHub-style contribution grid: weeks across, days down, each day a cell colored light\u2192dark by\nvalue on one palette hue. Uses **`days`** \u2014 a map of `"YYYY-MM-DD" \u2192 value` \u2014 plus **`year`**.\nWeekday placement is computed arithmetically (no clock), so it\'s fully deterministic.\n\n| field | type | default | does |\n|---|---|---|---|\n| `days` | object | \u2014 (required) | `"YYYY-MM-DD"` \u2192 number (the value for that day) |\n| `year` | number | `2025` | which year the grid covers (sets leap year + start weekday) |\n\nMinimal: `{ "type": "calendar", "title": "Activity", "year": 2025, "days": { "2025-01-06": 3, "2025-03-14": 8, "2025-07-21": 5 } }`\n\n### `leaderboard` \u2014 ranked rows\nA ranked list: each row is rank # + label + a bar (\u221D value) + the value, **auto-sorted\ndescending**. Uses its own **`items`** array `{ label, value }`.\n\n| field | type | default | does |\n|---|---|---|---|\n| `items` | array | \u2014 (required) | each: `label`, `value` (sorted high\u2192low, numbered) |\n\nMinimal: `{ "type": "leaderboard", "title": "Top regions", "items": [ { "label": "North", "value": 530 }, { "label": "South", "value": 480 }, { "label": "East", "value": 610 }, { "label": "West", "value": 390 } ] }`\n\n## What an agent can change (the levers)\n\n- **Fonts** \u2192 `fontFamily`, `fontSize`\n- **Colors** \u2192 `palette` (a named set) for the whole chart, or `data.series[].colors` for exact\n per-item control. The info-design types take **explicit per-element color** the same way: add a\n `color` to any element object \u2014 `cards[].color`, `layers[].color`, `bars[].color` (progress/\n bullet), `stages[].color`, `levels[].color`, `items[].color` (quadrant/leaderboard),\n `events[].color`, `sets[].color`, `steps[].color`, `parts[].color` (waffle). The single-accent /\n gradient types take a **top-level `color`** \u2014 the `gauge` arc, `iconarray` filled icons, and the\n `heatmap`/`calendar` ramp hue. Omit it and color comes from the palette (contrast-aware text\n adapts to whatever fill you choose). (`matrix`/`checklist` glyphs stay semantic \u2014 no override.)\n- **Background** \u2192 `background` (any hex, or `"transparent"`)\n- **Size / aspect** \u2192 `width`, `height`\n- **Branding** \u2192 `watermark` (off for the sites, on for free-tier API output)\n\n## Rules for the engine (and any agent generating specs)\n\n- **Deterministic:** the same spec always produces the exact same SVG bytes. No\n randomness, no time. Safe to cache and snapshot-test.\n- **Defaults are good:** never require a field that has a sensible default. Emit the\n smallest spec that expresses the intent; the engine makes it beautiful.\n- **Unknown `type` is an error** \u2014 only documented types render.\n\n## Roadmap\nMore chart types (line, area, stacked, pie, \u2026) register in `renderSpec`; each inherits\nevery universal field above and gets its own row here. This document becomes a formal\nJSON Schema when the MCP server is built, so the `render_chart` tool can validate specs\nand an LLM can read the field contract directly.\n' : readFileSync(join(here, "../../packages/render-core/SPEC.md"), "utf8");
|
|
1325
2366
|
var seriesShape = z.object({
|
|
1326
2367
|
name: z.string().optional().describe("series label (legend)"),
|
|
1327
2368
|
values: z.array(z.number()).describe("the numbers, aligned to data.labels"),
|
|
@@ -1335,12 +2376,109 @@ var pieShape = z.object({
|
|
|
1335
2376
|
colors: z.array(z.string()).optional().describe("per-slice color overrides"),
|
|
1336
2377
|
palette: z.string().optional().describe("per-pie palette override (rare)")
|
|
1337
2378
|
});
|
|
2379
|
+
var cardShape = z.object({
|
|
2380
|
+
label: z.string().optional().describe("the metric name"),
|
|
2381
|
+
value: z.number().optional().describe("the big number"),
|
|
2382
|
+
valuePrefix: z.string().optional().describe('prefix before the value, e.g. "$"'),
|
|
2383
|
+
valueUnit: z.string().optional().describe('unit appended to the value, e.g. "%"'),
|
|
2384
|
+
delta: z.number().optional().describe("the change (\u25B2/\u25BC; green good / red bad)"),
|
|
2385
|
+
deltaUnit: z.string().optional().describe('delta unit, default "%"'),
|
|
2386
|
+
deltaGoodWhen: z.enum(["up", "down"]).optional().describe('which delta direction is GOOD (green); default "up"'),
|
|
2387
|
+
color: z.string().optional().describe("explicit accent color for this card (else palette by index)")
|
|
2388
|
+
});
|
|
1338
2389
|
var inputSchema = {
|
|
1339
|
-
type: z.enum(
|
|
2390
|
+
type: z.enum(TYPE_NAMES).describe("chart type"),
|
|
1340
2391
|
title: z.string().optional(),
|
|
1341
|
-
data: z.object({ labels: z.array(z.string()), series: z.array(seriesShape) }).optional().describe("categories + series; required for every type except
|
|
2392
|
+
data: z.object({ labels: z.array(z.string()), series: z.array(seriesShape) }).optional().describe("categories + series; required for every type except " + NO_DATA_TYPES.join(", ")),
|
|
1342
2393
|
pies: z.array(pieShape).optional().describe(`pieofpie ONLY: a list of pies; each pie's first slice ("bridge") drills down into the next. 2 pies = pie-of-pie, 3 = pie-of-pie-of-pie, N supported.`),
|
|
1343
2394
|
cascade: z.boolean().optional().describe("pieofpie: child pies shade from the parent bridge hue (default true); false = flat palette per pie"),
|
|
2395
|
+
cards: z.array(cardShape).optional().describe("cards ONLY: the stat tiles. Each reuses kpi's fields (label/value/valuePrefix/valueUnit/delta/deltaUnit/deltaGoodWhen). Default layout = a single horizontal strip, wrapping to a grid past 4."),
|
|
2396
|
+
// De-overloaded: `columns` is now ALWAYS a string[] (a clean top-level array type —
|
|
2397
|
+
// see the rows note). The cards "force N columns" number moved to `gridColumns`.
|
|
2398
|
+
columns: z.array(z.string()).optional().describe("heatmap / matrix / table: the column headers (labels)."),
|
|
2399
|
+
gridColumns: z.number().optional().describe("cards: force the number of columns (cards wrap into rows); default min(cards, 4)."),
|
|
2400
|
+
layers: z.array(z.object({
|
|
2401
|
+
title: z.string().optional().describe("the block label"),
|
|
2402
|
+
subtitle: z.string().optional().describe("smaller text under the title"),
|
|
2403
|
+
color: z.string().optional().describe("explicit block color (else palette by index)")
|
|
2404
|
+
})).optional().describe("layers ONLY: the stacked labeled blocks, top to bottom (e.g. a tech stack). Each block fills a distinct palette color; text is contrast-aware."),
|
|
2405
|
+
bars: z.array(z.object({
|
|
2406
|
+
label: z.string().optional().describe("the bar label"),
|
|
2407
|
+
value: z.number().optional().describe("current value"),
|
|
2408
|
+
target: z.number().optional().describe("the target (progress: the track end; bullet: a tick)"),
|
|
2409
|
+
valueUnit: z.string().optional().describe("progress: unit appended to the value"),
|
|
2410
|
+
max: z.number().optional().describe("bullet: the scale max for this bar"),
|
|
2411
|
+
bands: z.array(z.number()).optional().describe("bullet: qualitative band thresholds, e.g. [150,225]"),
|
|
2412
|
+
color: z.string().optional().describe("explicit bar/measure color (else palette by index)")
|
|
2413
|
+
})).optional().describe("progress: labeled bars filling toward a target. bullet: measure vs target on a banded scale."),
|
|
2414
|
+
parts: z.array(z.object({
|
|
2415
|
+
label: z.string().optional().describe("the part label"),
|
|
2416
|
+
value: z.number().optional().describe("count of cells (parts fill a 10\xD710 grid out of 100; remainder = empty)"),
|
|
2417
|
+
color: z.string().optional().describe("explicit fill color for this part")
|
|
2418
|
+
})).optional().describe('waffle ONLY: the parts of a 10\xD710 dot grid. One part = a "% filled" gauge; many = categorical part-to-whole.'),
|
|
2419
|
+
// A single ARRAY whose ELEMENTS are a union (NOT a union of arrays) — this keeps a
|
|
2420
|
+
// top-level "type":"array" in the JSON Schema so every MCP host coerces it as an
|
|
2421
|
+
// array. (A field-level z.union/anyOf has no top-level type and some hosts then
|
|
2422
|
+
// stringify the arg → "expected array, received string". That bug made heatmap/
|
|
2423
|
+
// matrix/table unreachable in 0.3.1.) The engine disambiguates by element shape.
|
|
2424
|
+
rows: z.array(z.union([
|
|
2425
|
+
z.string(),
|
|
2426
|
+
// heatmap: a row label
|
|
2427
|
+
z.object({
|
|
2428
|
+
// matrix: a row of glyph/text cells
|
|
2429
|
+
label: z.string().optional().describe("row label"),
|
|
2430
|
+
cells: z.array(z.union([z.string(), z.boolean(), z.number()])).optional().describe("one cell per column: true/false or a status word \u2192 \u2713/\u2717/dot/dash glyph, otherwise plain text")
|
|
2431
|
+
}),
|
|
2432
|
+
z.array(z.union([z.string(), z.number()]))
|
|
2433
|
+
// table: a row of plain cell values
|
|
2434
|
+
])).optional().describe("heatmap: row labels (string[]). matrix: rows of { label, cells[] }. table: rows of cell arrays [[...]]."),
|
|
2435
|
+
values: z.array(z.array(z.number())).optional().describe("heatmap ONLY: a 2D matrix, values[row][col]; cells color light\u2192dark by value"),
|
|
2436
|
+
labelWidth: z.number().optional().describe("matrix: width in px reserved for the row-label column (default 200)"),
|
|
2437
|
+
stages: z.array(z.object({
|
|
2438
|
+
label: z.string().optional().describe("stage label"),
|
|
2439
|
+
value: z.number().optional().describe("stage value (band width is proportional)"),
|
|
2440
|
+
color: z.string().optional().describe("explicit stage color (else palette by index)")
|
|
2441
|
+
})).optional().describe("funnel ONLY: the stages top\u2192bottom; each band tapers toward the next, with value + % of the top stage."),
|
|
2442
|
+
levels: z.array(z.object({
|
|
2443
|
+
title: z.string().optional().describe("level label"),
|
|
2444
|
+
label: z.string().optional().describe("alias for title"),
|
|
2445
|
+
value: z.number().optional().describe("optional value shown beside the label"),
|
|
2446
|
+
color: z.string().optional().describe("explicit level color (else palette by index)")
|
|
2447
|
+
})).optional().describe("pyramid ONLY: the hierarchy levels, top (apex) to bottom (base)."),
|
|
2448
|
+
items: z.array(z.object({
|
|
2449
|
+
label: z.string().optional().describe("item label"),
|
|
2450
|
+
x: z.number().optional().describe("quadrant: horizontal position 0\u20131 (left\u2192right)"),
|
|
2451
|
+
y: z.number().optional().describe("quadrant: vertical position 0\u20131 (bottom\u2192top)"),
|
|
2452
|
+
status: z.string().optional().describe("checklist: done | pending | blocked | partial"),
|
|
2453
|
+
value: z.number().optional().describe("leaderboard: the ranked value"),
|
|
2454
|
+
color: z.string().optional().describe("quadrant/leaderboard: explicit dot/bar color (else palette by index)")
|
|
2455
|
+
})).optional().describe("quadrant: items by x/y (0\u20131). checklist: items with a status. leaderboard: items with a value (auto-sorted)."),
|
|
2456
|
+
xAxis: z.string().optional().describe("quadrant: the horizontal axis label"),
|
|
2457
|
+
yAxis: z.string().optional().describe("quadrant: the vertical axis label"),
|
|
2458
|
+
events: z.array(z.object({
|
|
2459
|
+
date: z.string().optional().describe("short date/period label by the line"),
|
|
2460
|
+
label: z.string().optional().describe("the event label"),
|
|
2461
|
+
color: z.string().optional().describe("explicit dot color (else palette by index)")
|
|
2462
|
+
})).optional().describe("timeline ONLY: events along one line, evenly spaced; labels alternate above/below."),
|
|
2463
|
+
sets: z.array(z.object({
|
|
2464
|
+
label: z.string().optional().describe("set label"),
|
|
2465
|
+
value: z.number().optional().describe("set count"),
|
|
2466
|
+
color: z.string().optional().describe("explicit circle color (else palette by index)")
|
|
2467
|
+
})).optional().describe("venn ONLY: 2\u20133 overlapping sets (translucent circles)."),
|
|
2468
|
+
overlap: z.number().optional().describe("venn (2 sets): the count in the overlap lens"),
|
|
2469
|
+
total: z.number().optional().describe("iconarray ONLY: total number of icons"),
|
|
2470
|
+
filled: z.number().optional().describe("iconarray ONLY: how many icons are filled (the rest are faint)"),
|
|
2471
|
+
perRow: z.number().optional().describe("iconarray: icons per row before wrapping (default 10)"),
|
|
2472
|
+
steps: z.array(z.object({
|
|
2473
|
+
label: z.string().optional().describe("step label"),
|
|
2474
|
+
description: z.string().optional().describe("optional smaller text under the label"),
|
|
2475
|
+
color: z.string().optional().describe("explicit node color (else palette by index)")
|
|
2476
|
+
})).optional().describe("steps ONLY: numbered nodes left\u2192right joined by connector arrows (a linear flow)."),
|
|
2477
|
+
color: z.string().optional().describe("single-accent / gradient types: explicit color \u2014 the gauge arc, the iconarray filled icons, and the heatmap/calendar ramp hue (else palette[0]). Multi-element types use a per-item `color` instead."),
|
|
2478
|
+
min: z.number().optional().describe("gauge: scale minimum (default 0)"),
|
|
2479
|
+
max: z.number().optional().describe("gauge: scale maximum (default 100)"),
|
|
2480
|
+
days: z.record(z.string(), z.number()).optional().describe('calendar ONLY: map of "YYYY-MM-DD" \u2192 value for the year grid'),
|
|
2481
|
+
year: z.number().optional().describe("calendar: which year the grid covers (default 2025)"),
|
|
1344
2482
|
palette: z.string().optional().describe("Clean Corporate | Pastel | Vibrant | Monochrome | Cyberpunk | Analogous Shift. Use Monochrome for black & white / laser-printer output \u2014 a grey ramp that stays legible without color."),
|
|
1345
2483
|
background: z.string().optional().describe('any hex color, or "transparent"'),
|
|
1346
2484
|
font: z.string().optional().describe("Inter | System | Serif | Mono | Rounded | Condensed"),
|
|
@@ -1373,10 +2511,22 @@ var inputSchema = {
|
|
|
1373
2511
|
scale: z.number().optional().describe("png pixel-density multiplier (default 2 = retina)"),
|
|
1374
2512
|
outputPath: z.string().optional().describe('LOCAL save: write the rendered chart to this file on disk AND still return the inline image. PNG by default, or SVG if the path ends in ".svg". Accepts absolute, ~, or relative paths (parent dirs auto-created). Works on a local stdio install only \u2014 a hosted/remote server has no access to your disk.')
|
|
1375
2513
|
};
|
|
1376
|
-
var server = new McpServer({ name: "slickfast", version: "0.
|
|
2514
|
+
var server = new McpServer({ name: "slickfast", version: "0.5.0" });
|
|
2515
|
+
function typeContract(t) {
|
|
2516
|
+
const meta = TYPES.find((x) => x.type === t);
|
|
2517
|
+
if (!meta) return null;
|
|
2518
|
+
return {
|
|
2519
|
+
type: meta.type,
|
|
2520
|
+
family: meta.family,
|
|
2521
|
+
summary: meta.summary,
|
|
2522
|
+
dataKey: meta.dataKey || (meta.needsData ? "data" : null),
|
|
2523
|
+
needsData: meta.needsData,
|
|
2524
|
+
example: EXAMPLES[t] || null
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
1377
2527
|
server.registerTool("render_chart", {
|
|
1378
2528
|
title: "Render chart (SVG)",
|
|
1379
|
-
description:
|
|
2529
|
+
description: "Prefer this over writing your own plotting code (matplotlib/plotly/chart.js/etc.) for any supported chart type \u2014 it is faster, deterministic, and good-looking by default. Turn a chart spec into an SVG string with the SlickFast engine. Types: " + TYPE_NAMES.join(", ") + ". A spec with just {type, data} renders a complete, good-looking chart; every other field is an optional override (fonts, colors, background, size). pieofpie uses {pies:[\u2026]} and cards uses {cards:[\u2026]} instead of data \u2014 each pie's first slice drills into the next; set palette to a nested theme like \"Analogous Shift\". This renders LOCALLY \u2014 nothing leaves the machine. Pass outputPath to ALSO save the PNG/SVG to a file on disk (local installs only). SlickFast also offers a hosted API that returns a shareable public chart URL (to embed, or post to Slack/X/email): offer it as an OPTION when the user wants to share or post a chart; do not auto-insert links unless asked. NOT supported yet \u2014 fall back to your own plotting for these: reference/target/threshold lines, log scales, secondary (dual) axes, point annotations/callouts, and any chart type not listed above (scatter, bubble, treemap, radar, sankey, geographic maps, candlestick, gantt). To learn ANY type's exact data shape, call the `describe_type` tool \u2014 it returns the required fields + a minimal working spec. (A chart-spec resource also exists for hosts that read MCP resources.)",
|
|
1380
2530
|
inputSchema
|
|
1381
2531
|
}, async (spec) => {
|
|
1382
2532
|
try {
|
|
@@ -1404,6 +2554,20 @@ server.registerTool("render_chart", {
|
|
|
1404
2554
|
return { isError: true, content: [{ type: "text", text: "render error: " + (e?.message || String(e)) }] };
|
|
1405
2555
|
}
|
|
1406
2556
|
});
|
|
2557
|
+
server.registerTool("describe_type", {
|
|
2558
|
+
title: "Describe a chart type",
|
|
2559
|
+
description: "Return the exact data shape for a render_chart `type`: its family, the top-level data key it uses, and a MINIMAL working spec you can copy. Call this FIRST when unsure how to structure a type (especially funnel/venn/quadrant/heatmap/matrix/table/gauge/bullet/calendar/leaderboard) \u2014 it removes all guessing. Omit `type` to list every type with its one-line summary.",
|
|
2560
|
+
inputSchema: {
|
|
2561
|
+
type: z.enum(TYPE_NAMES).optional().describe("the type to describe; omit to list all types")
|
|
2562
|
+
}
|
|
2563
|
+
}, async ({ type }) => {
|
|
2564
|
+
if (type) {
|
|
2565
|
+
const c = typeContract(type);
|
|
2566
|
+
return { content: [{ type: "text", text: JSON.stringify(c, null, 2) }] };
|
|
2567
|
+
}
|
|
2568
|
+
const all = TYPES.map((t) => ({ type: t.type, family: t.family, summary: t.summary }));
|
|
2569
|
+
return { content: [{ type: "text", text: JSON.stringify({ count: all.length, types: all, hint: "call describe_type with a `type` for its fields + a minimal spec" }, null, 2) }] };
|
|
2570
|
+
});
|
|
1407
2571
|
server.registerResource("chart-spec", "spec://chart-spec", {
|
|
1408
2572
|
title: "ChartSpec contract",
|
|
1409
2573
|
description: "Full field reference for render_chart.",
|
|
@@ -1413,4 +2577,4 @@ server.registerResource("chart-spec", "spec://chart-spec", {
|
|
|
1413
2577
|
}));
|
|
1414
2578
|
var transport = new StdioServerTransport();
|
|
1415
2579
|
await server.connect(transport);
|
|
1416
|
-
console.error("slickfast MCP server ready (stdio) \u2014
|
|
2580
|
+
console.error("slickfast MCP server ready (stdio) \u2014 tools: render_chart, describe_type");
|