UNPKG

@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.

297 lines (296 loc) • 9.91 kB
/** * Band utilization object for `<ui9000-band-utilization-chart>`. * * The widget reads a WidgetItem, not a custom series list. Long rows use * `yAxe` as the entity, `xAxe` as the share, and `groupBy` as the segment. * Wide rows use `xAxe` as the entity and one `yAxe` column per segment. * * Call this chart only when that breakdown is repeated for at least two * entities and each entity sums to 100. One category of slices stays a pie. * Values that do not sum to a whole stay a bar. */ import { familiesForChart } from '../../catalog/chart-roles.js'; const WHOLE = 100; const EPS = 0.5; const UNIT_KEYS = new Set(['unit', 'measureunit', 'measureunitsymbol', 'uom']); const TITLE_KEYS = new Set(['title']); export function bandUtilizationFits(payload) { const rows = asRows(payload); if (!rows) return false; return longAxes(rows) !== null || wideAxes(rows) !== null; } /** Pie for one whole. Band when the same shares repeat once per entity. */ export function partToWholeChart(payload) { return bandUtilizationFits(payload) ? 'band-utilization-chart' : 'pie-chart'; } export function isPartToWholeChart(id) { return familiesForChart(id).some((family) => family.id === 'part-to-whole'); } export function bandUtilizationPayload(fields, rows) { const axes = fittingAxes(fields, rows) ?? longAxes(rows) ?? wideAxes(rows) ?? incompleteAxes(fields, rows); if (!axes) return null; return axes.kind === 'long' ? longPayload(axes, rows) : widePayload(axes, rows); } /** * Bound columns, but only when that pair already sums to a whole. * A first metric that does not (`weight` beside `share`) must not win * over the column `longAxes` finds. */ function fittingAxes(fields, rows) { const bound = boundAxes(fields, rows); if (!bound) return null; if (sumsToWhole(rows, bound.label, bound.series, bound.share)) { return { kind: 'long', entity: bound.label, segment: bound.series, share: bound.share }; } if (sumsToWhole(rows, bound.series, bound.label, bound.share)) { return { kind: 'long', entity: bound.series, segment: bound.label, share: bound.share }; } return null; } /** Named chart on a short row: keep the bound columns even when they do not sum to 100. */ function incompleteAxes(fields, rows) { const bound = boundAxes(fields, rows); if (!bound) return null; return { kind: 'long', entity: bound.label, segment: bound.series, share: bound.share }; } function boundAxes(fields, rows) { const label = present(rows, fields.label ?? fields.entity); const series = present(rows, fields.series ?? fields.segment); const share = present(rows, fields.y ?? fields.share ?? fields.metric); if (!label || !series || !share || label === series || label === share || series === share) { return null; } return { label, series, share }; } function longAxes(rows) { const { text, numeric } = splitKeys(rows); if (text.length < 2 || numeric.length < 1) return null; for (const share of numeric) { for (const entity of text) { for (const segment of text) { if (entity === segment) continue; if (sumsToWhole(rows, entity, segment, share)) { return { kind: 'long', entity, segment, share }; } } } } return null; } function wideAxes(rows) { const { text, numeric } = splitKeys(rows); if (text.length !== 1 || numeric.length < 2 || rows.length < 2) return null; const entity = text[0]; const seen = new Set(); for (const row of rows) { const label = textOf(row[entity]); if (!label || seen.has(label)) return null; seen.add(label); let sum = 0; for (const key of numeric) { const value = finite(row[key]); if (value === undefined) return null; sum += value; } if (Math.abs(sum - WHOLE) > EPS) return null; } return { kind: 'wide', entity, shares: numeric }; } function sumsToWhole(rows, entity, segment, share) { const groups = new Map(); for (const row of rows) { const label = textOf(row[entity]); const part = textOf(row[segment]); const value = finite(row[share]); if (!label || !part || value === undefined) continue; const parts = groups.get(label) ?? new Map(); parts.set(part, (parts.get(part) ?? 0) + value); groups.set(label, parts); } if (groups.size < 2) return false; let expected; for (const parts of groups.values()) { if (parts.size < 2) return false; const names = [...parts.keys()].sort(); if (!expected) expected = names; else if (names.join('\0') !== expected.join('\0')) return false; let sum = 0; for (const value of parts.values()) sum += value; if (Math.abs(sum - WHOLE) > EPS) return false; } return true; } function longPayload(axes, rows) { const data = []; const entities = []; const segments = []; for (const row of rows) { const label = textOf(row[axes.entity]); const part = textOf(row[axes.segment]); const value = finite(row[axes.share]); if (!label || !part || value === undefined) continue; if (!entities.includes(label)) entities.push(label); if (!segments.includes(part)) segments.push(part); data.push({ [axes.entity]: label, [axes.segment]: part, [axes.share]: value }); } if (data.length === 0 || segments.length === 0) return null; return widgetPayload({ name: titleOf(rows), yAxe: [axes.entity], xAxe: [axes.share], groupBy: [axes.segment], uniqueValues: { [axes.entity]: entities, [axes.segment]: segments }, shareFields: [axes.share], unit: unitOf(rows), data, }); } function widePayload(axes, rows) { const data = []; const entities = []; for (const row of rows) { const label = textOf(row[axes.entity]); if (!label) continue; const next = { [axes.entity]: label }; let kept = false; for (const key of axes.shares) { const value = finite(row[key]); if (value === undefined) continue; next[key] = value; kept = true; } if (!kept) continue; if (!entities.includes(label)) entities.push(label); data.push(next); } if (data.length === 0) return null; return widgetPayload({ name: titleOf(rows), xAxe: [axes.entity], yAxe: axes.shares, uniqueValues: { [axes.entity]: entities }, shareFields: axes.shares, unit: unitOf(rows), data, }); } function widgetPayload(input) { const detail = percentDetail(input.unit); const axisDetails = {}; for (const field of input.shareFields) axisDetails[field] = detail; return { chartType: 'bandUtilizationChart', name: input.name ?? 'band-utilization-chart', xAxe: input.xAxe, yAxe: input.yAxe, ...(input.groupBy ? { groupBy: input.groupBy } : {}), uniqueValues: input.uniqueValues, axisDetails, data: input.data, }; } function percentDetail(unit) { if (unit && unit !== '%') { return { label: unit, type: 'number', measure_unit: unit }; } return { label: 'Percent', type: 'number', subtype: 'percentage', measure_unit_type: 'percentage', measure_unit_symbol: '%', }; } function splitKeys(rows) { const keys = Object.keys(rows[0] ?? {}).filter((key) => !isUnitKey(key) && !isTitleKey(key)); const numeric = keys.filter((key) => isNumericColumn(rows, key)); const text = keys.filter((key) => !numeric.includes(key)); return { text, numeric }; } function isNumericColumn(rows, key) { let seen = 0; let numeric = 0; for (const row of rows) { const raw = row[key]; if (raw == null || String(raw).trim() === '') continue; seen += 1; if (finite(raw) !== undefined) numeric += 1; } return seen > 0 && numeric / seen >= 0.9; } function asRows(payload) { if (!Array.isArray(payload) || payload.length === 0) return null; const rows = payload.filter(isRow); return rows.length > 0 ? rows : null; } function isRow(value) { return !!value && typeof value === 'object' && !Array.isArray(value); } function present(rows, field) { if (!field || rows.length === 0 || !(field in rows[0])) return undefined; return field; } function finite(value) { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) { return Number(value); } return undefined; } function textOf(value) { return String(value ?? '').trim(); } function unitOf(rows) { return constantText(rows, isUnitKey); } function titleOf(rows) { return constantText(rows, isTitleKey); } function constantText(rows, match) { for (const key of Object.keys(rows[0] ?? {})) { if (!match(key)) continue; const values = new Set(rows.map((row) => textOf(row[key])).filter(Boolean)); if (values.size === 1) return [...values][0]; } return undefined; } function isUnitKey(key) { return UNIT_KEYS.has(key.toLowerCase().replace(/[^a-z]/g, '')); } function isTitleKey(key) { return TITLE_KEYS.has(key.toLowerCase().replace(/[^a-z]/g, '')); }