@fusedashlabs/ui9000-workspace
Version:
UI9000 workspace MCP. One tool, show_workspace, draws a chart from your table. Set TYPESAFE_API_KEY to let Jev choose the chart.
352 lines (351 loc) • 11 kB
JavaScript
/**
* FuseDash widget for a chart that is not one of the five engine builders.
* The iframe dispatches on `chartType`. Rows stay out of the model context;
* this object is what the data-link stores.
*/
import { aggregatePoints } from './rows.js';
const TIME_X = new Set([
'line-chart',
'area-chart',
'step-line-chart',
'spark-line-chart',
'spark-area-chart',
'scatter-sparkline-chart',
]);
const LABEL_X = new Set([
'lollipop',
'radial-bar-chart',
'polar-area-chart',
'pie-chart',
'donut-chart',
'treemap-chart',
'waterfall-chart',
'area-grouped-bar-chart',
'radar-chart',
'violin-chart',
'box-plot-chart',
]);
const POINTS = new Set(['scatter-plot-chart', 'bubble-chart']);
const GRID = new Set(['matrix-chart', 'punchcard-chart']);
const FLOW = new Set(['sankey-chart']);
const MODEL = new Set([
'partial-dependence-chart',
'bias-variance-tradeoff-chart',
'gini-impurity-entropy-chart',
]);
const RAW = new Set([
...POINTS,
...MODEL,
'scatter-sparkline-chart',
'violin-chart',
'area-grouped-bar-chart',
'radar-chart',
'parallel-coordinates-chart',
]);
export function dataRolesForChart(id) {
if (id === 'status-gauge-widget')
return [];
if (POINTS.has(id) || MODEL.has(id)) {
return [
{ id: 'mx', required: true },
{ id: 'my', required: true },
{ id: 'series', required: false },
];
}
if (FLOW.has(id)) {
return [
{ id: 'source', required: true },
{ id: 'target', required: true },
{ id: 'y', required: true },
];
}
if (GRID.has(id)) {
return [
{ id: 'label', required: true },
{ id: 'series', required: true },
{ id: 'y', required: true },
];
}
if (id === 'parallel-coordinates-chart') {
return [
{ id: 'y', required: true },
{ id: 'label', required: false },
];
}
if (TIME_X.has(id)) {
return [
{ id: 'x', required: true },
{ id: 'y', required: true },
{ id: 'series', required: false },
];
}
if (LABEL_X.has(id)) {
return [
{ id: 'label', required: true },
{ id: 'y', required: true },
{ id: 'series', required: false },
];
}
return [];
}
export function hostedChartPayload(id, chartType, fields, rows) {
if (rows.length === 0)
return null;
const type = resolveChartType(id, chartType, fields, rows);
if (id === 'box-plot-chart')
return boxPayload(type, fields, rows);
if (id === 'parallel-coordinates-chart')
return parallelPayload(type, fields, rows);
if (FLOW.has(id))
return flowPayload(type, fields, rows);
if (GRID.has(id))
return gridPayload(type, fields, rows);
if (POINTS.has(id) || MODEL.has(id))
return pointPayload(type, fields, rows);
return seriesPayload(id, type, fields, rows);
}
function seriesPayload(id, chartType, fields, rows) {
const x = present(rows, fields.x ?? fields.label) ?? inferAxis(rows, 'label');
const y = present(rows, fields.y ?? fields.metric) ?? inferAxis(rows, 'number');
if (!x || !y || x === y)
return null;
const series = present(rows, fields.series);
if (!series && (id === 'line-chart' || id === 'area-chart')) {
const melted = meltWideMetrics(id, x, y, rows);
if (melted)
return melted;
}
const data = RAW.has(id) ? rows : summed(rows, x, y, series);
if (!data)
return null;
return {
chartType,
name: id,
xAxe: [x],
yAxe: [y],
...(series ? { groupBy: [series] } : {}),
data,
};
}
function pointPayload(chartType, fields, rows) {
const x = present(rows, fields.mx) ?? inferAxis(rows, 'number');
const y = present(rows, fields.my) ?? inferAxis(rows, 'number', x);
if (!x || !y || x === y)
return null;
const series = present(rows, fields.series);
return {
chartType,
name: chartType,
xAxe: [x],
yAxe: [y],
...(series ? { groupBy: [series] } : {}),
data: rows,
};
}
function gridPayload(chartType, fields, rows) {
const x = present(rows, fields.label) ?? inferAxis(rows, 'label');
const yKey = present(rows, fields.series) ?? inferAxis(rows, 'label', x);
const value = present(rows, fields.y) ?? inferAxis(rows, 'number');
if (!x || !yKey || !value)
return null;
return {
chartType,
name: chartType,
xAxe: [x],
yAxe: [value],
groupBy: [yKey],
data: rows,
};
}
function flowPayload(chartType, fields, rows) {
const source = present(rows, fields.source) ?? inferAxis(rows, 'label');
const target = present(rows, fields.target) ?? inferAxis(rows, 'label', source);
const value = present(rows, fields.y) ?? inferAxis(rows, 'number');
if (!source || !target || !value)
return null;
return {
chartType,
name: chartType,
xAxe: [source],
yAxe: [value],
groupBy: [target],
data: summed(rows, source, value, target) ?? rows,
};
}
function boxPayload(chartType, fields, rows) {
const x = present(rows, fields.label) ?? inferAxis(rows, 'label');
const y = present(rows, fields.y) ?? inferAxis(rows, 'number');
if (!x || !y)
return null;
const groups = new Map();
for (const row of rows) {
const label = String(row[x] ?? '').trim();
const value = Number(row[y]);
if (!label || !Number.isFinite(value))
continue;
const list = groups.get(label) ?? [];
list.push(value);
groups.set(label, list);
}
if (groups.size === 0)
return null;
const data = [...groups.entries()].map(([label, values]) => {
const stats = fiveNumber(values);
return { _id: { [x]: label }, ...stats };
});
return { chartType, name: 'box-plot-chart', xAxe: [x], yAxe: [y], data };
}
function parallelPayload(chartType, fields, rows) {
const label = present(rows, fields.label);
const axes = numericKeys(rows).filter((key) => key !== label);
if (axes.length === 0)
return null;
return {
chartType,
name: 'parallel-coordinates-chart',
...(label ? { xAxe: [label] } : {}),
yAxe: [present(rows, fields.y) ?? axes[0]],
uniqueValues: { axes },
data: rows,
};
}
function resolveChartType(id, chartType, fields, rows) {
const series = present(rows, fields.series);
const grouped = !!series && distinct(rows, series) > 1;
if (!grouped)
return chartType || id;
if (id === 'line-chart')
return 'lineGroupedChart';
if (id === 'area-chart')
return 'areaStackedChart';
if (id === 'lollipop')
return 'lollipopGroupedChart';
if (id === 'radar-chart')
return 'radarGroupedChart';
return chartType || id;
}
function summed(rows, x, y, series) {
if (!series) {
const points = aggregatePoints(rows, x, y);
if (!points)
return null;
return points.map((point) => ({ [x]: point.label, [y]: point.value }));
}
const sums = new Map();
const order = [];
for (const row of rows) {
const cat = String(row[x] ?? '').trim();
const ser = String(row[series] ?? '').trim();
const value = Number(row[y]);
if (!cat || !ser || !Number.isFinite(value))
continue;
const key = `${cat}\0${ser}`;
if (!sums.has(key))
order.push(key);
sums.set(key, (sums.get(key) ?? 0) + value);
}
if (order.length === 0)
return null;
return order.map((key) => {
const split = key.indexOf('\0');
return {
[x]: key.slice(0, split),
[series]: key.slice(split + 1),
[y]: sums.get(key),
};
});
}
/** One row per measure, so two metric columns become two lines on the same axis. */
function meltWideMetrics(id, x, y, rows) {
const metrics = numericKeys(rows).filter((key) => key !== x);
if (!metrics.includes(y) || metrics.length < 2)
return null;
const measure = unusedKey(rows, 'measure');
const value = unusedKey(rows, 'value');
const data = [];
for (const row of rows) {
const label = String(row[x] ?? '').trim();
if (!label)
continue;
for (const metric of metrics) {
const raw = row[metric];
const n = Number(raw);
if (String(raw ?? '').trim() === '' || !Number.isFinite(n))
continue;
data.push({ [x]: label, [measure]: metric, [value]: n });
}
}
if (data.length === 0)
return null;
return {
chartType: id === 'area-chart' ? 'areaStackedChart' : 'lineGroupedChart',
name: id,
xAxe: [x],
yAxe: [value],
groupBy: [measure],
data,
};
}
function unusedKey(rows, base) {
const taken = new Set(Object.keys(rows[0] ?? {}));
if (!taken.has(base))
return base;
let index = 2;
while (taken.has(`${base}${index}`))
index += 1;
return `${base}${index}`;
}
function fiveNumber(values) {
const sorted = [...values].sort((a, b) => a - b);
const q1 = quantile(sorted, 0.25);
const median = quantile(sorted, 0.5);
const q3 = quantile(sorted, 0.75);
const min = sorted[0] ?? 0;
const max = sorted[sorted.length - 1] ?? 0;
return {
min,
q1,
median,
q3,
max,
smallestNonOutlier: q1,
biggestNonOutlier: q3,
};
}
function quantile(sorted, p) {
if (sorted.length === 0)
return 0;
if (sorted.length === 1)
return sorted[0] ?? 0;
const index = (sorted.length - 1) * p;
const lo = Math.floor(index);
const hi = Math.ceil(index);
const a = sorted[lo] ?? 0;
const b = sorted[hi] ?? a;
if (lo === hi)
return a;
return a * (hi - index) + b * (index - lo);
}
function present(rows, field) {
if (!field || !(field in rows[0]))
return undefined;
return field;
}
function inferAxis(rows, kind, skip) {
for (const key of Object.keys(rows[0])) {
if (key === skip)
continue;
const numeric = rows.some((row) => Number.isFinite(Number(row[key])) && row[key] !== '');
if (kind === 'number' && numeric)
return key;
if (kind === 'label' && !numeric)
return key;
}
return undefined;
}
function numericKeys(rows) {
return Object.keys(rows[0]).filter((key) => rows.some((row) => Number.isFinite(Number(row[key])) && String(row[key] ?? '') !== ''));
}
function distinct(rows, field) {
return new Set(rows.map((row) => String(row[field] ?? '').trim()).filter(Boolean)).size;
}