From 22acb8110640566efb29f29d1181947b9f546885 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:46:16 +0000 Subject: [PATCH 01/16] Add a script that converts classic OOB tiles to SmartViz Default dashboards still use classic visualisations on most tiles, and there are a few hundred of them, so converting them by hand would be slow and uneven. The script replaces each classic `visualisation` with a `vizSpec` in place, so a conversion PR's diff shows only the tiles that changed. It works from the repository alone, because most plugins have no live environment to test against. Columns, types and headers come from the classic config, the tile's group and metadata, and the stream's declared metadata. Where converting would mean guessing (a table that shows every column of an undeclared stream, more than 12 table columns, a chart that plots some but not all numeric columns, an auto-picked scalar value) the tile is reported as blocked and left classic. Table columns follow the in-product SmartViz migration: `columnOrder` minus `hiddenColumns` is the shown set. Cells use the text renderer unless the shape is state, date or URL, because the number renderer reformats values and would drop units and decimal places. A dry run over every community plugin converts 809 tiles. Every spec passes the viz-spec schema from saas, and 43 tiles are left classic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- scripts/classic-to-smartviz/README.md | 53 +++ scripts/classic-to-smartviz/chartMappers.mjs | 423 +++++++++++++++++++ scripts/classic-to-smartviz/columns.mjs | 230 ++++++++++ scripts/classic-to-smartviz/convert.mjs | 258 +++++++++++ scripts/classic-to-smartviz/jsonEdit.mjs | 141 +++++++ scripts/classic-to-smartviz/tableMappers.mjs | 184 ++++++++ 6 files changed, 1289 insertions(+) create mode 100644 scripts/classic-to-smartviz/README.md create mode 100644 scripts/classic-to-smartviz/chartMappers.mjs create mode 100644 scripts/classic-to-smartviz/columns.mjs create mode 100644 scripts/classic-to-smartviz/convert.mjs create mode 100644 scripts/classic-to-smartviz/jsonEdit.mjs create mode 100644 scripts/classic-to-smartviz/tableMappers.mjs diff --git a/scripts/classic-to-smartviz/README.md b/scripts/classic-to-smartviz/README.md new file mode 100644 index 00000000..ef97e8d3 --- /dev/null +++ b/scripts/classic-to-smartviz/README.md @@ -0,0 +1,53 @@ +# classic-to-smartviz + +Converts the classic tiles in a plugin's default dashboards to SmartViz tiles. Each classic `config.visualisation` is replaced with a `config.vizSpec`, in place, so the rest of the file is left untouched and the diff shows only the tiles that changed. + +It works from the repository alone. Column names, types and headers come from the classic config, the tile's group and `metadata`, and the stream's declared `metadata`. No data is fetched. + +## Usage + +```sh +# Report only — what would convert, what is blocked and why +node scripts/classic-to-smartviz/convert.mjs plugins/OpenF1/v1 + +# Convert, bump each changed plugin by a patch, and validate every spec against the schema +node scripts/classic-to-smartviz/convert.mjs --write --bump patch --schema plugins/OpenF1/v1 plugins/NASA/v1 +``` + +| Option | | +| --- | --- | +| `--write` | Write the converted dashboards. Without it the script only reports. | +| `--bump patch\|minor` | With `--write`, bump `metadata.json` in each folder that had a tile converted. | +| `--schema ` | A CommonJS module exporting `vizSpecSchema` from `@squaredup/viz-spec` (in the saas repo). A spec that fails it, or loses a key to it, stays classic. | +| `--json ` | Also write the report as JSON. | + +`squaredup validate` checks every `vizSpec` against the same schema when the plugin is validated or deployed, so `--schema` is a convenience for checking a large batch before you push. + +## What converts to what + +| Classic | SmartViz | Notes | +| --- | --- | --- | +| Table | `richTable` | The shown columns are `columnOrder` minus `hiddenColumns`, or every visible declared column when there is no `columnOrder`. Headers come from `columnDisplayNames`, then the column's `displayName`, then the name the platform would infer. State, date and URL columns get the status, date and link renderers; everything else uses the text renderer, which keeps the shape's formatting. | +| Status blocks | `statusGrid` | The sublabel becomes a `statusGridSecondaryTemplate`, wrapped in `{{{{raw}}}}` so install-time templating leaves it alone. | +| Line graph | `line` | Classic's default fitted y axis and hidden data points carry over. Area shading is kept only when the lines are not split into series. | +| Bar chart | `bar` | Grouping, stacking, horizontal layout, value labels and simple colour rules carry over. | +| Scalar | `scalar` | | +| Gauge | `gauge` | | +| Donut | `pie` with a 60% hole and the total in the centre | | + +Tiles on the built-in properties stream, iframe tiles and tiles that are already SmartViz are left alone. + +## What it will not convert + +A tile is reported as blocked, and left classic, when converting it would mean guessing: + +- **A table that shows all columns** of a stream that does not declare its full column list. +- **A table with more than 12 columns.** A SmartViz table holds at most 12. +- **A line or bar chart that plots some, but not all, of a stream's numeric columns.** SmartViz plots one y column, or every numeric column. +- **A scalar, gauge or donut set to pick its columns automatically**, when the stream does not declare them. + +A column the classic config names but the tile's data no longer has (for example, after a group was added) is handled the way classic handles it: dropped from a table, or left for SmartViz to pick on a chart. Each case is noted in the report. + +## Before you raise the pull request + +The script cannot see data, so check each converted dashboard in a deployed plugin, with real or demo data, and compare it with the classic version. diff --git a/scripts/classic-to-smartviz/chartMappers.mjs b/scripts/classic-to-smartviz/chartMappers.mjs new file mode 100644 index 00000000..99de2eef --- /dev/null +++ b/scripts/classic-to-smartviz/chartMappers.mjs @@ -0,0 +1,423 @@ +import { findColumn, findFieldType, findSoleColumnOfType, getColumnTitle, isKnownColumn, isTimeLikeName } from './columns.mjs'; + +/** The classic Y-axis "Count (Built-in)" option. */ +const BUILT_IN_COUNT = 'builtin_count_by_label'; + +const LEGEND_POSITIONS = new Set(['top', 'bottom', 'left', 'right']); + +const REDUCTION_AGGREGATES = { sum: 'sum', mean: 'avg', avg: 'avg', min: 'min', max: 'max', count: 'count' }; + +const isSet = (value) => value !== undefined && value !== null && value !== '' && value !== 'auto' && value !== 'none'; + +const toList = (value) => (Array.isArray(value) ? value : isSet(value) ? [value] : []); + +/** A channel for a column, typed from its declared shape or, failing that, from the role the channel plays. */ +const toChannel = ({ columns }, field, fallbackType, extra = {}) => ({ + field, + type: findFieldType(columns, field) ?? fallbackType, + ...extra +}); + +/** A configured column the tile still has, or `undefined` (with a note) when it is unset or gone. */ +const findConfiguredColumn = (tileColumns, configured, label, notes) => { + if (!isSet(configured)) { + return undefined; + } + + if (isKnownColumn(tileColumns, configured)) { + return configured; + } + + notes.push(`${label} column ${configured} no longer exists, so SmartViz picks one as classic did`); + + return undefined; +}; + +/** The x channel of a line or bar chart, or `undefined` to let SmartViz pick the x column itself. */ +const toXChannel = (tileColumns, configured, chartType, notes) => { + const field = findConfiguredColumn(tileColumns, configured, 'x', notes); + + if (!field) { + return undefined; + } + + const declaredType = findFieldType(tileColumns.columns, field); + + if (declaredType) { + return { field, type: declaredType }; + } + + const guessedType = chartType === 'line' || isTimeLikeName(field) ? 'temporal' : 'nominal'; + notes.push(`x column ${field} has no declared shape; typed as ${guessedType}`); + + return { field, type: guessedType }; +}; + +/** + * The y channel. Several y columns only convert when they are every numeric column the chart would plot + * anyway, because SmartViz plots either one y column or all numeric columns. + */ +const toYEncoding = (tileColumns, configured, xField, notes) => { + const yColumns = toList(configured); + + if (yColumns[0] === BUILT_IN_COUNT) { + return xField + ? { y: { field: xField, type: 'quantitative', aggregate: 'count', title: 'Count' } } + : { blockedReason: 'counts rows with no x column set' }; + } + + const fields = yColumns + .filter((name) => name !== BUILT_IN_COUNT) + .map((name) => findConfiguredColumn(tileColumns, name, 'y', notes)) + .filter(Boolean); + + if (fields.length <= 1) { + return fields.length === 1 ? { y: toChannel(tileColumns, fields[0], 'quantitative') } : {}; + } + + const { columns, isComplete } = tileColumns; + const plottedByDefault = columns + .filter((column) => column.isVisible && column.name !== xField && findFieldType(columns, column.name) === 'quantitative') + .map((column) => column.name); + const isEveryNumericColumn = + isComplete && plottedByDefault.length === fields.length && fields.every((name) => plottedByDefault.includes(name)); + + return isEveryNumericColumn + ? {} + : { blockedReason: `plots ${fields.length} of the stream's numeric columns; SmartViz plots one or all of them` }; +}; + +/** + * Legend and axis-title style shared by line and bar. `autoTitleAxes` lists the axes classic gives a default + * title when the title is switched on with no text. + */ +const toLegendAndAxisStyle = (classicConfig, autoTitleAxes) => { + const xTitle = classicConfig.xAxisLabel; + const yTitle = classicConfig.yAxisLabel; + const showsXTitle = + classicConfig.showXAxisLabel !== false && Boolean(xTitle || (classicConfig.showXAxisLabel === true && autoTitleAxes.x)); + const showsYTitle = + classicConfig.showYAxisLabel !== false && Boolean(yTitle || (classicConfig.showYAxisLabel === true && autoTitleAxes.y)); + + return { + ...(classicConfig.showLegend === false + ? { legendPosition: 'none' } + : LEGEND_POSITIONS.has(classicConfig.legendPosition) && { legendPosition: classicConfig.legendPosition }), + ...(showsXTitle && { cartesianShowXAxisTitle: true, ...(xTitle && { cartesianXAxisTitle: xTitle }) }), + ...(showsYTitle && { cartesianShowYAxisTitle: true, ...(yTitle && { cartesianYAxisTitle: yTitle }) }), + ...(classicConfig.showGrid === false && { cartesianShowHorizontalGridLines: false }) + }; +}; + +/** Classic's line range modes: `auto` (the default) fits the data, `fit` anchors at zero. */ +const toLineRangeStyle = (classicConfig) => { + switch (classicConfig.yAxisRangeMode ?? 'auto') { + case 'auto': + return { cartesianYAxisRangeMode: 'fit' }; + case 'percentage': + return { cartesianYAxisRangeMode: 'percentage' }; + case 'custom': + return { + ...(typeof classicConfig.yAxisRangeFrom === 'number' && { cartesianYAxisMin: classicConfig.yAxisRangeFrom }), + ...(typeof classicConfig.yAxisRangeTo === 'number' && { cartesianYAxisMax: classicConfig.yAxisRangeTo }) + }; + default: + return {}; + } +}; + +const toVisualization = (chartType, encoding, style, notes) => ({ + spec: { chartType, encoding, ...(Object.keys(style).length > 0 && { style }) }, + notes +}); + +const toCartesianEncoding = (x, y, seriesField) => ({ + ...(x && { x }), + ...(y && { y }), + ...(seriesField && { series: { field: seriesField, type: 'nominal' } }) +}); + +/** Converts a classic `data-stream-line-graph` config to a line spec. */ +export const toLineChart = (classicConfig, tileColumns) => { + const notes = []; + const x = toXChannel(tileColumns, classicConfig.xAxisColumn, 'line', notes); + const { y, blockedReason } = toYEncoding(tileColumns, classicConfig.yAxisColumn, x?.field, notes); + + if (blockedReason) { + return { blockedReason }; + } + + const seriesField = findConfiguredColumn(tileColumns, classicConfig.seriesColumn, 'series', notes); + + /** A gradient under every line of a chart split into many series hides the lines, so only unsplit charts keep it. */ + const hasArea = classicConfig.shading !== false && !seriesField; + + if (classicConfig.shading !== false && seriesField) { + notes.push('area shading dropped because the lines are split by series'); + } + + const style = { + ...toLegendAndAxisStyle(classicConfig, { x: false, y: true }), + ...toLineRangeStyle(classicConfig), + ...(!classicConfig.dataPoints && { cartesianSymbol: 'none' }), + ...(hasArea && { cartesianAreaGradient: true }), + ...(classicConfig.cumulative === true && { cartesianCumulative: true }), + ...(classicConfig.showTrendLine === true && { cartesianTrendLine: true }), + ...(typeof classicConfig.decimalPlaces === 'number' && { cartesianYAxisDecimalPlaces: classicConfig.decimalPlaces }), + ...(isSet(classicConfig.unitLabelColumn) && { cartesianYAxisUnitColumn: classicConfig.unitLabelColumn }) + }; + + return toVisualization('line', toCartesianEncoding(x, y, seriesField), style, notes); +}; + +const PALETTE_RULE = /^(?:bar|series)Index\s*%\s*(\d+)\s*==\s*(\d+)$/u; +const LABEL_RULE = /^label\s*==\s*["'](.+?)["']$/u; +const SERIES_RULE = /^series\s*==\s*["'](.+?)["']$/u; +const VALUE_RULE = /^value\s*(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)$/u; +const VALUE_OPERATORS = { '>': 'gt', '>=': 'gte', '<': 'lt', '<=': 'lte' }; + +/** Converts one classic colour expression, or returns `undefined` when SmartViz has no equivalent. */ +const toColorRule = ({ color, expression }) => { + const trimmed = expression.trim(); + const series = SERIES_RULE.exec(trimmed)?.[1]; + const label = LABEL_RULE.exec(trimmed)?.[1]; + const value = VALUE_RULE.exec(trimmed); + + if (series) { + return { mappedColor: [series, color] }; + } + + if (label) { + return { colorRule: { label, color } }; + } + + return value ? { colorRule: { [VALUE_OPERATORS[value[1]]]: Number(value[2]), color } } : undefined; +}; + +/** Converts classic custom bar colours to SmartViz colour style, noting any rule it cannot express. */ +const toBarColorStyle = (colorState, notes) => { + const rules = (colorState?.type === 'custom' ? (colorState.customColors ?? []) : []).filter( + (rule) => rule.color && rule.expression + ); + + if (rules.length === 0) { + return {}; + } + + const paletteMatches = rules.map((rule) => PALETTE_RULE.exec(rule.expression.trim())); + + if (paletteMatches.every(Boolean)) { + const colors = paletteMatches + .map((match, index) => ({ remainder: Number(match[2]), color: rules[index].color })) + .sort((a, b) => a.remainder - b.remainder) + .map(({ color }) => color); + + return { colors }; + } + + const converted = rules.map((rule) => ({ rule, result: toColorRule(rule) })); + + converted + .filter(({ result }) => !result) + .forEach(({ rule }) => notes.push(`colour rule not converted: ${rule.expression.trim()} → ${rule.color}`)); + + const mappedColors = Object.fromEntries(converted.map(({ result }) => result?.mappedColor).filter(Boolean)); + const colorRules = converted.map(({ result }) => result?.colorRule).filter(Boolean); + + return { + ...(Object.keys(mappedColors).length > 0 && { mappedColors }), + ...(colorRules.length > 0 && { cartesianColorRules: colorRules }) + }; +}; + +const toBarStackStyle = (classicConfig, seriesField) => { + if (classicConfig.displayMode === 'percentage') { + return { cartesianStack: 'normalize' }; + } + + if (classicConfig.displayMode === 'cumulative') { + return { cartesianCumulative: true }; + } + + if (!seriesField) { + return {}; + } + + return { cartesianStack: classicConfig.grouping === true ? 'off' : 'on' }; +}; + +/** Converts a classic `data-stream-bar-chart` config to a bar spec. */ +export const toBarChart = (classicConfig, tileColumns) => { + const notes = []; + const x = toXChannel(tileColumns, classicConfig.xAxisData, 'bar', notes); + const { y, blockedReason } = toYEncoding(tileColumns, classicConfig.yAxisData, x?.field, notes); + + if (blockedReason) { + return { blockedReason }; + } + + const seriesField = findConfiguredColumn(tileColumns, classicConfig.xAxisGroup, 'group', notes); + const range = classicConfig.range; + + const style = { + ...toLegendAndAxisStyle(classicConfig, { x: true, y: true }), + ...toBarStackStyle(classicConfig, seriesField), + ...(classicConfig.horizontalLayout === 'horizontal' && { cartesianHorizontal: true }), + ...(classicConfig.showValue === true && { showLabels: true }), + ...(classicConfig.showTotals === true && { cartesianShowStackedTotals: true }), + ...(range?.type === 'custom' && typeof range.minimum === 'number' && { cartesianYAxisMin: range.minimum }), + ...(range?.type === 'custom' && typeof range.maximum === 'number' && { cartesianYAxisMax: range.maximum }), + ...toBarColorStyle(classicConfig.color, notes) + }; + + return toVisualization('bar', toCartesianEncoding(x, y, seriesField), style, notes); +}; + +/** + * Reads a classic scalar or gauge `value`: a column name, or a reduction `{ type, columns }` over all rows. + * A value column the tile no longer has falls back to its only numeric column, as classic's default does. + * A count needs a field to point at, so it borrows any column the tile is known to have. + */ +const toValueChannel = (value, tileColumns, notes) => { + if (typeof value === 'string' && isSet(value)) { + if (isKnownColumn(tileColumns, value)) { + return { channel: toChannel(tileColumns, value, 'quantitative') }; + } + + const replacement = findSoleColumnOfType(tileColumns, 'quantitative'); + + if (!replacement) { + return { blockedReason: `value column ${value} no longer exists` }; + } + + notes.push(`value column ${value} no longer exists; using ${replacement}`); + + return { channel: toChannel(tileColumns, replacement, 'quantitative') }; + } + + const aggregate = REDUCTION_AGGREGATES[value?.type]; + + if (aggregate === 'count') { + const anyField = value.columns?.[0] ?? tileColumns.columns[0]?.name; + + return anyField + ? { channel: { field: anyField, type: 'quantitative', aggregate: 'count', title: 'Count' } } + : { blockedReason: 'counts rows, and the stream declares no column to count' }; + } + + if (aggregate && value.columns?.length === 1) { + return { channel: toChannel(tileColumns, value.columns[0], 'quantitative', { aggregate }) }; + } + + return { blockedReason: 'value column is set to auto' }; +}; + +/** Converts a classic `data-stream-scalar` config to a scalar spec. */ +export const toScalar = (classicConfig, tileColumns) => { + const notes = []; + const { channel, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); + + if (blockedReason) { + return { blockedReason }; + } + + const comparisonField = findConfiguredColumn(tileColumns, classicConfig.comparisonColumn, 'comparison', notes); + const labelPosition = classicConfig.labelPosition; + + if (!findColumn(tileColumns.columns, channel.field)) { + notes.push(`value column ${channel.field} is not declared by the stream; typed as a number`); + } + + const style = { + ...(typeof classicConfig.label === 'string' && classicConfig.label && { scalarLabelText: classicConfig.label }), + ...(labelPosition && labelPosition !== 'bottom' && { scalarLabelPosition: labelPosition }) + }; + + const encoding = { + values: [channel], + ...(comparisonField && { comparison: toChannel(tileColumns, comparisonField, 'quantitative') }) + }; + + return toVisualization('scalar', encoding, style, notes); +}; + +/** Converts a classic `data-stream-gauge` config to a gauge spec. */ +export const toGauge = (classicConfig, tileColumns) => { + const notes = []; + const { channel, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); + + if (blockedReason) { + return { blockedReason }; + } + + const minimumField = findConfiguredColumn(tileColumns, classicConfig.minimumColumn, 'minimum', notes); + const maximumField = findConfiguredColumn(tileColumns, classicConfig.maximumColumn, 'maximum', notes); + + const style = { + ...(typeof classicConfig.minimum === 'number' && { gaugeMin: classicConfig.minimum }), + ...(typeof classicConfig.maximum === 'number' && { gaugeMax: classicConfig.maximum }), + ...(classicConfig.label && { gaugeLabelText: String(classicConfig.label) }) + }; + + const encoding = { + value: { ...channel, type: 'quantitative' }, + ...(minimumField && { min: toChannel(tileColumns, minimumField, 'quantitative') }), + ...(maximumField && { max: toChannel(tileColumns, maximumField, 'quantitative') }) + }; + + return toVisualization('gauge', encoding, style, notes); +}; + +/** + * The donut's value channel: a count of rows per label for classic's "Count (Built-in)", the configured + * column, or the tile's only numeric column when the configured one is gone. + */ +const toDonutValue = (classicConfig, tileColumns, labelField, notes) => { + const configured = classicConfig.valueColumn; + + if (configured === BUILT_IN_COUNT) { + return { field: labelField, type: 'quantitative', aggregate: 'count', title: 'Count' }; + } + + const field = isKnownColumn(tileColumns, configured) ? configured : findSoleColumnOfType(tileColumns, 'quantitative'); + + if (!field) { + return undefined; + } + + if (field !== configured) { + notes.push(`value column ${configured} no longer exists; using ${field}`); + } + + return { field, type: 'quantitative', aggregate: 'sum', title: getColumnTitle(tileColumns.columns, field) }; +}; + +/** Converts a classic `data-stream-donut-chart` config to a donut-style pie spec. */ +export const toDonut = (classicConfig, tileColumns) => { + const notes = []; + + if (!isSet(classicConfig.labelColumn) || !isSet(classicConfig.valueColumn)) { + return { blockedReason: 'label or value column is set to auto' }; + } + + if (!isKnownColumn(tileColumns, classicConfig.labelColumn)) { + return { blockedReason: `label column ${classicConfig.labelColumn} no longer exists` }; + } + + const value = toDonutValue(classicConfig, tileColumns, classicConfig.labelColumn, notes); + + if (!value) { + return { blockedReason: `value column ${classicConfig.valueColumn} no longer exists` }; + } + + const style = { + pieInnerRadius: 60, + pieDonutCenterValue: classicConfig.hideCenterValue === true ? 'none' : 'total', + labelFontSize: 16, + ...(classicConfig.legendMode === 'table' && { pieLegendStyle: 'table' }) + }; + + const encoding = { label: { field: classicConfig.labelColumn, type: 'nominal' }, value }; + + return toVisualization('pie', encoding, style, notes); +}; diff --git a/scripts/classic-to-smartviz/columns.mjs b/scripts/classic-to-smartviz/columns.mjs new file mode 100644 index 00000000..2280d315 --- /dev/null +++ b/scripts/classic-to-smartviz/columns.mjs @@ -0,0 +1,230 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const NUMERIC_SHAPES = new Set([ + 'number', + 'integer', + 'decimal', + 'percent', + 'currency', + 'bytes', + 'kilobytes', + 'megabytes', + 'gigabytes', + 'terabytes', + 'milliseconds', + 'seconds', + 'minutes', + 'hours', + 'days', + 'duration' +]); + +const TIME_LIKE_NAME = /time|date|timestamp|created|updated|started|ended|^ts$|_at$|At$|day|hour|month|week/u; + +const AGGREGATE_TITLES = { count: 'Count', sum: 'Sum', mean: 'Average', avg: 'Average', min: 'Minimum', max: 'Maximum' }; + +/** + * Splits a column name into the header text the platform shows when a column declares no display name: + * the last path segment, split on case changes, underscores, hyphens and spaces, each word capitalised. + */ +export const toInferredTitle = (name) => { + const segments = name.split('.'); + const last = segments[segments.length - 1]; + const leaf = Number.isFinite(Number(last)) && segments.length > 1 ? `${segments[segments.length - 2]}[${last}]` : last; + + return (leaf.charAt(0).toUpperCase() + leaf.slice(1)) + .split(/(?=[A-Z][a-z])|_|-|(?=(?<=[a-z])[A-Z])|\s/gu) + .filter((part) => part !== '') + .map((part) => (/^[^a-z]*$/u.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())) + .join(' '); +}; + +const toShape = (metadataEntry) => { + const [name, config] = Array.isArray(metadataEntry.shape) ? metadataEntry.shape : [metadataEntry.shape, undefined]; + + return { shapeName: typeof name === 'string' && name !== '' ? name : undefined, shapeConfig: config }; +}; + +const toColumn = (metadataEntry) => ({ + name: metadataEntry.name, + displayName: metadataEntry.displayName || undefined, + role: metadataEntry.role, + isVisible: metadataEntry.visible !== false, + ...toShape(metadataEntry) +}); + +/** Keeps the first entry for each column name, because a later bare `{ name }` entry only re-asserts the column. */ +const toColumnSet = (metadata) => { + const named = metadata.filter((entry) => typeof entry.name === 'string'); + const columns = named.filter((entry, index) => named.findIndex((other) => other.name === entry.name) === index).map(toColumn); + + return { columns, isComplete: !metadata.some((entry) => entry.pattern !== undefined) }; +}; + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); + +/** + * Reads the declared columns of every data stream in a plugin version folder, from `dataStreams/*.json` (low-code + * plugins) or `data_streams.json` and its `rowTypes` (packaged plugins). A stream that declares no columns maps to + * `undefined`. `isComplete` is false when a `pattern` entry lets undeclared columns through. + */ +export const readStreamColumns = (pluginDirectory) => { + const lowCodeDirectory = path.join(pluginDirectory, 'dataStreams'); + const lowCodeStreams = fs.existsSync(lowCodeDirectory) + ? fs + .readdirSync(lowCodeDirectory) + .filter((file) => file.endsWith('.json')) + .map((file) => readJson(path.join(lowCodeDirectory, file))) + .map((stream) => [stream.name, Array.isArray(stream.metadata) ? toColumnSet(stream.metadata) : undefined]) + : []; + + const packagedFile = path.join(pluginDirectory, 'data_streams.json'); + const packaged = fs.existsSync(packagedFile) ? readJson(packagedFile) : undefined; + const rowTypeMetadataByName = new Map((packaged?.rowTypes ?? []).map((rowType) => [rowType.name, rowType.metadata])); + const packagedStreams = (packaged?.dataStreams ?? []).map(({ definition = {} }) => { + const metadata = definition.metadata ?? rowTypeMetadataByName.get(definition.rowType); + + return [definition.name, Array.isArray(metadata) ? toColumnSet(metadata) : undefined]; + }); + + return new Map([...lowCodeStreams, ...packagedStreams]); +}; + +const STREAM_REFERENCE = /\{\{\s*dataStreams\.\[?([^}\]]+?)\]?\s*\}\}/u; + +/** Returns the plugin stream name a tile reads, or `undefined` for a built-in stream such as SQL or properties. */ +export const getStreamName = (tileConfig) => { + const dataStream = tileConfig.dataStream ?? {}; + + return STREAM_REFERENCE.exec(dataStream.id ?? '')?.[1] ?? (dataStream.id ? undefined : dataStream.name); +}; + +/** True for a tile on the built-in object properties stream, whose columns are the scoped object's properties. */ +export const isPropertiesTile = (tileConfig) => + tileConfig.dataStream?.id === 'datastream-properties' || tileConfig.dataStream?.name === 'properties'; + +/** A group's `by` is one `[column, grouper]` pair, a list of them, or empty. */ +const toByPairs = (by) => { + if (!Array.isArray(by) || by.length === 0) { + return []; + } + + return Array.isArray(by[0]) ? by.filter((pair) => typeof pair[0] === 'string') : [by]; +}; + +/** A row count with no column is named `count`; any other aggregate is `_`. */ +const toGroupedColumns = (group, sourceColumns) => { + const byPairs = toByPairs(group.by); + const sourceTitle = (name) => sourceColumns.find((column) => column.name === name)?.displayName ?? toInferredTitle(name); + + const keyColumns = byPairs.map(([name, grouper]) => ({ + name: `${name}_${grouper}`, + displayName: sourceTitle(name), + isVisible: true, + groupKind: /^(by|time)/iu.test(grouper) ? 'temporal' : 'nominal' + })); + const aggregateColumns = (group.aggregate ?? []).flatMap(({ type, names, name }) => { + const columnNames = Array.isArray(names) ? names : name ? [name] : []; + const toAggregateColumn = (columnName) => ({ + name: columnName ? `${columnName}_${type}` : type, + displayName: columnName ? `${sourceTitle(columnName)} (${AGGREGATE_TITLES[type] ?? type})` : (AGGREGATE_TITLES[type] ?? type), + isVisible: true, + groupKind: 'quantitative' + }); + + return columnNames.length > 0 ? columnNames.map(toAggregateColumn) : [toAggregateColumn(undefined)]; + }); + + return [...keyColumns, ...aggregateColumns]; +}; + +/** Applies a tile's `dataStream.metadata` over the stream's columns: matching names are re-shaped, new names added. */ +const overlayColumns = (streamColumns, tileMetadata) => { + const tileColumns = toColumnSet(tileMetadata).columns; + const overlaid = streamColumns.map((column) => { + const override = tileMetadata.find((entry) => entry.name === column.name); + + if (!override) { + return column; + } + + return { + ...column, + ...(override.shape !== undefined && toShape(override)), + ...(override.displayName && { displayName: override.displayName }), + ...(override.role && { role: override.role }), + ...(override.visible !== undefined && { isVisible: override.visible !== false }) + }; + }); + const added = tileColumns.filter((column) => !findColumn(streamColumns, column.name)); + + return [...overlaid, ...added]; +}; + +/** + * Resolves the columns a tile's data will have: the post-group names when the tile groups, otherwise the + * stream's declared columns with the tile's own `dataStream.metadata` applied over them. `isComplete` is true + * only when every column the data can have is known. + */ +export const getTileColumns = (tileConfig, streamColumnsByName) => { + const dataStream = tileConfig.dataStream ?? {}; + const streamColumns = streamColumnsByName.get(getStreamName(tileConfig)); + const tileMetadata = Array.isArray(dataStream.metadata) ? dataStream.metadata : []; + + if (dataStream.group?.by?.length || dataStream.group?.aggregate?.length) { + return { columns: toGroupedColumns(dataStream.group, streamColumns?.columns ?? []), isComplete: true, source: 'group' }; + } + + if (!streamColumns) { + return { columns: overlayColumns([], tileMetadata), isComplete: false, source: tileMetadata.length ? 'tile metadata' : 'none' }; + } + + return { columns: overlayColumns(streamColumns.columns, tileMetadata), isComplete: streamColumns.isComplete, source: 'stream metadata' }; +}; + +/** Finds a column by name, or `undefined` when the column was not declared. */ +export const findColumn = (columns, name) => columns.find((column) => column.name === name); + +/** The header a column shows: an explicit override, its declared display name, or the platform's inferred name. */ +export const getColumnTitle = (columns, name, overrides = {}) => + overrides[name] || findColumn(columns, name)?.displayName || toInferredTitle(name); + +/** + * The SmartViz field type of a declared column, or `undefined` when the column or its shape is unknown. + * Callers fall back to the type the channel's role implies. + */ +export const findFieldType = (columns, name) => { + const column = findColumn(columns, name); + + if (column?.groupKind) { + return column.groupKind; + } + + if (!column?.shapeName) { + return undefined; + } + + if (column.shapeName === 'date') { + return 'temporal'; + } + + return NUMERIC_SHAPES.has(column.shapeName) ? 'quantitative' : 'nominal'; +}; + +/** + * True unless the tile's full column list is known and the name is not on it. Columns the platform expands + * from object values at run time (`name[Expanded].…`) are never declared, so they count as known. + */ +export const isKnownColumn = ({ columns, isComplete }, name) => + !isComplete || Boolean(findColumn(columns, name)) || name.includes('[Expanded]'); + +/** The tile's only visible column of a field type, or `undefined` when there are none or several. */ +export const findSoleColumnOfType = ({ columns }, type) => { + const matches = columns.filter((column) => column.isVisible && findFieldType(columns, column.name) === type); + + return matches.length === 1 ? matches[0].name : undefined; +}; + +/** Guesses whether an x-axis column holds time values from its name, for columns with no declared shape. */ +export const isTimeLikeName = (name) => TIME_LIKE_NAME.test(name); diff --git a/scripts/classic-to-smartviz/convert.mjs b/scripts/classic-to-smartviz/convert.mjs new file mode 100644 index 00000000..63462cda --- /dev/null +++ b/scripts/classic-to-smartviz/convert.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { toBarChart, toDonut, toGauge, toLineChart, toScalar } from './chartMappers.mjs'; +import { getTileColumns, isKnownColumn, isPropertiesTile, readStreamColumns } from './columns.mjs'; +import { replaceMembers } from './jsonEdit.mjs'; +import { toRichTable, toStatusGrid } from './tableMappers.mjs'; + +const USAGE = `Converts classic data-stream tiles in plugin default dashboards to SmartViz (vizSpec) tiles. + +Usage: node scripts/classic-to-smartviz/convert.mjs [options] ... + +Options: + --write Write the converted dashboards (default: report only) + --bump With --write, bump metadata.json by patch or minor in each folder that changed + --schema CommonJS build of @squaredup/viz-spec (or a bundle exporting vizSpecSchema) to validate + every spec against; a spec that fails is left classic + --json Also write the report as JSON`; + +const MAPPERS = { + 'data-stream-table': toRichTable, + 'data-stream-blocks': toStatusGrid, + 'data-stream-line-graph': toLineChart, + 'data-stream-bar-chart': toBarChart, + 'data-stream-scalar': toScalar, + 'data-stream-gauge': toGauge, + 'data-stream-donut-chart': toDonut +}; + +const STYLE_FIELD_KEYS = ['richTableRowLinkField', 'statusGridOrderByField', 'cartesianYAxisUnitColumn', 'cartesianY2AxisField']; + +const findDashboardFiles = (directory) => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return findDashboardFiles(entryPath); + } + + return entry.name.endsWith('.dash.json') ? [entryPath] : []; + }); + +const findDefaultContentDirectory = (pluginDirectory) => + ['defaultContent', 'DefaultContent'].map((name) => path.join(pluginDirectory, name)).find((candidate) => fs.existsSync(candidate)); + +/** Every column name a spec refers to: encoding channels, table columns and their link labels, and style fields. */ +const getSpecFields = (visualization) => { + const channels = Object.values(visualization.encoding ?? {}).flatMap((entry) => (Array.isArray(entry) ? entry : [entry])); + const encodingFields = channels.flatMap((channel) => [channel?.field, channel?.renderer?.labelField]); + const styleFields = STYLE_FIELD_KEYS.map((key) => visualization.style?.[key]); + + return [...new Set([...encodingFields, ...styleFields].filter((field) => typeof field === 'string'))]; +}; + +const getKeyPaths = (value, prefix = '') => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.entries(value).flatMap(([key, child]) => [`${prefix}${key}`, ...getKeyPaths(child, `${prefix}${key}.`)]) + : Array.isArray(value) + ? value.flatMap((item, index) => getKeyPaths(item, `${prefix}${index}.`)) + : []; + +/** + * Schema-checks a spec, returning the problems as text. Zod drops keys it does not know without failing, + * so a key missing from the parsed result counts as a problem too. + */ +const getSchemaProblems = (vizSpecSchema, spec) => { + const result = vizSpecSchema.safeParse(spec); + + if (!result.success) { + return result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`); + } + + const keptPaths = new Set(getKeyPaths(result.data)); + + return getKeyPaths(spec) + .filter((keyPath) => !keptPaths.has(keyPath)) + .map((keyPath) => `${keyPath}: dropped by the schema`); +}; + +const toIndentUnit = (text) => /\n([ \t]+)"/u.exec(text)?.[1] ?? ' '; + +/** Converts one tile, returning its report entry and, when it converts, the spec to write. */ +const convertTile = (tileConfig, streamColumnsByName, vizSpecSchema) => { + const title = tileConfig.title || '(untitled)'; + + if (tileConfig.vizSpec) { + return { title, status: 'skipped', reason: 'already SmartViz' }; + } + + if (isPropertiesTile(tileConfig)) { + return { title, status: 'skipped', reason: 'built-in properties stream stays classic' }; + } + + const classicType = tileConfig.visualisation?.type; + const mapper = MAPPERS[classicType]; + + if (!mapper) { + return { title, status: 'skipped', reason: `no converter for ${classicType ?? 'a tile with no visualisation'}` }; + } + + const tileColumns = getTileColumns(tileConfig, streamColumnsByName); + const { spec: visualization, notes = [], blockedReason } = mapper(tileConfig.visualisation.config?.[classicType] ?? {}, tileColumns); + + if (blockedReason) { + return { title, classicType, status: 'blocked', reason: blockedReason }; + } + + const spec = { version: '1.0', visualization }; + const schemaProblems = vizSpecSchema ? getSchemaProblems(vizSpecSchema, spec) : []; + + if (schemaProblems.length > 0) { + return { title, classicType, status: 'blocked', reason: `invalid spec: ${schemaProblems.join('; ')}` }; + } + + const unknownFields = getSpecFields(visualization).filter((field) => !isKnownColumn(tileColumns, field)); + + if (unknownFields.length > 0) { + return { title, classicType, status: 'blocked', reason: `refers to columns the tile does not have: ${unknownFields.join(', ')}` }; + } + + return { + title, + classicType, + status: 'converted', + chartType: visualization.chartType, + columnSource: tileColumns.source, + notes, + spec + }; +}; + +const convertDashboard = (filePath, streamColumnsByName, { vizSpecSchema, write }) => { + const text = fs.readFileSync(filePath, 'utf8'); + const contents = JSON.parse(text).dashboard?.contents ?? []; + + const tiles = contents + .map((item, index) => ({ index, config: item?.config })) + .filter(({ config }) => config?._type === 'tile/data-stream') + .map(({ index, config }) => ({ index, ...convertTile(config, streamColumnsByName, vizSpecSchema) })); + + const replacements = tiles + .filter((tile) => tile.status === 'converted') + .map((tile) => ({ + memberPath: ['dashboard', 'contents', tile.index, 'config', 'visualisation'], + key: 'vizSpec', + value: tile.spec, + indentUnit: toIndentUnit(text) + })); + + if (write && replacements.length > 0) { + fs.writeFileSync(filePath, replaceMembers(text, replacements)); + } + + return { file: filePath, tiles: tiles.map(({ spec, index, ...tile }) => tile) }; +}; + +const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/u; + +/** Bumps the `version` in a plugin folder's metadata.json in place, returning the old and new versions. */ +const bumpVersion = (pluginDirectory, level) => { + const metadataPath = path.join(pluginDirectory, 'metadata.json'); + const text = fs.readFileSync(metadataPath, 'utf8'); + const { version } = JSON.parse(text); + const [, major, minor, patch] = SEMVER.exec(version) ?? []; + + if (major === undefined) { + throw new Error(`${metadataPath}: version ${version} is not x.y.z`); + } + + const nextVersion = level === 'minor' ? `${major}.${Number(minor) + 1}.0` : `${major}.${minor}.${Number(patch) + 1}`; + fs.writeFileSync(metadataPath, text.replace(`"version": "${version}"`, `"version": "${nextVersion}"`)); + + return { from: version, to: nextVersion }; +}; + +const STATUS_MARKS = { converted: '✔', blocked: '✖', skipped: '·' }; + +const printReport = (plugins) => { + for (const plugin of plugins) { + console.log(`\n${plugin.folder}${plugin.version ? ` (${plugin.version.from} → ${plugin.version.to})` : ''}`); + + for (const dashboard of plugin.dashboards) { + console.log(` ${path.basename(dashboard.file)}`); + + for (const tile of dashboard.tiles) { + const detail = tile.status === 'converted' ? `${tile.classicType} → ${tile.chartType}` : tile.reason; + console.log(` ${STATUS_MARKS[tile.status]} ${tile.title}: ${detail}`); + (tile.notes ?? []).forEach((note) => console.log(` note: ${note}`)); + } + } + } + + const tiles = plugins.flatMap((plugin) => plugin.dashboards.flatMap((dashboard) => dashboard.tiles)); + const countOf = (status) => tiles.filter((tile) => tile.status === status).length; + console.log(`\n${countOf('converted')} converted, ${countOf('blocked')} blocked, ${countOf('skipped')} skipped`); +}; + +const loadVizSpecSchema = (schemaPath) => { + if (!schemaPath) { + return undefined; + } + + const { vizSpecSchema } = createRequire(import.meta.url)(path.resolve(schemaPath)); + + if (!vizSpecSchema?.safeParse) { + throw new Error(`${schemaPath} does not export vizSpecSchema`); + } + + return vizSpecSchema; +}; + +const main = () => { + const { values: options, positionals: folders } = parseArgs({ + allowPositionals: true, + options: { + write: { type: 'boolean', default: false }, + bump: { type: 'string' }, + schema: { type: 'string' }, + json: { type: 'string' }, + help: { type: 'boolean', default: false } + } + }); + + if (options.help || folders.length === 0) { + console.log(USAGE); + process.exit(options.help ? 0 : 1); + } + + if (options.bump && !['patch', 'minor'].includes(options.bump)) { + throw new Error('--bump must be patch or minor'); + } + + const vizSpecSchema = loadVizSpecSchema(options.schema); + + const plugins = folders.map((folder) => { + const defaultContentDirectory = findDefaultContentDirectory(folder); + const streamColumnsByName = readStreamColumns(folder); + const dashboards = defaultContentDirectory + ? findDashboardFiles(defaultContentDirectory).map((file) => + convertDashboard(file, streamColumnsByName, { vizSpecSchema, write: options.write }) + ) + : []; + const hasConversions = dashboards.some((dashboard) => dashboard.tiles.some((tile) => tile.status === 'converted')); + const version = options.write && options.bump && hasConversions ? bumpVersion(folder, options.bump) : undefined; + + return { folder, version, dashboards }; + }); + + printReport(plugins); + + if (options.json) { + fs.writeFileSync(options.json, `${JSON.stringify(plugins, null, 4)}\n`); + } +}; + +main(); diff --git a/scripts/classic-to-smartviz/jsonEdit.mjs b/scripts/classic-to-smartviz/jsonEdit.mjs new file mode 100644 index 00000000..0b8e134a --- /dev/null +++ b/scripts/classic-to-smartviz/jsonEdit.mjs @@ -0,0 +1,141 @@ +/** + * Parses JSON text into a tree that records where each value, and each object member's key, starts and ends, + * so a member can be replaced without reformatting the rest of the file. + */ +const parseTree = (text) => { + let position = 0; + + const skipWhitespace = () => { + while (/\s/u.test(text[position] ?? '')) { + position++; + } + }; + + const fail = (expected) => { + throw new Error(`Expected ${expected} at offset ${position}`); + }; + + const parseString = () => { + const start = position; + position++; + + while (text[position] !== '"') { + if (position >= text.length) { + fail('closing quote'); + } + + position += text[position] === '\\' ? 2 : 1; + } + + position++; + + return JSON.parse(text.slice(start, position)); + }; + + const parseValue = () => { + skipWhitespace(); + const start = position; + const character = text[position]; + + if (character === '{') { + position++; + const members = []; + skipWhitespace(); + + while (text[position] !== '}') { + skipWhitespace(); + const keyStart = position; + const key = parseString(); + skipWhitespace(); + text[position] === ':' ? position++ : fail(':'); + members.push({ key, keyStart, value: parseValue() }); + skipWhitespace(); + + if (text[position] === ',') { + position++; + skipWhitespace(); + } + } + + position++; + + return { kind: 'object', start, end: position, members }; + } + + if (character === '[') { + position++; + const items = []; + skipWhitespace(); + + while (text[position] !== ']') { + items.push(parseValue()); + skipWhitespace(); + + if (text[position] === ',') { + position++; + } + + skipWhitespace(); + } + + position++; + + return { kind: 'array', start, end: position, items }; + } + + if (character === '"') { + parseString(); + + return { kind: 'scalar', start, end: position }; + } + + const literal = /^(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/u.exec(text.slice(position)); + + if (!literal) { + fail('a JSON value'); + } + + position += literal[0].length; + + return { kind: 'scalar', start, end: position }; + }; + + return parseValue(); +}; + +const getChild = (node, step) => + typeof step === 'number' ? node?.items?.[step] : node?.members?.find((member) => member.key === step)?.value; + +const getIndent = (text, offset) => { + const lineStart = text.lastIndexOf('\n', offset - 1) + 1; + + return /^[ \t]*/u.exec(text.slice(lineStart))[0]; +}; + +/** + * Replaces the member at `memberPath` (keys and array indexes, ending in the member's key) with a new key and + * value, in place, indenting the new value to match the line the member starts on. Everything else in the + * text is left byte for byte. Replacements are applied from the end of the file backwards, so their offsets + * stay valid. + */ +export const replaceMembers = (text, replacements) => { + const tree = parseTree(text); + + const edits = replacements.map(({ memberPath, key, value, indentUnit = ' ' }) => { + const parent = memberPath.slice(0, -1).reduce(getChild, tree); + const member = parent?.members?.find((candidate) => candidate.key === memberPath[memberPath.length - 1]); + + if (!member) { + throw new Error(`No member at ${memberPath.join('.')}`); + } + + const indent = getIndent(text, member.keyStart); + const serialized = JSON.stringify(value, null, indentUnit).replace(/\n/gu, `\n${indent}`); + + return { start: member.keyStart, end: member.value.end, replacement: `${JSON.stringify(key)}: ${serialized}` }; + }); + + return edits + .sort((a, b) => b.start - a.start) + .reduce((current, edit) => current.slice(0, edit.start) + edit.replacement + current.slice(edit.end), text); +}; diff --git a/scripts/classic-to-smartviz/tableMappers.mjs b/scripts/classic-to-smartviz/tableMappers.mjs new file mode 100644 index 00000000..43bff855 --- /dev/null +++ b/scripts/classic-to-smartviz/tableMappers.mjs @@ -0,0 +1,184 @@ +import { findColumn, getColumnTitle, isKnownColumn } from './columns.mjs'; + +const MAX_TABLE_COLUMNS = 12; + +const isSet = (value) => value !== undefined && value !== null && value !== '' && value !== 'auto' && value !== 'none'; + +/** Wraps a Handlebars template so the platform's install-time templating leaves it for SmartViz to resolve. */ +export const toRawTemplate = (template) => `{{{{raw}}}}${template}{{{{/raw}}}}`; + +const COLUMN_REFERENCE = /^\{\{\s*column\.([^}\s]+)\s*\}\}$/u; + +/** + * Picks the richTable renderer that shows a column the way the classic table did. Text, date and status + * renderers show the shape-formatted value, so units and decimals carry over unchanged. + */ +const toRenderer = (column) => { + switch (column?.shapeName) { + case 'state': + return { type: 'status' }; + case 'date': + return { type: 'date' }; + case 'url': { + const labelField = COLUMN_REFERENCE.exec(String(column.shapeConfig?.label ?? ''))?.[1]; + + return labelField ? { type: 'link', labelField } : { type: 'link' }; + } + default: + return { type: 'text' }; + } +}; + +/** + * The columns a classic table shows, in order. `columnOrder` is the author's list of shown columns, as the + * in-product SmartViz migration reads it; `hiddenColumns` always wins. Like classic, names the data no longer + * has are dropped, and with none left (or no `columnOrder`) every visible column shows. + */ +const getShownColumnNames = (classicConfig, tileColumns, notes) => { + const hiddenColumns = new Set(classicConfig.hiddenColumns ?? []); + const columnOrder = (Array.isArray(classicConfig.columnOrder) ? classicConfig.columnOrder : []).filter( + (name) => !hiddenColumns.has(name) + ); + const knownNames = columnOrder.filter((name) => isKnownColumn(tileColumns, name)); + const staleNames = columnOrder.filter((name) => !knownNames.includes(name)); + + if (staleNames.length > 0) { + notes.push(`dropped column(s) the tile no longer has: ${staleNames.join(', ')}`); + } + + if (knownNames.length > 0) { + return { names: knownNames }; + } + + if (!tileColumns.isComplete) { + return { blockedReason: 'shows all columns, and the stream does not declare its full column list' }; + } + + return { + names: tileColumns.columns + .filter((column) => column.isVisible && !hiddenColumns.has(column.name)) + .map((column) => column.name) + }; +}; + +const getRowLinkField = (classicConfig) => { + const rowLink = classicConfig.rowLinkColumnName; + const field = typeof rowLink === 'string' ? rowLink : rowLink?.value; + + return field && field !== 'None' ? field : undefined; +}; + +/** Converts a classic `data-stream-table` config to a richTable spec. */ +export const toRichTable = (classicConfig, tileColumns) => { + const notes = []; + const { names, blockedReason } = getShownColumnNames(classicConfig, tileColumns, notes); + + if (blockedReason) { + return { blockedReason }; + } + + if (names.length === 0) { + return { blockedReason: 'no columns to show' }; + } + + if (names.length > MAX_TABLE_COLUMNS) { + return { blockedReason: `shows ${names.length} columns; a SmartViz table holds at most ${MAX_TABLE_COLUMNS}` }; + } + + const { columns } = tileColumns; + const displayNameOverrides = classicConfig.columnDisplayNames ?? {}; + const undeclaredNames = names.filter((name) => !findColumn(columns, name)); + const rowLinkField = getRowLinkField(classicConfig); + + if (undeclaredNames.length > 0) { + notes.push(`header and renderer inferred for undeclared column(s): ${undeclaredNames.join(', ')}`); + } + + const style = { + ...(classicConfig.transpose === true && { richTableTranspose: true }), + ...(rowLinkField && isKnownColumn(tileColumns, rowLinkField) && { richTableRowLinkField: rowLinkField }) + }; + + return { + spec: { + chartType: 'richTable', + encoding: { + columns: names.map((name) => ({ + field: name, + title: getColumnTitle(columns, name, displayNameOverrides), + renderer: toRenderer(findColumn(columns, name)) + })) + }, + ...(Object.keys(style).length > 0 && { style }) + }, + notes + }; +}; + +const toNominalChannel = (field) => ({ field, type: 'nominal' }); + +/** + * A configured column if the tile still has it, otherwise the first column `fallback` finds. Classic blocks + * resolve a missing state or link column the same way. + */ +const resolveColumn = (tileColumns, configured, fallback, label, notes) => { + if (isSet(configured) && isKnownColumn(tileColumns, configured)) { + return configured; + } + + if (isSet(configured)) { + notes.push(`${label} column ${configured} no longer exists`); + } + + return tileColumns.columns.find(fallback)?.name; +}; + +/** Converts a classic `data-stream-blocks` config to a statusGrid spec. */ +export const toStatusGrid = (classicConfig, tileColumns) => { + const notes = []; + + const labelField = resolveColumn(tileColumns, classicConfig.labelColumn, (column) => column.role === 'label', 'label', notes); + + if (!labelField) { + return { blockedReason: 'no label column the tile still has, and the stream declares no label role' }; + } + + const statusField = resolveColumn(tileColumns, classicConfig.stateColumn, (column) => column.shapeName === 'state', 'state', notes); + const linkField = resolveColumn( + tileColumns, + classicConfig.linkColumn, + (column) => column.role === 'link' || column.shapeName === 'url', + 'link', + notes + ); + const groupField = resolveColumn(tileColumns, classicConfig.groupBy, () => false, 'group', notes); + const orderField = resolveColumn(tileColumns, classicConfig.orderBy, () => false, 'order', notes); + const sublabelField = resolveColumn(tileColumns, classicConfig.sublabel, () => false, 'sublabel', notes); + const blockHeight = Number(classicConfig.blockHeight); + + if (!statusField) { + notes.push('no state column found; blocks show with no status'); + } + + const style = { + ...(typeof classicConfig.columns === 'number' && { statusGridColumnsPerRow: classicConfig.columns }), + ...(sublabelField && { statusGridSecondaryTemplate: toRawTemplate(`{{${sublabelField}}}`) }), + ...(classicConfig.wrapLabels === true && { statusGridLabelWrap: true }), + ...(orderField && { statusGridOrderByField: orderField }), + ...(blockHeight >= 50 && blockHeight <= 310 && { statusGridMaxBlockHeight: Math.round(blockHeight) }) + }; + + return { + spec: { + chartType: 'statusGrid', + encoding: { + label: toNominalChannel(labelField), + ...(statusField && { status: toNominalChannel(statusField) }), + ...(groupField && { group: toNominalChannel(groupField) }), + ...(linkField && { link: toNominalChannel(linkField) }) + }, + ...(Object.keys(style).length > 0 && { style }) + }, + notes: [...notes, ...(findColumn(tileColumns.columns, labelField) ? [] : [`label column ${labelField} is not declared by the stream`])] + }; +}; From 8594c3f99c7e8516d03013bb4783ba295c54d91f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:46:31 +0000 Subject: [PATCH 02/16] Convert the TfL, World Cup 2026 and OpenF1 dashboards to SmartViz The first wave of the SmartViz OOB upgrade, done with scripts/classic-to-smartviz. These three plugins need no credentials, so their dashboards can be checked against live data. - TransportForLondon 1.0.7: the arrivals table and the line status blocks (now a status grid, with the status description as each block's second line). - WorldCup2026 1.2.3: the four team tables. - OpenF1 1.1.1: nine tables and the lap times chart. Some tiles stay classic. The OpenF1 properties tables and the driver photo stay as they are, as on every SmartViz-first plugin. Weather plots air and track temperature, which are two of the stream's seven numeric columns, and SmartViz can plot only one y column or all of them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- .../v1/defaultContent/meeting.dash.json | 42 +- .../v1/defaultContent/overview.dash.json | 100 ++-- .../v1/defaultContent/session.dash.json | 442 ++++++++++++++---- plugins/OpenF1/v1/metadata.json | 2 +- .../v1/defaultContent/arrivals.dash.json | 56 ++- .../v1/defaultContent/lineStatus.dash.json | 28 +- plugins/TransportForLondon/v1/metadata.json | 2 +- .../v1/defaultContent/team.dash.json | 245 ++++++++-- plugins/WorldCup2026/v1/metadata.json | 2 +- 9 files changed, 742 insertions(+), 177 deletions(-) diff --git a/plugins/OpenF1/v1/defaultContent/meeting.dash.json b/plugins/OpenF1/v1/defaultContent/meeting.dash.json index b0faf707..a764396e 100644 --- a/plugins/OpenF1/v1/defaultContent/meeting.dash.json +++ b/plugins/OpenF1/v1/defaultContent/meeting.dash.json @@ -80,13 +80,41 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Meeting]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": ["session_name", "session_type", "date_start", "date_end"], - "hiddenColumns": ["session_key"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "session_name", + "title": "Session", + "renderer": { + "type": "text" + } + }, + { + "field": "session_type", + "title": "Type", + "renderer": { + "type": "text" + } + }, + { + "field": "date_start", + "title": "Start", + "renderer": { + "type": "date" + } + }, + { + "field": "date_end", + "title": "End", + "renderer": { + "type": "date" + } + } + ] } } } diff --git a/plugins/OpenF1/v1/defaultContent/overview.dash.json b/plugins/OpenF1/v1/defaultContent/overview.dash.json index abefd784..8dd20ca9 100644 --- a/plugins/OpenF1/v1/defaultContent/overview.dash.json +++ b/plugins/OpenF1/v1/defaultContent/overview.dash.json @@ -59,23 +59,47 @@ ] } }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "position_current", - "driver_number", - "driverName", - "driverTeam", - "points_current", - "position_start", - "points_start" - ], - "hiddenColumns": [ - "position_start", - "points_start" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "position_current", + "title": "Position", + "renderer": { + "type": "text" + } + }, + { + "field": "driver_number", + "title": "Driver #", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "driverTeam", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "points_current", + "title": "Points", + "renderer": { + "type": "text" + } + } ] } } @@ -112,21 +136,33 @@ ] } }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "position_current", - "team_name", - "points_current", - "position_start", - "points_start" - ], - "hiddenColumns": [ - "position_start", - "points_start" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "position_current", + "title": "Position", + "renderer": { + "type": "text" + } + }, + { + "field": "team_name", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "points_current", + "title": "Points", + "renderer": { + "type": "text" + } + } ] } } diff --git a/plugins/OpenF1/v1/defaultContent/session.dash.json b/plugins/OpenF1/v1/defaultContent/session.dash.json index f95691a6..0ef33431 100644 --- a/plugins/OpenF1/v1/defaultContent/session.dash.json +++ b/plugins/OpenF1/v1/defaultContent/session.dash.json @@ -34,24 +34,83 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "position", - "driver_number", - "driverName", - "number_of_laps", - "points", - "gapToLeader", - "duration", - "dnf", - "dns", - "dsq" - ], - "hiddenColumns": ["gap_to_leader"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "position", + "title": "Position", + "renderer": { + "type": "text" + } + }, + { + "field": "driver_number", + "title": "Car Number", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "number_of_laps", + "title": "Laps", + "renderer": { + "type": "text" + } + }, + { + "field": "points", + "title": "Points", + "renderer": { + "type": "text" + } + }, + { + "field": "gapToLeader", + "title": "Gap to Leader (s)", + "renderer": { + "type": "text" + } + }, + { + "field": "duration", + "title": "Race Time", + "renderer": { + "type": "text" + } + }, + { + "field": "dnf", + "title": "DNF", + "renderer": { + "type": "text" + } + }, + { + "field": "dns", + "title": "DNS", + "renderer": { + "type": "text" + } + }, + { + "field": "dsq", + "title": "DSQ", + "renderer": { + "type": "text" + } + } + ] } } } @@ -84,13 +143,48 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": ["driver_number", "full_name", "name_acronym", "team_name", "country_code"], - "hiddenColumns": ["headshot_url", "team_colour"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "driver_number", + "title": "Number", + "renderer": { + "type": "text" + } + }, + { + "field": "full_name", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "name_acronym", + "title": "Code", + "renderer": { + "type": "text" + } + }, + { + "field": "team_name", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "country_code", + "title": "Country", + "renderer": { + "type": "text" + } + } + ] } } } @@ -123,18 +217,30 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-line-graph", - "config": { - "data-stream-line-graph": { - "xAxisColumn": "lap_number", - "yAxisColumn": ["lap_duration"], - "seriesColumn": "driver_number", - "showLegend": true, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "lap_number", + "type": "quantitative" + }, + "y": { + "field": "lap_duration", + "type": "quantitative" + }, + "series": { + "field": "driver_number", + "type": "nominal" + } + }, + "style": { "legendPosition": "bottom", - "yAxisLabel": "Lap Time (s)", - "showYAxisLabel": true, - "showTrendLine": false + "cartesianShowYAxisTitle": true, + "cartesianYAxisTitle": "Lap Time (s)", + "cartesianYAxisRangeMode": "fit", + "cartesianSymbol": "none" } } } @@ -167,19 +273,61 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "driver_number", - "driverName", - "stint_number", - "compound", - "lap_start", - "lap_end", - "tyre_age_at_start" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "driver_number", + "title": "Driver Number", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "stint_number", + "title": "Stint", + "renderer": { + "type": "text" + } + }, + { + "field": "compound", + "title": "Compound", + "renderer": { + "type": "text" + } + }, + { + "field": "lap_start", + "title": "First Lap", + "renderer": { + "type": "text" + } + }, + { + "field": "lap_end", + "title": "Last Lap", + "renderer": { + "type": "text" + } + }, + { + "field": "tyre_age_at_start", + "title": "Tyre Age at Start (laps)", + "renderer": { + "type": "text" + } + } ] } } @@ -213,19 +361,61 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "driver_number", - "driverName", - "lap_number", - "date", - "pit_duration", - "lane_duration", - "stop_duration" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "driver_number", + "title": "Driver Number", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "lap_number", + "title": "Lap", + "renderer": { + "type": "text" + } + }, + { + "field": "date", + "title": "Time", + "renderer": { + "type": "date" + } + }, + { + "field": "pit_duration", + "title": "Pit Duration", + "renderer": { + "type": "text" + } + }, + { + "field": "lane_duration", + "title": "Lane Duration", + "renderer": { + "type": "text" + } + }, + { + "field": "stop_duration", + "title": "Stationary Time", + "renderer": { + "type": "text" + } + } ] } } @@ -303,19 +493,61 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "date", - "category", - "flag", - "message", - "lap_number", - "driver_number", - "driverName" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "date", + "title": "Time", + "renderer": { + "type": "date" + } + }, + { + "field": "category", + "title": "Category", + "renderer": { + "type": "text" + } + }, + { + "field": "flag", + "title": "Flag", + "renderer": { + "type": "text" + } + }, + { + "field": "message", + "title": "Message", + "renderer": { + "type": "text" + } + }, + { + "field": "lap_number", + "title": "Lap", + "renderer": { + "type": "text" + } + }, + { + "field": "driver_number", + "title": "Driver #", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + } ] } } @@ -349,18 +581,54 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "date", - "overtaking_driver_number", - "overtakingDriver", - "overtaken_driver_number", - "overtakenDriver", - "position" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "date", + "title": "Time", + "renderer": { + "type": "date" + } + }, + { + "field": "overtaking_driver_number", + "title": "Overtaking Driver #", + "renderer": { + "type": "text" + } + }, + { + "field": "overtakingDriver", + "title": "Overtaking Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "overtaken_driver_number", + "title": "Overtaken Driver #", + "renderer": { + "type": "text" + } + }, + { + "field": "overtakenDriver", + "title": "Overtaken Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "position", + "title": "New Position", + "renderer": { + "type": "text" + } + } ] } } diff --git a/plugins/OpenF1/v1/metadata.json b/plugins/OpenF1/v1/metadata.json index e8c793cd..9363ae35 100644 --- a/plugins/OpenF1/v1/metadata.json +++ b/plugins/OpenF1/v1/metadata.json @@ -1,7 +1,7 @@ { "name": "openf1", "displayName": "OpenF1", - "version": "1.1.0", + "version": "1.1.1", "author": { "name": "@clarkd", "type": "community" diff --git a/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json b/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json index fa2e0834..b2d2cedf 100644 --- a/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json +++ b/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json @@ -31,22 +31,50 @@ "variables": [ "{{variables.Station}}" ], - "visualisation": { - "config": { - "data-stream-table": { - "columnOrder": [ - "timeToStation", - "platformName", - "towards", - "currentLocation", - "expectedArrival" - ], - "hiddenColumns": [ - "direction" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "timeToStation", + "title": "Time To Station", + "renderer": { + "type": "text" + } + }, + { + "field": "platformName", + "title": "Platform Name", + "renderer": { + "type": "text" + } + }, + { + "field": "towards", + "title": "Towards", + "renderer": { + "type": "text" + } + }, + { + "field": "currentLocation", + "title": "Current Location", + "renderer": { + "type": "text" + } + }, + { + "field": "expectedArrival", + "title": "Expected Arrival", + "renderer": { + "type": "text" + } + } ] } - }, - "type": "data-stream-table" + } } }, "h": 5, diff --git a/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json b/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json index 7d4b9a01..ac179ac0 100644 --- a/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json +++ b/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json @@ -30,14 +30,26 @@ "variables": [ "{{variables.Lines}}" ], - "visualisation": { - "type": "data-stream-blocks", - "config": { - "data-stream-blocks": { - "stateColumn": "status", - "linkColumn": "link", - "sublabel": "statusDescription", - "labelColumn": "name" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "statusGrid", + "encoding": { + "label": { + "field": "name", + "type": "nominal" + }, + "status": { + "field": "status", + "type": "nominal" + }, + "link": { + "field": "link", + "type": "nominal" + } + }, + "style": { + "statusGridSecondaryTemplate": "{{{{raw}}}}{{statusDescription}}{{{{/raw}}}}" } } } diff --git a/plugins/TransportForLondon/v1/metadata.json b/plugins/TransportForLondon/v1/metadata.json index bff48818..4b691485 100644 --- a/plugins/TransportForLondon/v1/metadata.json +++ b/plugins/TransportForLondon/v1/metadata.json @@ -1,7 +1,7 @@ { "name": "transport-for-london", "displayName": "Transport for London (TfL)", - "version": "1.0.6", + "version": "1.0.7", "author": { "name": "@clarkd", "type": "community" diff --git a/plugins/WorldCup2026/v1/defaultContent/team.dash.json b/plugins/WorldCup2026/v1/defaultContent/team.dash.json index 15ab1ca0..d14a48cc 100644 --- a/plugins/WorldCup2026/v1/defaultContent/team.dash.json +++ b/plugins/WorldCup2026/v1/defaultContent/team.dash.json @@ -35,12 +35,51 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": true, - "columnOrder": ["date", "home_away", "opponent", "stage", "group"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "date", + "title": "Date", + "renderer": { + "type": "text" + } + }, + { + "field": "home_away", + "title": "Home / Away", + "renderer": { + "type": "text" + } + }, + { + "field": "opponent", + "title": "Opponent", + "renderer": { + "type": "text" + } + }, + { + "field": "stage", + "title": "Stage", + "renderer": { + "type": "text" + } + }, + { + "field": "group", + "title": "Group", + "renderer": { + "type": "text" + } + } + ] + }, + "style": { + "richTableTranspose": true } } } @@ -72,12 +111,58 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": true, - "columnOrder": ["date", "home_away", "opponent", "score", "result", "stage"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "date", + "title": "Date", + "renderer": { + "type": "text" + } + }, + { + "field": "home_away", + "title": "Home / Away", + "renderer": { + "type": "text" + } + }, + { + "field": "opponent", + "title": "Opponent", + "renderer": { + "type": "text" + } + }, + { + "field": "score", + "title": "Score", + "renderer": { + "type": "text" + } + }, + { + "field": "result", + "title": "Result", + "renderer": { + "type": "status" + } + }, + { + "field": "stage", + "title": "Stage", + "renderer": { + "type": "text" + } + } + ] + }, + "style": { + "richTableTranspose": true } } } @@ -109,13 +194,76 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": ["team", "mp", "w", "d", "l", "gf", "ga", "gd", "pts"], - "hiddenColumns": ["group"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "team", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "mp", + "title": "Played", + "renderer": { + "type": "text" + } + }, + { + "field": "w", + "title": "W", + "renderer": { + "type": "text" + } + }, + { + "field": "d", + "title": "D", + "renderer": { + "type": "text" + } + }, + { + "field": "l", + "title": "L", + "renderer": { + "type": "text" + } + }, + { + "field": "gf", + "title": "GF", + "renderer": { + "type": "text" + } + }, + { + "field": "ga", + "title": "GA", + "renderer": { + "type": "text" + } + }, + { + "field": "gd", + "title": "GD", + "renderer": { + "type": "text" + } + }, + { + "field": "pts", + "title": "Points", + "renderer": { + "type": "text" + } + } + ] } } } @@ -147,13 +295,58 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": true, - "columnOrder": ["country", "group", "gf", "ga", "gd", "pts"], - "hiddenColumns": ["mp", "w", "d", "l", "sourceId"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "country", + "title": "Country", + "renderer": { + "type": "text" + } + }, + { + "field": "group", + "title": "Group", + "renderer": { + "type": "text" + } + }, + { + "field": "gf", + "title": "Goals For", + "renderer": { + "type": "text" + } + }, + { + "field": "ga", + "title": "Goals Against", + "renderer": { + "type": "text" + } + }, + { + "field": "gd", + "title": "Goal Difference", + "renderer": { + "type": "text" + } + }, + { + "field": "pts", + "title": "Points", + "renderer": { + "type": "text" + } + } + ] + }, + "style": { + "richTableTranspose": true } } } diff --git a/plugins/WorldCup2026/v1/metadata.json b/plugins/WorldCup2026/v1/metadata.json index 71bb24d0..aba977f5 100644 --- a/plugins/WorldCup2026/v1/metadata.json +++ b/plugins/WorldCup2026/v1/metadata.json @@ -1,7 +1,7 @@ { "name": "worldcup2026", "displayName": "FIFA World Cup 2026", - "version": "1.2.2", + "version": "1.2.3", "author": { "name": "@TimWheeler-SQUP", "type": "community" From 20bbd6accb18cc6a7ac4bec747596a03dda23d87 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:46:31 +0000 Subject: [PATCH 03/16] Name OpenF1 lap time lines after the driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lap times chart split its lines by car number, so the legend read "1, 4, 11, 16…". The laps stream already carries the driver's name, which the session tables show, so the lines now use that instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- plugins/OpenF1/v1/defaultContent/session.dash.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/OpenF1/v1/defaultContent/session.dash.json b/plugins/OpenF1/v1/defaultContent/session.dash.json index 0ef33431..6556659f 100644 --- a/plugins/OpenF1/v1/defaultContent/session.dash.json +++ b/plugins/OpenF1/v1/defaultContent/session.dash.json @@ -231,7 +231,7 @@ "type": "quantitative" }, "series": { - "field": "driver_number", + "field": "driverName", "type": "nominal" } }, From 8d8cf737cbb3d4b84566600dcb1dd2e7e0fcc9cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:40:03 +0000 Subject: [PATCH 04/16] Show mapped state values as coloured badges in converted tables Before/after screenshots of the pilot showed the World Cup "Last Match" result reading "Success" in SmartViz where classic showed "Win". The state shape maps Win/Loss/Draw to success/error/unknown, and the status renderer shows the mapped state. Classic got away with it because it appended the platform's "Result (raw)" column. A state column with a value map now converts to that raw column, `[Expanded].rawState`, with a badge renderer coloured by the map, so the cell reads "Win" in green. The World Cup tile was converted before this rule existed, so it is updated by hand to match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- .../v1/defaultContent/team.dash.json | 9 +++++-- scripts/classic-to-smartviz/README.md | 2 +- scripts/classic-to-smartviz/tableMappers.mjs | 26 ++++++++++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/plugins/WorldCup2026/v1/defaultContent/team.dash.json b/plugins/WorldCup2026/v1/defaultContent/team.dash.json index d14a48cc..ab053f62 100644 --- a/plugins/WorldCup2026/v1/defaultContent/team.dash.json +++ b/plugins/WorldCup2026/v1/defaultContent/team.dash.json @@ -146,10 +146,15 @@ } }, { - "field": "result", + "field": "result[Expanded].rawState", "title": "Result", "renderer": { - "type": "status" + "type": "badge", + "colors": { + "Win": "#2BB660", + "Loss": "#DE0038", + "Draw": "#94A3B8" + } } }, { diff --git a/scripts/classic-to-smartviz/README.md b/scripts/classic-to-smartviz/README.md index ef97e8d3..93ede3ed 100644 --- a/scripts/classic-to-smartviz/README.md +++ b/scripts/classic-to-smartviz/README.md @@ -27,7 +27,7 @@ node scripts/classic-to-smartviz/convert.mjs --write --bump patch --schema [Expanded].rawState` column, as a badge coloured by the map. | | Status blocks | `statusGrid` | The sublabel becomes a `statusGridSecondaryTemplate`, wrapped in `{{{{raw}}}}` so install-time templating leaves it alone. | | Line graph | `line` | Classic's default fitted y axis and hidden data points carry over. Area shading is kept only when the lines are not split into series. | | Bar chart | `bar` | Grouping, stacking, horizontal layout, value labels and simple colour rules carry over. | diff --git a/scripts/classic-to-smartviz/tableMappers.mjs b/scripts/classic-to-smartviz/tableMappers.mjs index 43bff855..0fed9c85 100644 --- a/scripts/classic-to-smartviz/tableMappers.mjs +++ b/scripts/classic-to-smartviz/tableMappers.mjs @@ -9,6 +9,29 @@ export const toRawTemplate = (template) => `{{{{raw}}}}${template}{{{{/raw}}}}`; const COLUMN_REFERENCE = /^\{\{\s*column\.([^}\s]+)\s*\}\}$/u; +const STATE_COLORS = { success: '#2BB660', info: '#3B82F6', warning: '#F59E0B', error: '#DE0038', unknown: '#94A3B8' }; + +/** + * A state column that maps its own values to states (`Win` → success) keeps those values in a column the + * platform adds, `[Expanded].rawState`, which classic tables showed as " (raw)". Shown as a badge + * coloured by the map, the cell reads "Win" in green rather than "Success". + */ +const toMappedStateColumn = (column) => { + const stateMap = column?.shapeName === 'state' ? column.shapeConfig?.map : undefined; + + if (!stateMap || typeof stateMap !== 'object' || Object.keys(stateMap).length === 0) { + return undefined; + } + + const colors = Object.fromEntries( + Object.entries(stateMap).flatMap(([state, values]) => + STATE_COLORS[state] && Array.isArray(values) ? values.map((value) => [String(value), STATE_COLORS[state]]) : [] + ) + ); + + return { field: `${column.name}[Expanded].rawState`, renderer: { type: 'badge', colors } }; +}; + /** * Picks the richTable renderer that shows a column the way the classic table did. Text, date and status * renderers show the shape-formatted value, so units and decimals carry over unchanged. @@ -106,7 +129,8 @@ export const toRichTable = (classicConfig, tileColumns) => { columns: names.map((name) => ({ field: name, title: getColumnTitle(columns, name, displayNameOverrides), - renderer: toRenderer(findColumn(columns, name)) + renderer: toRenderer(findColumn(columns, name)), + ...toMappedStateColumn(findColumn(columns, name)) })) }, ...(Object.keys(style).length > 0 && { style }) From 6d2b37c5904702640b977be58c7fd36e1dd8c079 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:49:53 +0000 Subject: [PATCH 05/16] Add enhancement rules to the SmartViz converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The converter now improves a tile on top of the like-for-like conversion where the repository alone shows it is safe: relative times for "last seen" style dates, health colours for status words, progress bars for percent columns, a 0–100 axis and gauge range for percentages, and Yes / No for booleans. Each rule works from column shapes, names and the converted spec, so none depends on what the data holds. A rule runs only on tiles converted in that run, so hand-built SmartViz tiles are never touched. Rules are on by default and chosen with --rules (all, none, or a list), and the report shows every rule that fired on each tile. Across the community plugins not yet converted they fire on 45 tiles for status colours, 38 for recency dates, 27 for booleans, 22 for progress bars and 4 for the percent axis. Every rule-enhanced spec passes the viz-spec schema. A rule turning monitor thresholds into gauge bands was dropped. On the OOB tiles that have a monitor, the monitor tests a different column from the one the gauge draws, or counts rows across the whole table. So it would almost never fire. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- .../v1/defaultContent/meeting.dash.json | 42 +- .../v1/defaultContent/overview.dash.json | 100 ++-- .../v1/defaultContent/session.dash.json | 442 ++++-------------- .../v1/defaultContent/arrivals.dash.json | 56 +-- .../v1/defaultContent/lineStatus.dash.json | 28 +- .../v1/defaultContent/team.dash.json | 250 ++-------- scripts/classic-to-smartviz/README.md | 14 + scripts/classic-to-smartviz/convert.mjs | 43 +- .../classic-to-smartviz/enhancementRules.mjs | 169 +++++++ 9 files changed, 394 insertions(+), 750 deletions(-) create mode 100644 scripts/classic-to-smartviz/enhancementRules.mjs diff --git a/plugins/OpenF1/v1/defaultContent/meeting.dash.json b/plugins/OpenF1/v1/defaultContent/meeting.dash.json index a764396e..b0faf707 100644 --- a/plugins/OpenF1/v1/defaultContent/meeting.dash.json +++ b/plugins/OpenF1/v1/defaultContent/meeting.dash.json @@ -80,41 +80,13 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Meeting]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "session_name", - "title": "Session", - "renderer": { - "type": "text" - } - }, - { - "field": "session_type", - "title": "Type", - "renderer": { - "type": "text" - } - }, - { - "field": "date_start", - "title": "Start", - "renderer": { - "type": "date" - } - }, - { - "field": "date_end", - "title": "End", - "renderer": { - "type": "date" - } - } - ] + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["session_name", "session_type", "date_start", "date_end"], + "hiddenColumns": ["session_key"] } } } diff --git a/plugins/OpenF1/v1/defaultContent/overview.dash.json b/plugins/OpenF1/v1/defaultContent/overview.dash.json index 8dd20ca9..abefd784 100644 --- a/plugins/OpenF1/v1/defaultContent/overview.dash.json +++ b/plugins/OpenF1/v1/defaultContent/overview.dash.json @@ -59,47 +59,23 @@ ] } }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "position_current", - "title": "Position", - "renderer": { - "type": "text" - } - }, - { - "field": "driver_number", - "title": "Driver #", - "renderer": { - "type": "text" - } - }, - { - "field": "driverName", - "title": "Driver", - "renderer": { - "type": "text" - } - }, - { - "field": "driverTeam", - "title": "Team", - "renderer": { - "type": "text" - } - }, - { - "field": "points_current", - "title": "Points", - "renderer": { - "type": "text" - } - } + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "position_current", + "driver_number", + "driverName", + "driverTeam", + "points_current", + "position_start", + "points_start" + ], + "hiddenColumns": [ + "position_start", + "points_start" ] } } @@ -136,33 +112,21 @@ ] } }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "position_current", - "title": "Position", - "renderer": { - "type": "text" - } - }, - { - "field": "team_name", - "title": "Team", - "renderer": { - "type": "text" - } - }, - { - "field": "points_current", - "title": "Points", - "renderer": { - "type": "text" - } - } + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "position_current", + "team_name", + "points_current", + "position_start", + "points_start" + ], + "hiddenColumns": [ + "position_start", + "points_start" ] } } diff --git a/plugins/OpenF1/v1/defaultContent/session.dash.json b/plugins/OpenF1/v1/defaultContent/session.dash.json index 6556659f..f95691a6 100644 --- a/plugins/OpenF1/v1/defaultContent/session.dash.json +++ b/plugins/OpenF1/v1/defaultContent/session.dash.json @@ -34,83 +34,24 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "position", - "title": "Position", - "renderer": { - "type": "text" - } - }, - { - "field": "driver_number", - "title": "Car Number", - "renderer": { - "type": "text" - } - }, - { - "field": "driverName", - "title": "Driver", - "renderer": { - "type": "text" - } - }, - { - "field": "number_of_laps", - "title": "Laps", - "renderer": { - "type": "text" - } - }, - { - "field": "points", - "title": "Points", - "renderer": { - "type": "text" - } - }, - { - "field": "gapToLeader", - "title": "Gap to Leader (s)", - "renderer": { - "type": "text" - } - }, - { - "field": "duration", - "title": "Race Time", - "renderer": { - "type": "text" - } - }, - { - "field": "dnf", - "title": "DNF", - "renderer": { - "type": "text" - } - }, - { - "field": "dns", - "title": "DNS", - "renderer": { - "type": "text" - } - }, - { - "field": "dsq", - "title": "DSQ", - "renderer": { - "type": "text" - } - } - ] + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "position", + "driver_number", + "driverName", + "number_of_laps", + "points", + "gapToLeader", + "duration", + "dnf", + "dns", + "dsq" + ], + "hiddenColumns": ["gap_to_leader"] } } } @@ -143,48 +84,13 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "driver_number", - "title": "Number", - "renderer": { - "type": "text" - } - }, - { - "field": "full_name", - "title": "Driver", - "renderer": { - "type": "text" - } - }, - { - "field": "name_acronym", - "title": "Code", - "renderer": { - "type": "text" - } - }, - { - "field": "team_name", - "title": "Team", - "renderer": { - "type": "text" - } - }, - { - "field": "country_code", - "title": "Country", - "renderer": { - "type": "text" - } - } - ] + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["driver_number", "full_name", "name_acronym", "team_name", "country_code"], + "hiddenColumns": ["headshot_url", "team_colour"] } } } @@ -217,30 +123,18 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "line", - "encoding": { - "x": { - "field": "lap_number", - "type": "quantitative" - }, - "y": { - "field": "lap_duration", - "type": "quantitative" - }, - "series": { - "field": "driverName", - "type": "nominal" - } - }, - "style": { + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "xAxisColumn": "lap_number", + "yAxisColumn": ["lap_duration"], + "seriesColumn": "driver_number", + "showLegend": true, "legendPosition": "bottom", - "cartesianShowYAxisTitle": true, - "cartesianYAxisTitle": "Lap Time (s)", - "cartesianYAxisRangeMode": "fit", - "cartesianSymbol": "none" + "yAxisLabel": "Lap Time (s)", + "showYAxisLabel": true, + "showTrendLine": false } } } @@ -273,61 +167,19 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "driver_number", - "title": "Driver Number", - "renderer": { - "type": "text" - } - }, - { - "field": "driverName", - "title": "Driver", - "renderer": { - "type": "text" - } - }, - { - "field": "stint_number", - "title": "Stint", - "renderer": { - "type": "text" - } - }, - { - "field": "compound", - "title": "Compound", - "renderer": { - "type": "text" - } - }, - { - "field": "lap_start", - "title": "First Lap", - "renderer": { - "type": "text" - } - }, - { - "field": "lap_end", - "title": "Last Lap", - "renderer": { - "type": "text" - } - }, - { - "field": "tyre_age_at_start", - "title": "Tyre Age at Start (laps)", - "renderer": { - "type": "text" - } - } + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "driver_number", + "driverName", + "stint_number", + "compound", + "lap_start", + "lap_end", + "tyre_age_at_start" ] } } @@ -361,61 +213,19 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "driver_number", - "title": "Driver Number", - "renderer": { - "type": "text" - } - }, - { - "field": "driverName", - "title": "Driver", - "renderer": { - "type": "text" - } - }, - { - "field": "lap_number", - "title": "Lap", - "renderer": { - "type": "text" - } - }, - { - "field": "date", - "title": "Time", - "renderer": { - "type": "date" - } - }, - { - "field": "pit_duration", - "title": "Pit Duration", - "renderer": { - "type": "text" - } - }, - { - "field": "lane_duration", - "title": "Lane Duration", - "renderer": { - "type": "text" - } - }, - { - "field": "stop_duration", - "title": "Stationary Time", - "renderer": { - "type": "text" - } - } + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "driver_number", + "driverName", + "lap_number", + "date", + "pit_duration", + "lane_duration", + "stop_duration" ] } } @@ -493,61 +303,19 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "date", - "title": "Time", - "renderer": { - "type": "date" - } - }, - { - "field": "category", - "title": "Category", - "renderer": { - "type": "text" - } - }, - { - "field": "flag", - "title": "Flag", - "renderer": { - "type": "text" - } - }, - { - "field": "message", - "title": "Message", - "renderer": { - "type": "text" - } - }, - { - "field": "lap_number", - "title": "Lap", - "renderer": { - "type": "text" - } - }, - { - "field": "driver_number", - "title": "Driver #", - "renderer": { - "type": "text" - } - }, - { - "field": "driverName", - "title": "Driver", - "renderer": { - "type": "text" - } - } + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "date", + "category", + "flag", + "message", + "lap_number", + "driver_number", + "driverName" ] } } @@ -581,54 +349,18 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "date", - "title": "Time", - "renderer": { - "type": "date" - } - }, - { - "field": "overtaking_driver_number", - "title": "Overtaking Driver #", - "renderer": { - "type": "text" - } - }, - { - "field": "overtakingDriver", - "title": "Overtaking Driver", - "renderer": { - "type": "text" - } - }, - { - "field": "overtaken_driver_number", - "title": "Overtaken Driver #", - "renderer": { - "type": "text" - } - }, - { - "field": "overtakenDriver", - "title": "Overtaken Driver", - "renderer": { - "type": "text" - } - }, - { - "field": "position", - "title": "New Position", - "renderer": { - "type": "text" - } - } + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "date", + "overtaking_driver_number", + "overtakingDriver", + "overtaken_driver_number", + "overtakenDriver", + "position" ] } } diff --git a/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json b/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json index b2d2cedf..fa2e0834 100644 --- a/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json +++ b/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json @@ -31,50 +31,22 @@ "variables": [ "{{variables.Station}}" ], - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "timeToStation", - "title": "Time To Station", - "renderer": { - "type": "text" - } - }, - { - "field": "platformName", - "title": "Platform Name", - "renderer": { - "type": "text" - } - }, - { - "field": "towards", - "title": "Towards", - "renderer": { - "type": "text" - } - }, - { - "field": "currentLocation", - "title": "Current Location", - "renderer": { - "type": "text" - } - }, - { - "field": "expectedArrival", - "title": "Expected Arrival", - "renderer": { - "type": "text" - } - } + "visualisation": { + "config": { + "data-stream-table": { + "columnOrder": [ + "timeToStation", + "platformName", + "towards", + "currentLocation", + "expectedArrival" + ], + "hiddenColumns": [ + "direction" ] } - } + }, + "type": "data-stream-table" } }, "h": 5, diff --git a/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json b/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json index ac179ac0..7d4b9a01 100644 --- a/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json +++ b/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json @@ -30,26 +30,14 @@ "variables": [ "{{variables.Lines}}" ], - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "statusGrid", - "encoding": { - "label": { - "field": "name", - "type": "nominal" - }, - "status": { - "field": "status", - "type": "nominal" - }, - "link": { - "field": "link", - "type": "nominal" - } - }, - "style": { - "statusGridSecondaryTemplate": "{{{{raw}}}}{{statusDescription}}{{{{/raw}}}}" + "visualisation": { + "type": "data-stream-blocks", + "config": { + "data-stream-blocks": { + "stateColumn": "status", + "linkColumn": "link", + "sublabel": "statusDescription", + "labelColumn": "name" } } } diff --git a/plugins/WorldCup2026/v1/defaultContent/team.dash.json b/plugins/WorldCup2026/v1/defaultContent/team.dash.json index ab053f62..15ab1ca0 100644 --- a/plugins/WorldCup2026/v1/defaultContent/team.dash.json +++ b/plugins/WorldCup2026/v1/defaultContent/team.dash.json @@ -35,51 +35,12 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "date", - "title": "Date", - "renderer": { - "type": "text" - } - }, - { - "field": "home_away", - "title": "Home / Away", - "renderer": { - "type": "text" - } - }, - { - "field": "opponent", - "title": "Opponent", - "renderer": { - "type": "text" - } - }, - { - "field": "stage", - "title": "Stage", - "renderer": { - "type": "text" - } - }, - { - "field": "group", - "title": "Group", - "renderer": { - "type": "text" - } - } - ] - }, - "style": { - "richTableTranspose": true + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true, + "columnOrder": ["date", "home_away", "opponent", "stage", "group"] } } } @@ -111,63 +72,12 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "date", - "title": "Date", - "renderer": { - "type": "text" - } - }, - { - "field": "home_away", - "title": "Home / Away", - "renderer": { - "type": "text" - } - }, - { - "field": "opponent", - "title": "Opponent", - "renderer": { - "type": "text" - } - }, - { - "field": "score", - "title": "Score", - "renderer": { - "type": "text" - } - }, - { - "field": "result[Expanded].rawState", - "title": "Result", - "renderer": { - "type": "badge", - "colors": { - "Win": "#2BB660", - "Loss": "#DE0038", - "Draw": "#94A3B8" - } - } - }, - { - "field": "stage", - "title": "Stage", - "renderer": { - "type": "text" - } - } - ] - }, - "style": { - "richTableTranspose": true + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true, + "columnOrder": ["date", "home_away", "opponent", "score", "result", "stage"] } } } @@ -199,76 +109,13 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "team", - "title": "Team", - "renderer": { - "type": "text" - } - }, - { - "field": "mp", - "title": "Played", - "renderer": { - "type": "text" - } - }, - { - "field": "w", - "title": "W", - "renderer": { - "type": "text" - } - }, - { - "field": "d", - "title": "D", - "renderer": { - "type": "text" - } - }, - { - "field": "l", - "title": "L", - "renderer": { - "type": "text" - } - }, - { - "field": "gf", - "title": "GF", - "renderer": { - "type": "text" - } - }, - { - "field": "ga", - "title": "GA", - "renderer": { - "type": "text" - } - }, - { - "field": "gd", - "title": "GD", - "renderer": { - "type": "text" - } - }, - { - "field": "pts", - "title": "Points", - "renderer": { - "type": "text" - } - } - ] + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["team", "mp", "w", "d", "l", "gf", "ga", "gd", "pts"], + "hiddenColumns": ["group"] } } } @@ -300,58 +147,13 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "vizSpec": { - "version": "1.0", - "visualization": { - "chartType": "richTable", - "encoding": { - "columns": [ - { - "field": "country", - "title": "Country", - "renderer": { - "type": "text" - } - }, - { - "field": "group", - "title": "Group", - "renderer": { - "type": "text" - } - }, - { - "field": "gf", - "title": "Goals For", - "renderer": { - "type": "text" - } - }, - { - "field": "ga", - "title": "Goals Against", - "renderer": { - "type": "text" - } - }, - { - "field": "gd", - "title": "Goal Difference", - "renderer": { - "type": "text" - } - }, - { - "field": "pts", - "title": "Points", - "renderer": { - "type": "text" - } - } - ] - }, - "style": { - "richTableTranspose": true + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true, + "columnOrder": ["country", "group", "gf", "ga", "gd", "pts"], + "hiddenColumns": ["mp", "w", "d", "l", "sourceId"] } } } diff --git a/scripts/classic-to-smartviz/README.md b/scripts/classic-to-smartviz/README.md index 93ede3ed..b887ee3b 100644 --- a/scripts/classic-to-smartviz/README.md +++ b/scripts/classic-to-smartviz/README.md @@ -19,6 +19,7 @@ node scripts/classic-to-smartviz/convert.mjs --write --bump patch --schema ` | A CommonJS module exporting `vizSpecSchema` from `@squaredup/viz-spec` (in the saas repo). A spec that fails it, or loses a key to it, stays classic. | +| `--rules ` | The enhancement rules to apply on top of the conversion: `all` (the default), `none`, or a comma-separated list. See [Enhancement rules](#enhancement-rules). | | `--json ` | Also write the report as JSON. | `squaredup validate` checks every `vizSpec` against the same schema when the plugin is validated or deployed, so `--schema` is a convenience for checking a large batch before you push. @@ -37,6 +38,19 @@ node scripts/classic-to-smartviz/convert.mjs --write --bump patch --schema With --write, bump metadata.json by patch or minor in each folder that changed --schema CommonJS build of @squaredup/viz-spec (or a bundle exporting vizSpecSchema) to validate every spec against; a spec that fails is left classic + --rules Enhancement rules to apply on top of the conversion: all (the default), none, or a + comma-separated list of ${RULE_NAMES.join(', ')} --json Also write the report as JSON`; const MAPPERS = { @@ -82,7 +85,7 @@ const getSchemaProblems = (vizSpecSchema, spec) => { const toIndentUnit = (text) => /\n([ \t]+)"/u.exec(text)?.[1] ?? ' '; /** Converts one tile, returning its report entry and, when it converts, the spec to write. */ -const convertTile = (tileConfig, streamColumnsByName, vizSpecSchema) => { +const convertTile = (tileConfig, streamColumnsByName, { vizSpecSchema, ruleNames }) => { const title = tileConfig.title || '(untitled)'; if (tileConfig.vizSpec) { @@ -107,14 +110,15 @@ const convertTile = (tileConfig, streamColumnsByName, vizSpecSchema) => { return { title, classicType, status: 'blocked', reason: blockedReason }; } - const spec = { version: '1.0', visualization }; + const enhanced = applyEnhancementRules(visualization, { tileColumns, tileConfig }, ruleNames); + const spec = { version: '1.0', visualization: enhanced.spec }; const schemaProblems = vizSpecSchema ? getSchemaProblems(vizSpecSchema, spec) : []; if (schemaProblems.length > 0) { return { title, classicType, status: 'blocked', reason: `invalid spec: ${schemaProblems.join('; ')}` }; } - const unknownFields = getSpecFields(visualization).filter((field) => !isKnownColumn(tileColumns, field)); + const unknownFields = getSpecFields(enhanced.spec).filter((field) => !isKnownColumn(tileColumns, field)); if (unknownFields.length > 0) { return { title, classicType, status: 'blocked', reason: `refers to columns the tile does not have: ${unknownFields.join(', ')}` }; @@ -127,18 +131,19 @@ const convertTile = (tileConfig, streamColumnsByName, vizSpecSchema) => { chartType: visualization.chartType, columnSource: tileColumns.source, notes, + rulesApplied: enhanced.applied, spec }; }; -const convertDashboard = (filePath, streamColumnsByName, { vizSpecSchema, write }) => { +const convertDashboard = (filePath, streamColumnsByName, { vizSpecSchema, ruleNames, write }) => { const text = fs.readFileSync(filePath, 'utf8'); const contents = JSON.parse(text).dashboard?.contents ?? []; const tiles = contents .map((item, index) => ({ index, config: item?.config })) .filter(({ config }) => config?._type === 'tile/data-stream') - .map(({ index, config }) => ({ index, ...convertTile(config, streamColumnsByName, vizSpecSchema) })); + .map(({ index, config }) => ({ index, ...convertTile(config, streamColumnsByName, { vizSpecSchema, ruleNames }) })); const replacements = tiles .filter((tile) => tile.status === 'converted') @@ -188,6 +193,7 @@ const printReport = (plugins) => { const detail = tile.status === 'converted' ? `${tile.classicType} → ${tile.chartType}` : tile.reason; console.log(` ${STATUS_MARKS[tile.status]} ${tile.title}: ${detail}`); (tile.notes ?? []).forEach((note) => console.log(` note: ${note}`)); + (tile.rulesApplied ?? []).forEach(({ rule, note }) => console.log(` rule ${rule}: ${note}`)); } } } @@ -195,6 +201,29 @@ const printReport = (plugins) => { const tiles = plugins.flatMap((plugin) => plugin.dashboards.flatMap((dashboard) => dashboard.tiles)); const countOf = (status) => tiles.filter((tile) => tile.status === status).length; console.log(`\n${countOf('converted')} converted, ${countOf('blocked')} blocked, ${countOf('skipped')} skipped`); + + const ruleCounts = Object.groupBy(tiles.flatMap((tile) => tile.rulesApplied ?? []), ({ rule }) => rule); + Object.entries(ruleCounts).forEach(([rule, hits]) => console.log(` ${rule}: ${hits.length} tile(s)`)); +}; + +/** Reads `--rules`: `all`, `none`, or a comma-separated list of rule names. */ +const toRuleNames = (option) => { + if (option === 'all') { + return RULE_NAMES; + } + + if (option === 'none') { + return []; + } + + const names = option.split(',').map((name) => name.trim()).filter(Boolean); + const unknown = names.filter((name) => !RULE_NAMES.includes(name)); + + if (unknown.length > 0) { + throw new Error(`Unknown rule(s): ${unknown.join(', ')}. Rules: ${RULE_NAMES.join(', ')}`); + } + + return names; }; const loadVizSpecSchema = (schemaPath) => { @@ -219,6 +248,7 @@ const main = () => { bump: { type: 'string' }, schema: { type: 'string' }, json: { type: 'string' }, + rules: { type: 'string', default: 'all' }, help: { type: 'boolean', default: false } } }); @@ -233,13 +263,14 @@ const main = () => { } const vizSpecSchema = loadVizSpecSchema(options.schema); + const ruleNames = toRuleNames(options.rules); const plugins = folders.map((folder) => { const defaultContentDirectory = findDefaultContentDirectory(folder); const streamColumnsByName = readStreamColumns(folder); const dashboards = defaultContentDirectory ? findDashboardFiles(defaultContentDirectory).map((file) => - convertDashboard(file, streamColumnsByName, { vizSpecSchema, write: options.write }) + convertDashboard(file, streamColumnsByName, { vizSpecSchema, ruleNames, write: options.write }) ) : []; const hasConversions = dashboards.some((dashboard) => dashboard.tiles.some((tile) => tile.status === 'converted')); diff --git a/scripts/classic-to-smartviz/enhancementRules.mjs b/scripts/classic-to-smartviz/enhancementRules.mjs new file mode 100644 index 00000000..b5738450 --- /dev/null +++ b/scripts/classic-to-smartviz/enhancementRules.mjs @@ -0,0 +1,169 @@ +import { findColumn } from './columns.mjs'; + +const RECENCY_NAME = /last|seen|updated|modified|checked|heartbeat|sync|refreshed|polled/iu; +const STATUS_NAME = /status|state|health|result|severity|priority|outcome|level|condition/iu; +const SIGNED_NAME = /difference|change|delta|growth|trend|result/iu; + +const ERROR = '#DE0038'; +const WARNING = '#F59E0B'; +const SUCCESS = '#2BB660'; +const INFO = '#3B82F6'; +const UNKNOWN = '#94A3B8'; + +/** Health colours for the words status, severity and priority columns commonly hold. Matched whole-value, ignoring case. */ +const STATUS_COLORS = { + critical: ERROR, + error: ERROR, + failed: ERROR, + failure: ERROR, + down: ERROR, + offline: ERROR, + unhealthy: ERROR, + high: ERROR, + warning: WARNING, + warn: WARNING, + medium: WARNING, + moderate: WARNING, + degraded: WARNING, + partial: WARNING, + ok: SUCCESS, + healthy: SUCCESS, + success: SUCCESS, + succeeded: SUCCESS, + passed: SUCCESS, + up: SUCCESS, + online: SUCCESS, + resolved: SUCCESS, + low: INFO, + info: INFO, + informational: INFO, + unknown: UNKNOWN, + unmonitored: UNKNOWN +}; + +const getShapeName = ({ tileColumns }, field) => findColumn(tileColumns.columns, field)?.shapeName; + +const getLabel = ({ tileColumns }, field) => `${field} ${findColumn(tileColumns.columns, field)?.displayName ?? ''}`; + +const withStyle = (spec, style) => ({ ...spec, style: { ...spec.style, ...style } }); + +const hasColorStyle = (style = {}) => + Boolean(style.colors?.length || Object.keys(style.mappedColors ?? {}).length || style.cartesianColorRules?.length); + +/** Rewrites the renderer of each richTable column `pick` selects, returning the spec and the fields it changed. */ +const updateTableColumns = (spec, pick, toRenderer) => { + if (spec.chartType !== 'richTable') { + return { spec, fields: [] }; + } + + const fields = spec.encoding.columns.filter(pick).map((column) => column.field); + const columns = spec.encoding.columns.map((column) => + fields.includes(column.field) ? { ...column, renderer: toRenderer(column.renderer) } : column + ); + + return { spec: { ...spec, encoding: { ...spec.encoding, columns } }, fields }; +}; + +/** + * Optional improvements applied on top of the like-for-like conversion. Each takes the converted spec and the + * tile's context, and returns the spec (changed or not) with a note when it changed something. Every rule works + * from column shapes, names and the converted spec alone, so none of them depends on what the data holds. + */ +export const ENHANCEMENT_RULES = { + /** Dates of a "last seen / updated / synced" kind show as "3 hours ago", with the exact time in the tooltip. */ + recencyDates: (spec, context) => { + const { spec: updated, fields } = updateTableColumns( + spec, + (column) => column.renderer.type === 'date' && RECENCY_NAME.test(getLabel(context, column.field)), + (renderer) => ({ ...renderer, relative: true }) + ); + + return { spec: updated, note: fields.length > 0 ? `relative time for ${fields.join(', ')}` : undefined }; + }, + + /** + * Donuts and bar or line charts split by a status-like column get health colours for the status words they + * hold. Values outside the vocabulary keep the palette, and a tile with colours of its own is left alone. + */ + statusColours: (spec, context) => { + const { chartType, encoding, style } = spec; + const candidates = { + pie: [encoding.label?.field], + bar: [encoding.series?.field, encoding.x?.field], + line: [encoding.series?.field] + }[chartType]; + const statusField = candidates?.find((field) => field && STATUS_NAME.test(getLabel(context, field))); + + if (!statusField || hasColorStyle(style)) { + return { spec }; + } + + return { spec: withStyle(spec, { mappedColors: STATUS_COLORS }), note: `status colours for ${statusField}` }; + }, + + /** + * Percent columns in tables show as a bar filled to the value out of 100. Columns whose name suggests a signed + * value (a difference or change) are left as text, because a bar cannot show a negative. + */ + percentBars: (spec, context) => { + const { spec: updated, fields } = updateTableColumns( + spec, + (column) => + column.renderer.type === 'text' && + getShapeName(context, column.field) === 'percent' && + !SIGNED_NAME.test(getLabel(context, column.field)), + () => ({ type: 'progress' }) + ); + + return { spec: updated, note: fields.length > 0 ? `progress bars for ${fields.join(', ')}` : undefined }; + }, + + /** A line chart of a percentage gets a fixed 0–100 axis, unless the tile set its own range. */ + percentAxis: (spec, context) => { + const { chartType, encoding, style = {} } = spec; + const hasOwnRange = style.cartesianYAxisMin !== undefined || style.cartesianYAxisMax !== undefined; + + if (chartType !== 'line' || !encoding.y || hasOwnRange || getShapeName(context, encoding.y.field) !== 'percent') { + return { spec }; + } + + return { spec: withStyle(spec, { cartesianYAxisRangeMode: 'percentage' }), note: 'y axis fixed to 0–100%' }; + }, + + /** A gauge of a percentage with no range of its own runs from 0 to 100. */ + percentGauge: (spec, context) => { + const { chartType, encoding, style = {} } = spec; + const hasOwnRange = + style.gaugeMin !== undefined || style.gaugeMax !== undefined || encoding.min !== undefined || encoding.max !== undefined; + + if (chartType !== 'gauge' || hasOwnRange || getShapeName(context, encoding.value.field) !== 'percent') { + return { spec }; + } + + return { spec: withStyle(spec, { gaugeMin: 0, gaugeMax: 100 }), note: 'gauge range 0–100%' }; + }, + + /** Boolean columns read "Yes" / "No" in the table's own text colour, so neither value implies good or bad. */ + booleanLabels: (spec, context) => { + const { spec: updated, fields } = updateTableColumns( + spec, + (column) => column.renderer.type === 'text' && getShapeName(context, column.field) === 'boolean', + () => ({ type: 'boolean', trueLabel: 'Yes', falseLabel: 'No', trueColor: 'inherit', falseColor: 'inherit' }) + ); + + return { spec: updated, note: fields.length > 0 ? `Yes / No for ${fields.join(', ')}` : undefined }; + } +}; + +export const RULE_NAMES = Object.keys(ENHANCEMENT_RULES); + +/** Applies the named rules in order, returning the final spec and the notes of the rules that changed it. */ +export const applyEnhancementRules = (spec, context, ruleNames) => + ruleNames.reduce( + (result, name) => { + const { spec: next, note } = ENHANCEMENT_RULES[name](result.spec, context); + + return { spec: next, applied: note ? [...result.applied, { rule: name, note }] : result.applied }; + }, + { spec, applied: [] } + ); From 2e6f92e44338474982adbdef84485973f71983c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:49:53 +0000 Subject: [PATCH 06/16] Show OpenF1 DNF, DNS and DSQ as Yes / No Regenerating the pilot dashboards with the converter's enhancement rules on changes only these three session result columns, which now read Yes / No instead of true / false. The rest of the pilot output is unchanged, including the World Cup result badge, which the rules now produce. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- .../v1/defaultContent/meeting.dash.json | 42 +- .../v1/defaultContent/overview.dash.json | 100 ++-- .../v1/defaultContent/session.dash.json | 454 ++++++++++++++---- 3 files changed, 470 insertions(+), 126 deletions(-) diff --git a/plugins/OpenF1/v1/defaultContent/meeting.dash.json b/plugins/OpenF1/v1/defaultContent/meeting.dash.json index b0faf707..a764396e 100644 --- a/plugins/OpenF1/v1/defaultContent/meeting.dash.json +++ b/plugins/OpenF1/v1/defaultContent/meeting.dash.json @@ -80,13 +80,41 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Meeting]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": ["session_name", "session_type", "date_start", "date_end"], - "hiddenColumns": ["session_key"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "session_name", + "title": "Session", + "renderer": { + "type": "text" + } + }, + { + "field": "session_type", + "title": "Type", + "renderer": { + "type": "text" + } + }, + { + "field": "date_start", + "title": "Start", + "renderer": { + "type": "date" + } + }, + { + "field": "date_end", + "title": "End", + "renderer": { + "type": "date" + } + } + ] } } } diff --git a/plugins/OpenF1/v1/defaultContent/overview.dash.json b/plugins/OpenF1/v1/defaultContent/overview.dash.json index abefd784..8dd20ca9 100644 --- a/plugins/OpenF1/v1/defaultContent/overview.dash.json +++ b/plugins/OpenF1/v1/defaultContent/overview.dash.json @@ -59,23 +59,47 @@ ] } }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "position_current", - "driver_number", - "driverName", - "driverTeam", - "points_current", - "position_start", - "points_start" - ], - "hiddenColumns": [ - "position_start", - "points_start" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "position_current", + "title": "Position", + "renderer": { + "type": "text" + } + }, + { + "field": "driver_number", + "title": "Driver #", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "driverTeam", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "points_current", + "title": "Points", + "renderer": { + "type": "text" + } + } ] } } @@ -112,21 +136,33 @@ ] } }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "position_current", - "team_name", - "points_current", - "position_start", - "points_start" - ], - "hiddenColumns": [ - "position_start", - "points_start" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "position_current", + "title": "Position", + "renderer": { + "type": "text" + } + }, + { + "field": "team_name", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "points_current", + "title": "Points", + "renderer": { + "type": "text" + } + } ] } } diff --git a/plugins/OpenF1/v1/defaultContent/session.dash.json b/plugins/OpenF1/v1/defaultContent/session.dash.json index f95691a6..4154fe13 100644 --- a/plugins/OpenF1/v1/defaultContent/session.dash.json +++ b/plugins/OpenF1/v1/defaultContent/session.dash.json @@ -34,24 +34,95 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "position", - "driver_number", - "driverName", - "number_of_laps", - "points", - "gapToLeader", - "duration", - "dnf", - "dns", - "dsq" - ], - "hiddenColumns": ["gap_to_leader"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "position", + "title": "Position", + "renderer": { + "type": "text" + } + }, + { + "field": "driver_number", + "title": "Car Number", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "number_of_laps", + "title": "Laps", + "renderer": { + "type": "text" + } + }, + { + "field": "points", + "title": "Points", + "renderer": { + "type": "text" + } + }, + { + "field": "gapToLeader", + "title": "Gap to Leader (s)", + "renderer": { + "type": "text" + } + }, + { + "field": "duration", + "title": "Race Time", + "renderer": { + "type": "text" + } + }, + { + "field": "dnf", + "title": "DNF", + "renderer": { + "type": "boolean", + "trueLabel": "Yes", + "falseLabel": "No", + "trueColor": "inherit", + "falseColor": "inherit" + } + }, + { + "field": "dns", + "title": "DNS", + "renderer": { + "type": "boolean", + "trueLabel": "Yes", + "falseLabel": "No", + "trueColor": "inherit", + "falseColor": "inherit" + } + }, + { + "field": "dsq", + "title": "DSQ", + "renderer": { + "type": "boolean", + "trueLabel": "Yes", + "falseLabel": "No", + "trueColor": "inherit", + "falseColor": "inherit" + } + } + ] } } } @@ -84,13 +155,48 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": ["driver_number", "full_name", "name_acronym", "team_name", "country_code"], - "hiddenColumns": ["headshot_url", "team_colour"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "driver_number", + "title": "Number", + "renderer": { + "type": "text" + } + }, + { + "field": "full_name", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "name_acronym", + "title": "Code", + "renderer": { + "type": "text" + } + }, + { + "field": "team_name", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "country_code", + "title": "Country", + "renderer": { + "type": "text" + } + } + ] } } } @@ -123,18 +229,30 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-line-graph", - "config": { - "data-stream-line-graph": { - "xAxisColumn": "lap_number", - "yAxisColumn": ["lap_duration"], - "seriesColumn": "driver_number", - "showLegend": true, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "lap_number", + "type": "quantitative" + }, + "y": { + "field": "lap_duration", + "type": "quantitative" + }, + "series": { + "field": "driverName", + "type": "nominal" + } + }, + "style": { "legendPosition": "bottom", - "yAxisLabel": "Lap Time (s)", - "showYAxisLabel": true, - "showTrendLine": false + "cartesianShowYAxisTitle": true, + "cartesianYAxisTitle": "Lap Time (s)", + "cartesianYAxisRangeMode": "fit", + "cartesianSymbol": "none" } } } @@ -167,19 +285,61 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "driver_number", - "driverName", - "stint_number", - "compound", - "lap_start", - "lap_end", - "tyre_age_at_start" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "driver_number", + "title": "Driver Number", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "stint_number", + "title": "Stint", + "renderer": { + "type": "text" + } + }, + { + "field": "compound", + "title": "Compound", + "renderer": { + "type": "text" + } + }, + { + "field": "lap_start", + "title": "First Lap", + "renderer": { + "type": "text" + } + }, + { + "field": "lap_end", + "title": "Last Lap", + "renderer": { + "type": "text" + } + }, + { + "field": "tyre_age_at_start", + "title": "Tyre Age at Start (laps)", + "renderer": { + "type": "text" + } + } ] } } @@ -213,19 +373,61 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "driver_number", - "driverName", - "lap_number", - "date", - "pit_duration", - "lane_duration", - "stop_duration" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "driver_number", + "title": "Driver Number", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "lap_number", + "title": "Lap", + "renderer": { + "type": "text" + } + }, + { + "field": "date", + "title": "Time", + "renderer": { + "type": "date" + } + }, + { + "field": "pit_duration", + "title": "Pit Duration", + "renderer": { + "type": "text" + } + }, + { + "field": "lane_duration", + "title": "Lane Duration", + "renderer": { + "type": "text" + } + }, + { + "field": "stop_duration", + "title": "Stationary Time", + "renderer": { + "type": "text" + } + } ] } } @@ -303,19 +505,61 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "date", - "category", - "flag", - "message", - "lap_number", - "driver_number", - "driverName" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "date", + "title": "Time", + "renderer": { + "type": "date" + } + }, + { + "field": "category", + "title": "Category", + "renderer": { + "type": "text" + } + }, + { + "field": "flag", + "title": "Flag", + "renderer": { + "type": "text" + } + }, + { + "field": "message", + "title": "Message", + "renderer": { + "type": "text" + } + }, + { + "field": "lap_number", + "title": "Lap", + "renderer": { + "type": "text" + } + }, + { + "field": "driver_number", + "title": "Driver #", + "renderer": { + "type": "text" + } + }, + { + "field": "driverName", + "title": "Driver", + "renderer": { + "type": "text" + } + } ] } } @@ -349,18 +593,54 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "date", - "overtaking_driver_number", - "overtakingDriver", - "overtaken_driver_number", - "overtakenDriver", - "position" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "date", + "title": "Time", + "renderer": { + "type": "date" + } + }, + { + "field": "overtaking_driver_number", + "title": "Overtaking Driver #", + "renderer": { + "type": "text" + } + }, + { + "field": "overtakingDriver", + "title": "Overtaking Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "overtaken_driver_number", + "title": "Overtaken Driver #", + "renderer": { + "type": "text" + } + }, + { + "field": "overtakenDriver", + "title": "Overtaken Driver", + "renderer": { + "type": "text" + } + }, + { + "field": "position", + "title": "New Position", + "renderer": { + "type": "text" + } + } ] } } From 291c2937cdecd96fa42d12efeeb171ec4061737f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:50:44 +0000 Subject: [PATCH 07/16] Restore the TfL and World Cup SmartViz dashboards The enhancement-rules commit accidentally took classic copies of these dashboards with it: they had been staged while the pilot was being regenerated from main. This puts back the converted versions, which are unchanged from before that commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- .../v1/defaultContent/arrivals.dash.json | 56 +++- .../v1/defaultContent/lineStatus.dash.json | 28 +- .../v1/defaultContent/team.dash.json | 250 ++++++++++++++++-- 3 files changed, 286 insertions(+), 48 deletions(-) diff --git a/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json b/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json index fa2e0834..b2d2cedf 100644 --- a/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json +++ b/plugins/TransportForLondon/v1/defaultContent/arrivals.dash.json @@ -31,22 +31,50 @@ "variables": [ "{{variables.Station}}" ], - "visualisation": { - "config": { - "data-stream-table": { - "columnOrder": [ - "timeToStation", - "platformName", - "towards", - "currentLocation", - "expectedArrival" - ], - "hiddenColumns": [ - "direction" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "timeToStation", + "title": "Time To Station", + "renderer": { + "type": "text" + } + }, + { + "field": "platformName", + "title": "Platform Name", + "renderer": { + "type": "text" + } + }, + { + "field": "towards", + "title": "Towards", + "renderer": { + "type": "text" + } + }, + { + "field": "currentLocation", + "title": "Current Location", + "renderer": { + "type": "text" + } + }, + { + "field": "expectedArrival", + "title": "Expected Arrival", + "renderer": { + "type": "text" + } + } ] } - }, - "type": "data-stream-table" + } } }, "h": 5, diff --git a/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json b/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json index 7d4b9a01..ac179ac0 100644 --- a/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json +++ b/plugins/TransportForLondon/v1/defaultContent/lineStatus.dash.json @@ -30,14 +30,26 @@ "variables": [ "{{variables.Lines}}" ], - "visualisation": { - "type": "data-stream-blocks", - "config": { - "data-stream-blocks": { - "stateColumn": "status", - "linkColumn": "link", - "sublabel": "statusDescription", - "labelColumn": "name" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "statusGrid", + "encoding": { + "label": { + "field": "name", + "type": "nominal" + }, + "status": { + "field": "status", + "type": "nominal" + }, + "link": { + "field": "link", + "type": "nominal" + } + }, + "style": { + "statusGridSecondaryTemplate": "{{{{raw}}}}{{statusDescription}}{{{{/raw}}}}" } } } diff --git a/plugins/WorldCup2026/v1/defaultContent/team.dash.json b/plugins/WorldCup2026/v1/defaultContent/team.dash.json index 15ab1ca0..ab053f62 100644 --- a/plugins/WorldCup2026/v1/defaultContent/team.dash.json +++ b/plugins/WorldCup2026/v1/defaultContent/team.dash.json @@ -35,12 +35,51 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": true, - "columnOrder": ["date", "home_away", "opponent", "stage", "group"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "date", + "title": "Date", + "renderer": { + "type": "text" + } + }, + { + "field": "home_away", + "title": "Home / Away", + "renderer": { + "type": "text" + } + }, + { + "field": "opponent", + "title": "Opponent", + "renderer": { + "type": "text" + } + }, + { + "field": "stage", + "title": "Stage", + "renderer": { + "type": "text" + } + }, + { + "field": "group", + "title": "Group", + "renderer": { + "type": "text" + } + } + ] + }, + "style": { + "richTableTranspose": true } } } @@ -72,12 +111,63 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": true, - "columnOrder": ["date", "home_away", "opponent", "score", "result", "stage"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "date", + "title": "Date", + "renderer": { + "type": "text" + } + }, + { + "field": "home_away", + "title": "Home / Away", + "renderer": { + "type": "text" + } + }, + { + "field": "opponent", + "title": "Opponent", + "renderer": { + "type": "text" + } + }, + { + "field": "score", + "title": "Score", + "renderer": { + "type": "text" + } + }, + { + "field": "result[Expanded].rawState", + "title": "Result", + "renderer": { + "type": "badge", + "colors": { + "Win": "#2BB660", + "Loss": "#DE0038", + "Draw": "#94A3B8" + } + } + }, + { + "field": "stage", + "title": "Stage", + "renderer": { + "type": "text" + } + } + ] + }, + "style": { + "richTableTranspose": true } } } @@ -109,13 +199,76 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": ["team", "mp", "w", "d", "l", "gf", "ga", "gd", "pts"], - "hiddenColumns": ["group"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "team", + "title": "Team", + "renderer": { + "type": "text" + } + }, + { + "field": "mp", + "title": "Played", + "renderer": { + "type": "text" + } + }, + { + "field": "w", + "title": "W", + "renderer": { + "type": "text" + } + }, + { + "field": "d", + "title": "D", + "renderer": { + "type": "text" + } + }, + { + "field": "l", + "title": "L", + "renderer": { + "type": "text" + } + }, + { + "field": "gf", + "title": "GF", + "renderer": { + "type": "text" + } + }, + { + "field": "ga", + "title": "GA", + "renderer": { + "type": "text" + } + }, + { + "field": "gd", + "title": "GD", + "renderer": { + "type": "text" + } + }, + { + "field": "pts", + "title": "Points", + "renderer": { + "type": "text" + } + } + ] } } } @@ -147,13 +300,58 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[Team]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": true, - "columnOrder": ["country", "group", "gf", "ga", "gd", "pts"], - "hiddenColumns": ["mp", "w", "d", "l", "sourceId"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "country", + "title": "Country", + "renderer": { + "type": "text" + } + }, + { + "field": "group", + "title": "Group", + "renderer": { + "type": "text" + } + }, + { + "field": "gf", + "title": "Goals For", + "renderer": { + "type": "text" + } + }, + { + "field": "ga", + "title": "Goals Against", + "renderer": { + "type": "text" + } + }, + { + "field": "gd", + "title": "Goal Difference", + "renderer": { + "type": "text" + } + }, + { + "field": "pts", + "title": "Points", + "renderer": { + "type": "text" + } + } + ] + }, + "style": { + "richTableTranspose": true } } } From cd78ea4810f63aa7facb2c757d76e4c35cf0c072 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:01:43 +0000 Subject: [PATCH 08/16] Fix converter issues found in the rule-sample screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before/after screenshots of ProxmoxVE, Healthchecks.io, CrowdStrike and GoogleHealth dashboards, with demo data, turned up five problems: - Mapped state badges were blank for computed columns. The platform adds the `[Expanded].rawState` copy only for columns read from the response, so a computed state column keeps the status renderer. - A status-block sublabel naming `status[Expanded].rawState` rendered nothing, because Handlebars reads the dots and brackets as a path. Names that are not plain identifiers now use `{{lookup this "…"}}`. - Chart axes and legends showed raw field names such as `backupGroup_uniqueValues`, because SmartViz falls back to the field name when a channel has no title. Channels now carry the column's display name. - Scalars with no label in classic showed their field name ("count") under the number. They now set no label, as classic did. - "Not monitored" and "Paused" join the status colours, as grey, so a health donut no longer shows them in a palette colour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- scripts/classic-to-smartviz/chartMappers.mjs | 29 ++++++++++++------- scripts/classic-to-smartviz/columns.mjs | 1 + .../classic-to-smartviz/enhancementRules.mjs | 4 ++- scripts/classic-to-smartviz/tableMappers.mjs | 12 ++++++-- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/scripts/classic-to-smartviz/chartMappers.mjs b/scripts/classic-to-smartviz/chartMappers.mjs index 99de2eef..9dddef1d 100644 --- a/scripts/classic-to-smartviz/chartMappers.mjs +++ b/scripts/classic-to-smartviz/chartMappers.mjs @@ -11,10 +11,14 @@ const isSet = (value) => value !== undefined && value !== null && value !== '' & const toList = (value) => (Array.isArray(value) ? value : isSet(value) ? [value] : []); -/** A channel for a column, typed from its declared shape or, failing that, from the role the channel plays. */ +/** + * A channel for a column, typed from its declared shape or, failing that, from the role the channel plays. Its + * title is the header classic showed, because SmartViz falls back to the raw field name in axis titles. + */ const toChannel = ({ columns }, field, fallbackType, extra = {}) => ({ field, type: findFieldType(columns, field) ?? fallbackType, + title: getColumnTitle(columns, field), ...extra }); @@ -42,15 +46,16 @@ const toXChannel = (tileColumns, configured, chartType, notes) => { } const declaredType = findFieldType(tileColumns.columns, field); + const title = getColumnTitle(tileColumns.columns, field); if (declaredType) { - return { field, type: declaredType }; + return { field, type: declaredType, title }; } const guessedType = chartType === 'line' || isTimeLikeName(field) ? 'temporal' : 'nominal'; notes.push(`x column ${field} has no declared shape; typed as ${guessedType}`); - return { field, type: guessedType }; + return { field, type: guessedType, title }; }; /** @@ -131,10 +136,10 @@ const toVisualization = (chartType, encoding, style, notes) => ({ notes }); -const toCartesianEncoding = (x, y, seriesField) => ({ +const toCartesianEncoding = (x, y, seriesField, { columns }) => ({ ...(x && { x }), ...(y && { y }), - ...(seriesField && { series: { field: seriesField, type: 'nominal' } }) + ...(seriesField && { series: { field: seriesField, type: 'nominal', title: getColumnTitle(columns, seriesField) } }) }); /** Converts a classic `data-stream-line-graph` config to a line spec. */ @@ -167,7 +172,7 @@ export const toLineChart = (classicConfig, tileColumns) => { ...(isSet(classicConfig.unitLabelColumn) && { cartesianYAxisUnitColumn: classicConfig.unitLabelColumn }) }; - return toVisualization('line', toCartesianEncoding(x, y, seriesField), style, notes); + return toVisualization('line', toCartesianEncoding(x, y, seriesField, tileColumns), style, notes); }; const PALETTE_RULE = /^(?:bar|series)Index\s*%\s*(\d+)\s*==\s*(\d+)$/u; @@ -270,7 +275,7 @@ export const toBarChart = (classicConfig, tileColumns) => { ...toBarColorStyle(classicConfig.color, notes) }; - return toVisualization('bar', toCartesianEncoding(x, y, seriesField), style, notes); + return toVisualization('bar', toCartesianEncoding(x, y, seriesField, tileColumns), style, notes); }; /** @@ -328,10 +333,12 @@ export const toScalar = (classicConfig, tileColumns) => { notes.push(`value column ${channel.field} is not declared by the stream; typed as a number`); } - const style = { - ...(typeof classicConfig.label === 'string' && classicConfig.label && { scalarLabelText: classicConfig.label }), - ...(labelPosition && labelPosition !== 'bottom' && { scalarLabelPosition: labelPosition }) - }; + const labelText = typeof classicConfig.label === 'string' ? classicConfig.label : ''; + + /** Classic shows no label unless one is set, where SmartViz would show the field name. */ + const style = labelText + ? { scalarLabelText: labelText, ...(labelPosition && labelPosition !== 'bottom' && { scalarLabelPosition: labelPosition }) } + : { scalarLabelPosition: 'none' }; const encoding = { values: [channel], diff --git a/scripts/classic-to-smartviz/columns.mjs b/scripts/classic-to-smartviz/columns.mjs index 2280d315..97118ce5 100644 --- a/scripts/classic-to-smartviz/columns.mjs +++ b/scripts/classic-to-smartviz/columns.mjs @@ -51,6 +51,7 @@ const toColumn = (metadataEntry) => ({ displayName: metadataEntry.displayName || undefined, role: metadataEntry.role, isVisible: metadataEntry.visible !== false, + isComputed: metadataEntry.computed === true, ...toShape(metadataEntry) }); diff --git a/scripts/classic-to-smartviz/enhancementRules.mjs b/scripts/classic-to-smartviz/enhancementRules.mjs index b5738450..bc046c0c 100644 --- a/scripts/classic-to-smartviz/enhancementRules.mjs +++ b/scripts/classic-to-smartviz/enhancementRules.mjs @@ -38,7 +38,9 @@ const STATUS_COLORS = { info: INFO, informational: INFO, unknown: UNKNOWN, - unmonitored: UNKNOWN + unmonitored: UNKNOWN, + 'not monitored': UNKNOWN, + paused: UNKNOWN }; const getShapeName = ({ tileColumns }, field) => findColumn(tileColumns.columns, field)?.shapeName; diff --git a/scripts/classic-to-smartviz/tableMappers.mjs b/scripts/classic-to-smartviz/tableMappers.mjs index 0fed9c85..ef5fef7e 100644 --- a/scripts/classic-to-smartviz/tableMappers.mjs +++ b/scripts/classic-to-smartviz/tableMappers.mjs @@ -7,6 +7,13 @@ const isSet = (value) => value !== undefined && value !== null && value !== '' & /** Wraps a Handlebars template so the platform's install-time templating leaves it for SmartViz to resolve. */ export const toRawTemplate = (template) => `{{{{raw}}}}${template}{{{{/raw}}}}`; +/** + * A Handlebars reference to a column. A name with dots or brackets (`status[Expanded].rawState`) would read as a + * path, so it is looked up by its whole name instead. + */ +const toTemplateReference = (name) => + /^[A-Za-z_$][\w$]*$/u.test(name) ? `{{${name}}}` : `{{lookup this ${JSON.stringify(name)}}}`; + const COLUMN_REFERENCE = /^\{\{\s*column\.([^}\s]+)\s*\}\}$/u; const STATE_COLORS = { success: '#2BB660', info: '#3B82F6', warning: '#F59E0B', error: '#DE0038', unknown: '#94A3B8' }; @@ -17,7 +24,8 @@ const STATE_COLORS = { success: '#2BB660', info: '#3B82F6', warning: '#F59E0B', * coloured by the map, the cell reads "Win" in green rather than "Success". */ const toMappedStateColumn = (column) => { - const stateMap = column?.shapeName === 'state' ? column.shapeConfig?.map : undefined; + /** The platform adds the raw copy only for columns read from the response, not for computed ones. */ + const stateMap = column?.shapeName === 'state' && !column.isComputed ? column.shapeConfig?.map : undefined; if (!stateMap || typeof stateMap !== 'object' || Object.keys(stateMap).length === 0) { return undefined; @@ -186,7 +194,7 @@ export const toStatusGrid = (classicConfig, tileColumns) => { const style = { ...(typeof classicConfig.columns === 'number' && { statusGridColumnsPerRow: classicConfig.columns }), - ...(sublabelField && { statusGridSecondaryTemplate: toRawTemplate(`{{${sublabelField}}}`) }), + ...(sublabelField && { statusGridSecondaryTemplate: toRawTemplate(toTemplateReference(sublabelField)) }), ...(classicConfig.wrapLabels === true && { statusGridLabelWrap: true }), ...(orderField && { statusGridOrderByField: orderField }), ...(blockHeight >= 50 && blockHeight <= 310 && { statusGridMaxBlockHeight: Math.round(blockHeight) }) From d1a82d2f2acd0c3a16cc6b295d6ff1d0fe3e22ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:01:44 +0000 Subject: [PATCH 09/16] Give the OpenF1 lap times chart readable axis titles Regenerated with the converter's fixes: the lap times channels now carry "Lap", "Lap Time" and "Driver" as titles rather than leaving SmartViz to fall back to the raw field names. The other pilot dashboards come out unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- plugins/OpenF1/v1/defaultContent/session.dash.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/OpenF1/v1/defaultContent/session.dash.json b/plugins/OpenF1/v1/defaultContent/session.dash.json index 4154fe13..76898245 100644 --- a/plugins/OpenF1/v1/defaultContent/session.dash.json +++ b/plugins/OpenF1/v1/defaultContent/session.dash.json @@ -236,15 +236,18 @@ "encoding": { "x": { "field": "lap_number", - "type": "quantitative" + "type": "quantitative", + "title": "Lap" }, "y": { "field": "lap_duration", - "type": "quantitative" + "type": "quantitative", + "title": "Lap Time" }, "series": { "field": "driverName", - "type": "nominal" + "type": "nominal", + "title": "Driver" } }, "style": { From 519317f9fd9f58787b253cef4396a8a32ff3f30e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:17:51 +0000 Subject: [PATCH 10/16] Stop install-time templating from blanking SmartViz templates When a plugin's default dashboards are installed, the platform runs the whole dashboard through Handlebars to fill in stream and config ids. That also resolves SmartViz templates, and with no `value` or `servingVariation` in scope they render as empty strings: - EcoStruxureIT-DCE: the Temperature and Humidity label formats on the device and environmental dashboards. - LaunchDarkly: the serving-variation second line on the flag state and environment state status grids. Each template is now wrapped in {{{{raw}}}} so it reaches SmartViz intact, as Auvik's already are. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- plugins/EcoStruxureIT-DCE/v1/defaultContent/device.dash.json | 4 ++-- .../v1/defaultContent/environmental.dash.json | 4 ++-- plugins/EcoStruxureIT-DCE/v1/metadata.json | 2 +- plugins/LaunchDarkly/v1/defaultContent/environment.dash.json | 2 +- plugins/LaunchDarkly/v1/defaultContent/flag.dash.json | 2 +- plugins/LaunchDarkly/v1/metadata.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/EcoStruxureIT-DCE/v1/defaultContent/device.dash.json b/plugins/EcoStruxureIT-DCE/v1/defaultContent/device.dash.json index fb1c91a3..a510715b 100644 --- a/plugins/EcoStruxureIT-DCE/v1/defaultContent/device.dash.json +++ b/plugins/EcoStruxureIT-DCE/v1/defaultContent/device.dash.json @@ -151,7 +151,7 @@ ], "gaugeAxisLabels": true, "gaugeLabelText": "Highest (F)", - "labelFormat": "{{value}}" + "labelFormat": "{{{{raw}}}}{{value}}{{{{/raw}}}}" } } } @@ -250,7 +250,7 @@ ], "gaugeAxisLabels": true, "gaugeLabelText": "Average (%RH)", - "labelFormat": "{{value}}" + "labelFormat": "{{{{raw}}}}{{value}}{{{{/raw}}}}" } } } diff --git a/plugins/EcoStruxureIT-DCE/v1/defaultContent/environmental.dash.json b/plugins/EcoStruxureIT-DCE/v1/defaultContent/environmental.dash.json index cd492b54..057dfc5c 100644 --- a/plugins/EcoStruxureIT-DCE/v1/defaultContent/environmental.dash.json +++ b/plugins/EcoStruxureIT-DCE/v1/defaultContent/environmental.dash.json @@ -151,7 +151,7 @@ ], "gaugeAxisLabels": true, "gaugeLabelText": "Highest (F)", - "labelFormat": "{{value}}" + "labelFormat": "{{{{raw}}}}{{value}}{{{{/raw}}}}" } } } @@ -250,7 +250,7 @@ ], "gaugeAxisLabels": true, "gaugeLabelText": "Average (%RH)", - "labelFormat": "{{value}}" + "labelFormat": "{{{{raw}}}}{{value}}{{{{/raw}}}}" } } } diff --git a/plugins/EcoStruxureIT-DCE/v1/metadata.json b/plugins/EcoStruxureIT-DCE/v1/metadata.json index 98fbaa36..1fda5f6f 100644 --- a/plugins/EcoStruxureIT-DCE/v1/metadata.json +++ b/plugins/EcoStruxureIT-DCE/v1/metadata.json @@ -1,7 +1,7 @@ { "name": "ecostruxure-it-dce", "displayName": "EcoStruxure IT Data Center Expert", - "version": "1.0.2", + "version": "1.0.3", "author": { "name": "@shawn149", "type": "community" diff --git a/plugins/LaunchDarkly/v1/defaultContent/environment.dash.json b/plugins/LaunchDarkly/v1/defaultContent/environment.dash.json index b970a394..81988f51 100644 --- a/plugins/LaunchDarkly/v1/defaultContent/environment.dash.json +++ b/plugins/LaunchDarkly/v1/defaultContent/environment.dash.json @@ -391,7 +391,7 @@ } }, "style": { - "statusGridSecondaryTemplate": "{{servingVariation}}", + "statusGridSecondaryTemplate": "{{{{raw}}}}{{servingVariation}}{{{{/raw}}}}", "statusGridOrderByField": "name", "statusGridOrderDirection": "asc", "statusGridMaxBlockHeight": 80 diff --git a/plugins/LaunchDarkly/v1/defaultContent/flag.dash.json b/plugins/LaunchDarkly/v1/defaultContent/flag.dash.json index bb0e7159..e2c18182 100644 --- a/plugins/LaunchDarkly/v1/defaultContent/flag.dash.json +++ b/plugins/LaunchDarkly/v1/defaultContent/flag.dash.json @@ -67,7 +67,7 @@ } }, "style": { - "statusGridSecondaryTemplate": "{{servingVariation}}", + "statusGridSecondaryTemplate": "{{{{raw}}}}{{servingVariation}}{{{{/raw}}}}", "statusGridOrderByField": "environment", "statusGridOrderDirection": "asc" } diff --git a/plugins/LaunchDarkly/v1/metadata.json b/plugins/LaunchDarkly/v1/metadata.json index 204bd455..588a5af8 100644 --- a/plugins/LaunchDarkly/v1/metadata.json +++ b/plugins/LaunchDarkly/v1/metadata.json @@ -1,7 +1,7 @@ { "name": "launchdarkly", "displayName": "LaunchDarkly", - "version": "1.0.0", + "version": "1.0.1", "author": { "name": "@clarkd", "type": "community" }, "description": "Monitor LaunchDarkly feature flags across your projects and environments, including flag state, lifecycle status and evaluation volume.", "category": "CI/CD Tools", From eeac23619e6df02ed3e7467b871fcc571d7b3cb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:17:51 +0000 Subject: [PATCH 11/16] Convert more squaredup-plugin-repository tiles A dry run over squaredup-plugin-repository blocked about 100 tiles for reasons on the converter's side rather than SmartViz's: - A packaged plugin's data_streams.json metadata is not the full column list, because its handler can return columns the metadata does not declare. Treating it as complete flagged real columns such as `count` and `label` as missing. It is now treated as partial. - A donut whose label column a later group replaced fell back, in classic, to the group's category column. It now does the same instead of being left classic. - Classic donut `palette` colours, keyed by label, now carry over as `mappedColors`. squaredup-plugin-repository now converts 1,049 tiles, up from 948, and what still blocks is down to SmartViz product gaps. The community repo is unchanged at 809 converted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- scripts/classic-to-smartviz/chartMappers.mjs | 25 ++++++++++++++++---- scripts/classic-to-smartviz/columns.mjs | 3 ++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/classic-to-smartviz/chartMappers.mjs b/scripts/classic-to-smartviz/chartMappers.mjs index 9dddef1d..a23c66fa 100644 --- a/scripts/classic-to-smartviz/chartMappers.mjs +++ b/scripts/classic-to-smartviz/chartMappers.mjs @@ -403,15 +403,31 @@ const toDonutValue = (classicConfig, tileColumns, labelField, notes) => { export const toDonut = (classicConfig, tileColumns) => { const notes = []; + /** Classic's per-label colours, which SmartViz matches the same way: by label, ignoring case. */ + const hasPalette = + classicConfig.palette !== null && + typeof classicConfig.palette === 'object' && + Object.values(classicConfig.palette).length > 0 && + Object.values(classicConfig.palette).every((color) => typeof color === 'string'); + if (!isSet(classicConfig.labelColumn) || !isSet(classicConfig.valueColumn)) { return { blockedReason: 'label or value column is set to auto' }; } - if (!isKnownColumn(tileColumns, classicConfig.labelColumn)) { + /** Like classic, a label column the tile no longer has falls back to its only category column. */ + const labelField = isKnownColumn(tileColumns, classicConfig.labelColumn) + ? classicConfig.labelColumn + : findSoleColumnOfType(tileColumns, 'nominal'); + + if (!labelField) { return { blockedReason: `label column ${classicConfig.labelColumn} no longer exists` }; } - const value = toDonutValue(classicConfig, tileColumns, classicConfig.labelColumn, notes); + if (labelField !== classicConfig.labelColumn) { + notes.push(`label column ${classicConfig.labelColumn} no longer exists; using ${labelField}`); + } + + const value = toDonutValue(classicConfig, tileColumns, labelField, notes); if (!value) { return { blockedReason: `value column ${classicConfig.valueColumn} no longer exists` }; @@ -421,10 +437,11 @@ export const toDonut = (classicConfig, tileColumns) => { pieInnerRadius: 60, pieDonutCenterValue: classicConfig.hideCenterValue === true ? 'none' : 'total', labelFontSize: 16, - ...(classicConfig.legendMode === 'table' && { pieLegendStyle: 'table' }) + ...(classicConfig.legendMode === 'table' && { pieLegendStyle: 'table' }), + ...(hasPalette && { mappedColors: classicConfig.palette }) }; - const encoding = { label: { field: classicConfig.labelColumn, type: 'nominal' }, value }; + const encoding = { label: { field: labelField, type: 'nominal', title: getColumnTitle(tileColumns.columns, labelField) }, value }; return toVisualization('pie', encoding, style, notes); }; diff --git a/scripts/classic-to-smartviz/columns.mjs b/scripts/classic-to-smartviz/columns.mjs index 97118ce5..f33c71a4 100644 --- a/scripts/classic-to-smartviz/columns.mjs +++ b/scripts/classic-to-smartviz/columns.mjs @@ -86,7 +86,8 @@ export const readStreamColumns = (pluginDirectory) => { const packagedStreams = (packaged?.dataStreams ?? []).map(({ definition = {} }) => { const metadata = definition.metadata ?? rowTypeMetadataByName.get(definition.rowType); - return [definition.name, Array.isArray(metadata) ? toColumnSet(metadata) : undefined]; + /** A packaged plugin's handler can return columns its metadata does not declare, so the list is never complete. */ + return [definition.name, Array.isArray(metadata) ? { ...toColumnSet(metadata), isComplete: false } : undefined]; }); return new Map([...lowCodeStreams, ...packagedStreams]); From 09e4f6b0bf107cf95e3a09a58eb48c74d94167e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:01:54 +0000 Subject: [PATCH 12/16] Convert row-count scalars and tables of up to 50 columns SmartViz now allows up to 50 table columns, and a scalar with scalarRowCount set no longer needs a value column (saas #10217). The converter maps classic row counts to scalarRowCount and raises its table cap to 50, so 108 more OOB tiles convert. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- scripts/classic-to-smartviz/README.md | 2 +- scripts/classic-to-smartviz/chartMappers.mjs | 23 +++++++++++++------- scripts/classic-to-smartviz/tableMappers.mjs | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/scripts/classic-to-smartviz/README.md b/scripts/classic-to-smartviz/README.md index b887ee3b..9e1455ab 100644 --- a/scripts/classic-to-smartviz/README.md +++ b/scripts/classic-to-smartviz/README.md @@ -56,7 +56,7 @@ On top of the like-for-like conversion, these rules improve a tile where the rep A tile is reported as blocked, and left classic, when converting it would mean guessing: - **A table that shows all columns** of a stream that does not declare its full column list. -- **A table with more than 12 columns.** A SmartViz table holds at most 12. +- **A table with more than 50 columns.** A SmartViz table holds at most 50. - **A line or bar chart that plots some, but not all, of a stream's numeric columns.** SmartViz plots one y column, or every numeric column. - **A scalar, gauge or donut set to pick its columns automatically**, when the stream does not declare them. diff --git a/scripts/classic-to-smartviz/chartMappers.mjs b/scripts/classic-to-smartviz/chartMappers.mjs index a23c66fa..cf2c83a3 100644 --- a/scripts/classic-to-smartviz/chartMappers.mjs +++ b/scripts/classic-to-smartviz/chartMappers.mjs @@ -281,7 +281,7 @@ export const toBarChart = (classicConfig, tileColumns) => { /** * Reads a classic scalar or gauge `value`: a column name, or a reduction `{ type, columns }` over all rows. * A value column the tile no longer has falls back to its only numeric column, as classic's default does. - * A count needs a field to point at, so it borrows any column the tile is known to have. + * A count is flagged `isRowCount`; a gauge has no row count, so it borrows any column the tile is known to have. */ const toValueChannel = (value, tileColumns, notes) => { if (typeof value === 'string' && isSet(value)) { @@ -305,9 +305,10 @@ const toValueChannel = (value, tileColumns, notes) => { if (aggregate === 'count') { const anyField = value.columns?.[0] ?? tileColumns.columns[0]?.name; - return anyField - ? { channel: { field: anyField, type: 'quantitative', aggregate: 'count', title: 'Count' } } - : { blockedReason: 'counts rows, and the stream declares no column to count' }; + return { + isRowCount: true, + ...(anyField && { channel: { field: anyField, type: 'quantitative', aggregate: 'count', title: 'Count' } }) + }; } if (aggregate && value.columns?.length === 1) { @@ -320,7 +321,7 @@ const toValueChannel = (value, tileColumns, notes) => { /** Converts a classic `data-stream-scalar` config to a scalar spec. */ export const toScalar = (classicConfig, tileColumns) => { const notes = []; - const { channel, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); + const { channel, isRowCount, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); if (blockedReason) { return { blockedReason }; @@ -329,19 +330,21 @@ export const toScalar = (classicConfig, tileColumns) => { const comparisonField = findConfiguredColumn(tileColumns, classicConfig.comparisonColumn, 'comparison', notes); const labelPosition = classicConfig.labelPosition; - if (!findColumn(tileColumns.columns, channel.field)) { + if (!isRowCount && !findColumn(tileColumns.columns, channel.field)) { notes.push(`value column ${channel.field} is not declared by the stream; typed as a number`); } const labelText = typeof classicConfig.label === 'string' ? classicConfig.label : ''; /** Classic shows no label unless one is set, where SmartViz would show the field name. */ - const style = labelText + const labelStyle = labelText ? { scalarLabelText: labelText, ...(labelPosition && labelPosition !== 'bottom' && { scalarLabelPosition: labelPosition }) } : { scalarLabelPosition: 'none' }; + const style = { ...labelStyle, ...(isRowCount && { scalarRowCount: true }) }; + /** A row count reads no column, so it needs no value channel. */ const encoding = { - values: [channel], + values: isRowCount ? [] : [channel], ...(comparisonField && { comparison: toChannel(tileColumns, comparisonField, 'quantitative') }) }; @@ -357,6 +360,10 @@ export const toGauge = (classicConfig, tileColumns) => { return { blockedReason }; } + if (!channel) { + return { blockedReason: 'counts rows, and the stream declares no column to count' }; + } + const minimumField = findConfiguredColumn(tileColumns, classicConfig.minimumColumn, 'minimum', notes); const maximumField = findConfiguredColumn(tileColumns, classicConfig.maximumColumn, 'maximum', notes); diff --git a/scripts/classic-to-smartviz/tableMappers.mjs b/scripts/classic-to-smartviz/tableMappers.mjs index ef5fef7e..32d554a9 100644 --- a/scripts/classic-to-smartviz/tableMappers.mjs +++ b/scripts/classic-to-smartviz/tableMappers.mjs @@ -1,6 +1,6 @@ import { findColumn, getColumnTitle, isKnownColumn } from './columns.mjs'; -const MAX_TABLE_COLUMNS = 12; +const MAX_TABLE_COLUMNS = 50; const isSet = (value) => value !== undefined && value !== null && value !== '' && value !== 'auto' && value !== 'none'; From b223c8e33275071c91b2b70f1fc868211b571c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 09:58:09 +0000 Subject: [PATCH 13/16] Let SmartViz pick automatic columns and plot y column lists SmartViz now finds a channel left out of the spec in the data as classic does, plots a list of y columns, and labels donuts with name and percentage (saas #10225). The converter uses these: a column classic left on auto is left out of the spec, several y columns become a list, properties-stream tiles convert, "show values as percentage" maps to pieLabels nameAndPercent, and percent table columns use the bar renderer out of 100 instead of the always-green progress bar. Across both plugin repos, 2,441 of 2,470 classic OOB tiles now convert. Tables that hide or rename columns of a stream with no full column list still stay classic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- scripts/classic-to-smartviz/README.md | 18 ++--- scripts/classic-to-smartviz/chartMappers.mjs | 81 +++++++++++-------- scripts/classic-to-smartviz/columns.mjs | 4 - scripts/classic-to-smartviz/convert.mjs | 6 +- .../classic-to-smartviz/enhancementRules.mjs | 19 +++-- scripts/classic-to-smartviz/tableMappers.mjs | 58 +++++++------ 6 files changed, 103 insertions(+), 83 deletions(-) diff --git a/scripts/classic-to-smartviz/README.md b/scripts/classic-to-smartviz/README.md index 9e1455ab..e6712c12 100644 --- a/scripts/classic-to-smartviz/README.md +++ b/scripts/classic-to-smartviz/README.md @@ -28,15 +28,15 @@ node scripts/classic-to-smartviz/convert.mjs --write --bump patch --schema [Expanded].rawState` column, as a badge coloured by the map. | +| Table | `richTable` | The shown columns are `columnOrder` minus `hiddenColumns`, or every visible declared column when there is no `columnOrder`. When the stream does not declare its full column list, the spec names no columns and SmartViz shows every column the data has. Headers come from `columnDisplayNames`, then the column's `displayName`, then the name the platform would infer. State, date and URL columns get the status, date and link renderers; everything else uses the text renderer, which keeps the shape's formatting. A state column that maps its own values to states (say `Win` to success) shows those values instead, from the platform's `[Expanded].rawState` column, as a badge coloured by the map. | | Status blocks | `statusGrid` | The sublabel becomes a `statusGridSecondaryTemplate`, wrapped in `{{{{raw}}}}` so install-time templating leaves it alone. | -| Line graph | `line` | Classic's default fitted y axis and hidden data points carry over. Area shading is kept only when the lines are not split into series. | -| Bar chart | `bar` | Grouping, stacking, horizontal layout, value labels and simple colour rules carry over. | -| Scalar | `scalar` | | +| Line graph | `line` | Classic's default fitted y axis and hidden data points carry over. Several y columns become a list. Area shading is kept only when the lines are not split into series. | +| Bar chart | `bar` | Grouping, stacking, horizontal layout, value labels and simple colour rules carry over. Several y columns become a list. | +| Scalar | `scalar` | A row count becomes `scalarRowCount`. | | Gauge | `gauge` | | -| Donut | `pie` with a 60% hole and the total in the centre | | +| Donut | `pie` with a 60% hole and the total in the centre | "Show values as percentage" becomes `pieLabels: "nameAndPercent"`. | -Tiles on the built-in properties stream, iframe tiles and tiles that are already SmartViz are left alone. +A column classic leaves on auto (a scalar or gauge value, a donut label or value, a status block label) is left out of the spec, and SmartViz picks it from the data the way classic does. Iframe tiles and tiles that are already SmartViz are left alone. ## Enhancement rules @@ -46,7 +46,7 @@ On top of the like-for-like conversion, these rules improve a tile where the rep | --- | --- | --- | | `recencyDates` | Shows a date as "3 hours ago", with the exact time in the tooltip | A table date column named like last seen, updated, modified, checked, heartbeat, sync, refreshed or polled | | `statusColours` | Colours status words: critical / error / failed / down → red, warning / medium / degraded → amber, ok / healthy / success / up → green, low / info → blue, unknown → grey. Other values keep the palette | A donut, or a bar or line chart split by a column named like status, state, health, result, severity, priority, outcome, level or condition, with no colours of its own | -| `percentBars` | Shows a bar filled to the value out of 100 | A table column with the percent shape, unless its name suggests a signed value (difference, change, delta, growth, trend, result) | +| `percentBars` | Shows a bar filled to the value out of 100, in a neutral colour | A table column with the percent shape, unless its name suggests a signed value (difference, change, delta, growth, trend, result) | | `percentAxis` | Fixes the y axis to 0–100% | A line chart whose y column has the percent shape and no range of its own | | `percentGauge` | Runs the gauge from 0 to 100 | A gauge of a percent-shaped column with no range of its own | | `booleanLabels` | Shows "Yes" / "No" in the table's own text colour, so neither value reads as good or bad | A table column with the boolean shape | @@ -55,10 +55,8 @@ On top of the like-for-like conversion, these rules improve a tile where the rep A tile is reported as blocked, and left classic, when converting it would mean guessing: -- **A table that shows all columns** of a stream that does not declare its full column list. +- **A table that hides or renames columns** of a stream that does not declare its full column list. SmartViz can show every column of such a stream, but not hide or rename one it cannot name. - **A table with more than 50 columns.** A SmartViz table holds at most 50. -- **A line or bar chart that plots some, but not all, of a stream's numeric columns.** SmartViz plots one y column, or every numeric column. -- **A scalar, gauge or donut set to pick its columns automatically**, when the stream does not declare them. A column the classic config names but the tile's data no longer has (for example, after a group was added) is handled the way classic handles it: dropped from a table, or left for SmartViz to pick on a chart. Each case is noted in the report. diff --git a/scripts/classic-to-smartviz/chartMappers.mjs b/scripts/classic-to-smartviz/chartMappers.mjs index cf2c83a3..07838b58 100644 --- a/scripts/classic-to-smartviz/chartMappers.mjs +++ b/scripts/classic-to-smartviz/chartMappers.mjs @@ -59,8 +59,8 @@ const toXChannel = (tileColumns, configured, chartType, notes) => { }; /** - * The y channel. Several y columns only convert when they are every numeric column the chart would plot - * anyway, because SmartViz plots either one y column or all numeric columns. + * The y channel: one column, a list of columns, or none when the columns are every numeric column the chart + * would plot anyway. */ const toYEncoding = (tileColumns, configured, xField, notes) => { const yColumns = toList(configured); @@ -87,9 +87,7 @@ const toYEncoding = (tileColumns, configured, xField, notes) => { const isEveryNumericColumn = isComplete && plottedByDefault.length === fields.length && fields.every((name) => plottedByDefault.includes(name)); - return isEveryNumericColumn - ? {} - : { blockedReason: `plots ${fields.length} of the stream's numeric columns; SmartViz plots one or all of them` }; + return isEveryNumericColumn ? {} : { y: fields.map((field) => toChannel(tileColumns, field, 'quantitative')) }; }; /** @@ -282,9 +280,14 @@ export const toBarChart = (classicConfig, tileColumns) => { * Reads a classic scalar or gauge `value`: a column name, or a reduction `{ type, columns }` over all rows. * A value column the tile no longer has falls back to its only numeric column, as classic's default does. * A count is flagged `isRowCount`; a gauge has no row count, so it borrows any column the tile is known to have. + * A value left on auto is flagged `isAutomatic`, for SmartViz to find in the data as classic does. */ const toValueChannel = (value, tileColumns, notes) => { - if (typeof value === 'string' && isSet(value)) { + if (!isSet(value)) { + return { isAutomatic: true }; + } + + if (typeof value === 'string') { if (isKnownColumn(tileColumns, value)) { return { channel: toChannel(tileColumns, value, 'quantitative') }; } @@ -315,13 +318,13 @@ const toValueChannel = (value, tileColumns, notes) => { return { channel: toChannel(tileColumns, value.columns[0], 'quantitative', { aggregate }) }; } - return { blockedReason: 'value column is set to auto' }; + return { blockedReason: `value reduction ${value?.type} has no SmartViz equivalent` }; }; /** Converts a classic `data-stream-scalar` config to a scalar spec. */ export const toScalar = (classicConfig, tileColumns) => { const notes = []; - const { channel, isRowCount, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); + const { channel, isRowCount, isAutomatic, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); if (blockedReason) { return { blockedReason }; @@ -330,7 +333,9 @@ export const toScalar = (classicConfig, tileColumns) => { const comparisonField = findConfiguredColumn(tileColumns, classicConfig.comparisonColumn, 'comparison', notes); const labelPosition = classicConfig.labelPosition; - if (!isRowCount && !findColumn(tileColumns.columns, channel.field)) { + if (isAutomatic) { + notes.push('value column left for SmartViz to pick, as classic does'); + } else if (!isRowCount && !findColumn(tileColumns.columns, channel.field)) { notes.push(`value column ${channel.field} is not declared by the stream; typed as a number`); } @@ -342,9 +347,9 @@ export const toScalar = (classicConfig, tileColumns) => { : { scalarLabelPosition: 'none' }; const style = { ...labelStyle, ...(isRowCount && { scalarRowCount: true }) }; - /** A row count reads no column, so it needs no value channel. */ + /** A row count reads no column, so it needs no value channel; an automatic value has none to name. */ const encoding = { - values: isRowCount ? [] : [channel], + ...(!isAutomatic && { values: isRowCount ? [] : [channel] }), ...(comparisonField && { comparison: toChannel(tileColumns, comparisonField, 'quantitative') }) }; @@ -354,13 +359,15 @@ export const toScalar = (classicConfig, tileColumns) => { /** Converts a classic `data-stream-gauge` config to a gauge spec. */ export const toGauge = (classicConfig, tileColumns) => { const notes = []; - const { channel, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); + const { channel, isAutomatic, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); if (blockedReason) { return { blockedReason }; } - if (!channel) { + if (isAutomatic) { + notes.push('value column left for SmartViz to pick, as classic does'); + } else if (!channel) { return { blockedReason: 'counts rows, and the stream declares no column to count' }; } @@ -374,7 +381,7 @@ export const toGauge = (classicConfig, tileColumns) => { }; const encoding = { - value: { ...channel, type: 'quantitative' }, + ...(channel && { value: { ...channel, type: 'quantitative' } }), ...(minimumField && { min: toChannel(tileColumns, minimumField, 'quantitative') }), ...(maximumField && { max: toChannel(tileColumns, maximumField, 'quantitative') }) }; @@ -384,18 +391,25 @@ export const toGauge = (classicConfig, tileColumns) => { /** * The donut's value channel: a count of rows per label for classic's "Count (Built-in)", the configured - * column, or the tile's only numeric column when the configured one is gone. + * column, or the tile's only numeric column when the configured one is gone. `undefined` leaves the value for + * SmartViz to find in the data, as classic does when it is on auto or no column fits. */ const toDonutValue = (classicConfig, tileColumns, labelField, notes) => { const configured = classicConfig.valueColumn; + if (!isSet(configured)) { + return undefined; + } + if (configured === BUILT_IN_COUNT) { - return { field: labelField, type: 'quantitative', aggregate: 'count', title: 'Count' }; + return labelField ? { field: labelField, type: 'quantitative', aggregate: 'count', title: 'Count' } : undefined; } const field = isKnownColumn(tileColumns, configured) ? configured : findSoleColumnOfType(tileColumns, 'quantitative'); if (!field) { + notes.push(`value column ${configured} no longer exists, so SmartViz picks one as classic did`); + return undefined; } @@ -417,27 +431,24 @@ export const toDonut = (classicConfig, tileColumns) => { Object.values(classicConfig.palette).length > 0 && Object.values(classicConfig.palette).every((color) => typeof color === 'string'); - if (!isSet(classicConfig.labelColumn) || !isSet(classicConfig.valueColumn)) { - return { blockedReason: 'label or value column is set to auto' }; - } - /** Like classic, a label column the tile no longer has falls back to its only category column. */ - const labelField = isKnownColumn(tileColumns, classicConfig.labelColumn) - ? classicConfig.labelColumn - : findSoleColumnOfType(tileColumns, 'nominal'); - - if (!labelField) { - return { blockedReason: `label column ${classicConfig.labelColumn} no longer exists` }; - } - - if (labelField !== classicConfig.labelColumn) { - notes.push(`label column ${classicConfig.labelColumn} no longer exists; using ${labelField}`); + const configuredLabel = isSet(classicConfig.labelColumn) ? classicConfig.labelColumn : undefined; + const labelField = + configuredLabel && + (isKnownColumn(tileColumns, configuredLabel) ? configuredLabel : findSoleColumnOfType(tileColumns, 'nominal')); + + if (configuredLabel && labelField !== configuredLabel) { + notes.push( + labelField + ? `label column ${configuredLabel} no longer exists; using ${labelField}` + : `label column ${configuredLabel} no longer exists, so SmartViz picks one as classic did` + ); } const value = toDonutValue(classicConfig, tileColumns, labelField, notes); - if (!value) { - return { blockedReason: `value column ${classicConfig.valueColumn} no longer exists` }; + if (!labelField || !value) { + notes.push('label or value column left for SmartViz to pick, as classic does'); } const style = { @@ -445,10 +456,14 @@ export const toDonut = (classicConfig, tileColumns) => { pieDonutCenterValue: classicConfig.hideCenterValue === true ? 'none' : 'total', labelFontSize: 16, ...(classicConfig.legendMode === 'table' && { pieLegendStyle: 'table' }), + ...(classicConfig.showValuesAsPercentage === true && { pieLabels: 'nameAndPercent' }), ...(hasPalette && { mappedColors: classicConfig.palette }) }; - const encoding = { label: { field: labelField, type: 'nominal', title: getColumnTitle(tileColumns.columns, labelField) }, value }; + const encoding = { + ...(labelField && { label: { field: labelField, type: 'nominal', title: getColumnTitle(tileColumns.columns, labelField) } }), + ...(value && { value }) + }; return toVisualization('pie', encoding, style, notes); }; diff --git a/scripts/classic-to-smartviz/columns.mjs b/scripts/classic-to-smartviz/columns.mjs index f33c71a4..b8d72683 100644 --- a/scripts/classic-to-smartviz/columns.mjs +++ b/scripts/classic-to-smartviz/columns.mjs @@ -102,10 +102,6 @@ export const getStreamName = (tileConfig) => { return STREAM_REFERENCE.exec(dataStream.id ?? '')?.[1] ?? (dataStream.id ? undefined : dataStream.name); }; -/** True for a tile on the built-in object properties stream, whose columns are the scoped object's properties. */ -export const isPropertiesTile = (tileConfig) => - tileConfig.dataStream?.id === 'datastream-properties' || tileConfig.dataStream?.name === 'properties'; - /** A group's `by` is one `[column, grouper]` pair, a list of them, or empty. */ const toByPairs = (by) => { if (!Array.isArray(by) || by.length === 0) { diff --git a/scripts/classic-to-smartviz/convert.mjs b/scripts/classic-to-smartviz/convert.mjs index adb6b7dc..7543abf3 100644 --- a/scripts/classic-to-smartviz/convert.mjs +++ b/scripts/classic-to-smartviz/convert.mjs @@ -4,7 +4,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { parseArgs } from 'node:util'; import { toBarChart, toDonut, toGauge, toLineChart, toScalar } from './chartMappers.mjs'; -import { getTileColumns, isKnownColumn, isPropertiesTile, readStreamColumns } from './columns.mjs'; +import { getTileColumns, isKnownColumn, readStreamColumns } from './columns.mjs'; import { replaceMembers } from './jsonEdit.mjs'; import { applyEnhancementRules, RULE_NAMES } from './enhancementRules.mjs'; import { toRichTable, toStatusGrid } from './tableMappers.mjs'; @@ -92,10 +92,6 @@ const convertTile = (tileConfig, streamColumnsByName, { vizSpecSchema, ruleNames return { title, status: 'skipped', reason: 'already SmartViz' }; } - if (isPropertiesTile(tileConfig)) { - return { title, status: 'skipped', reason: 'built-in properties stream stays classic' }; - } - const classicType = tileConfig.visualisation?.type; const mapper = MAPPERS[classicType]; diff --git a/scripts/classic-to-smartviz/enhancementRules.mjs b/scripts/classic-to-smartviz/enhancementRules.mjs index bc046c0c..c3ea942e 100644 --- a/scripts/classic-to-smartviz/enhancementRules.mjs +++ b/scripts/classic-to-smartviz/enhancementRules.mjs @@ -54,7 +54,8 @@ const hasColorStyle = (style = {}) => /** Rewrites the renderer of each richTable column `pick` selects, returning the spec and the fields it changed. */ const updateTableColumns = (spec, pick, toRenderer) => { - if (spec.chartType !== 'richTable') { + /** An automatic table names no columns, so it has none to change. */ + if (spec.chartType !== 'richTable' || !spec.encoding.columns) { return { spec, fields: [] }; } @@ -104,8 +105,9 @@ export const ENHANCEMENT_RULES = { }, /** - * Percent columns in tables show as a bar filled to the value out of 100. Columns whose name suggests a signed - * value (a difference or change) are left as text, because a bar cannot show a negative. + * Percent columns in tables show as a bar filled to the value out of 100, in the bar's neutral default colour + * rather than the always-healthy fill of `progress`. Columns whose name suggests a signed value (a difference + * or change) are left as text, because a bar cannot show a negative. */ percentBars: (spec, context) => { const { spec: updated, fields } = updateTableColumns( @@ -114,10 +116,10 @@ export const ENHANCEMENT_RULES = { column.renderer.type === 'text' && getShapeName(context, column.field) === 'percent' && !SIGNED_NAME.test(getLabel(context, column.field)), - () => ({ type: 'progress' }) + () => ({ type: 'bar', max: 100 }) ); - return { spec: updated, note: fields.length > 0 ? `progress bars for ${fields.join(', ')}` : undefined }; + return { spec: updated, note: fields.length > 0 ? `bars out of 100 for ${fields.join(', ')}` : undefined }; }, /** A line chart of a percentage gets a fixed 0–100 axis, unless the tile set its own range. */ @@ -125,7 +127,10 @@ export const ENHANCEMENT_RULES = { const { chartType, encoding, style = {} } = spec; const hasOwnRange = style.cartesianYAxisMin !== undefined || style.cartesianYAxisMax !== undefined; - if (chartType !== 'line' || !encoding.y || hasOwnRange || getShapeName(context, encoding.y.field) !== 'percent') { + const yChannels = [encoding.y ?? []].flat(); + const isPercent = yChannels.length > 0 && yChannels.every((channel) => getShapeName(context, channel.field) === 'percent'); + + if (chartType !== 'line' || hasOwnRange || !isPercent) { return { spec }; } @@ -138,7 +143,7 @@ export const ENHANCEMENT_RULES = { const hasOwnRange = style.gaugeMin !== undefined || style.gaugeMax !== undefined || encoding.min !== undefined || encoding.max !== undefined; - if (chartType !== 'gauge' || hasOwnRange || getShapeName(context, encoding.value.field) !== 'percent') { + if (chartType !== 'gauge' || !encoding.value || hasOwnRange || getShapeName(context, encoding.value.field) !== 'percent') { return { spec }; } diff --git a/scripts/classic-to-smartviz/tableMappers.mjs b/scripts/classic-to-smartviz/tableMappers.mjs index 32d554a9..feccf80f 100644 --- a/scripts/classic-to-smartviz/tableMappers.mjs +++ b/scripts/classic-to-smartviz/tableMappers.mjs @@ -63,7 +63,8 @@ const toRenderer = (column) => { /** * The columns a classic table shows, in order. `columnOrder` is the author's list of shown columns, as the * in-product SmartViz migration reads it; `hiddenColumns` always wins. Like classic, names the data no longer - * has are dropped, and with none left (or no `columnOrder`) every visible column shows. + * has are dropped, and with none left (or no `columnOrder`) every visible column shows. When the stream does + * not declare its full column list, `isAutomatic` leaves the columns for SmartViz to find in the data. */ const getShownColumnNames = (classicConfig, tileColumns, notes) => { const hiddenColumns = new Set(classicConfig.hiddenColumns ?? []); @@ -82,7 +83,12 @@ const getShownColumnNames = (classicConfig, tileColumns, notes) => { } if (!tileColumns.isComplete) { - return { blockedReason: 'shows all columns, and the stream does not declare its full column list' }; + /** An automatic table shows every column the data has, so it cannot hide or rename any. */ + const isCustomised = hiddenColumns.size > 0 || Object.keys(classicConfig.columnDisplayNames ?? {}).length > 0; + + return isCustomised + ? { blockedReason: 'hides or renames columns of a stream that does not declare its full column list' } + : { isAutomatic: true }; } return { @@ -102,12 +108,23 @@ const getRowLinkField = (classicConfig) => { /** Converts a classic `data-stream-table` config to a richTable spec. */ export const toRichTable = (classicConfig, tileColumns) => { const notes = []; - const { names, blockedReason } = getShownColumnNames(classicConfig, tileColumns, notes); + const { names, isAutomatic, blockedReason } = getShownColumnNames(classicConfig, tileColumns, notes); if (blockedReason) { return { blockedReason }; } + const rowLinkField = getRowLinkField(classicConfig); + const style = { + ...(classicConfig.transpose === true && { richTableTranspose: true }), + ...(rowLinkField && isKnownColumn(tileColumns, rowLinkField) && { richTableRowLinkField: rowLinkField }) + }; + const withStyle = (encoding) => ({ chartType: 'richTable', encoding, ...(Object.keys(style).length > 0 && { style }) }); + + if (isAutomatic) { + return { spec: withStyle({}), notes: [...notes, 'shows every column the data has, as classic does'] }; + } + if (names.length === 0) { return { blockedReason: 'no columns to show' }; } @@ -119,30 +136,20 @@ export const toRichTable = (classicConfig, tileColumns) => { const { columns } = tileColumns; const displayNameOverrides = classicConfig.columnDisplayNames ?? {}; const undeclaredNames = names.filter((name) => !findColumn(columns, name)); - const rowLinkField = getRowLinkField(classicConfig); if (undeclaredNames.length > 0) { notes.push(`header and renderer inferred for undeclared column(s): ${undeclaredNames.join(', ')}`); } - const style = { - ...(classicConfig.transpose === true && { richTableTranspose: true }), - ...(rowLinkField && isKnownColumn(tileColumns, rowLinkField) && { richTableRowLinkField: rowLinkField }) - }; - return { - spec: { - chartType: 'richTable', - encoding: { - columns: names.map((name) => ({ - field: name, - title: getColumnTitle(columns, name, displayNameOverrides), - renderer: toRenderer(findColumn(columns, name)), - ...toMappedStateColumn(findColumn(columns, name)) - })) - }, - ...(Object.keys(style).length > 0 && { style }) - }, + spec: withStyle({ + columns: names.map((name) => ({ + field: name, + title: getColumnTitle(columns, name, displayNameOverrides), + renderer: toRenderer(findColumn(columns, name)), + ...toMappedStateColumn(findColumn(columns, name)) + })) + }), notes }; }; @@ -172,7 +179,7 @@ export const toStatusGrid = (classicConfig, tileColumns) => { const labelField = resolveColumn(tileColumns, classicConfig.labelColumn, (column) => column.role === 'label', 'label', notes); if (!labelField) { - return { blockedReason: 'no label column the tile still has, and the stream declares no label role' }; + notes.push('label column left for SmartViz to pick, as classic does'); } const statusField = resolveColumn(tileColumns, classicConfig.stateColumn, (column) => column.shapeName === 'state', 'state', notes); @@ -204,13 +211,16 @@ export const toStatusGrid = (classicConfig, tileColumns) => { spec: { chartType: 'statusGrid', encoding: { - label: toNominalChannel(labelField), + ...(labelField && { label: toNominalChannel(labelField) }), ...(statusField && { status: toNominalChannel(statusField) }), ...(groupField && { group: toNominalChannel(groupField) }), ...(linkField && { link: toNominalChannel(linkField) }) }, ...(Object.keys(style).length > 0 && { style }) }, - notes: [...notes, ...(findColumn(tileColumns.columns, labelField) ? [] : [`label column ${labelField} is not declared by the stream`])] + notes: [ + ...notes, + ...(!labelField || findColumn(tileColumns.columns, labelField) ? [] : [`label column ${labelField} is not declared by the stream`]) + ] }; }; From d09289b8a3d298cb34ab0a2abe318e4d8b2eaa81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:47:06 +0000 Subject: [PATCH 14/16] Stack bar charts with several y columns, as classic does Classic stacks a bar chart's y columns unless grouping is on, but the converter only stacked bars split by a series column, so a chart of several y columns drew them side by side. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- scripts/classic-to-smartviz/chartMappers.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/classic-to-smartviz/chartMappers.mjs b/scripts/classic-to-smartviz/chartMappers.mjs index 07838b58..53b2181f 100644 --- a/scripts/classic-to-smartviz/chartMappers.mjs +++ b/scripts/classic-to-smartviz/chartMappers.mjs @@ -233,7 +233,8 @@ const toBarColorStyle = (colorState, notes) => { }; }; -const toBarStackStyle = (classicConfig, seriesField) => { +/** Classic stacks the bars of several series, or of several y columns, unless grouping is on. */ +const toBarStackStyle = (classicConfig, hasSeveralBars) => { if (classicConfig.displayMode === 'percentage') { return { cartesianStack: 'normalize' }; } @@ -242,7 +243,7 @@ const toBarStackStyle = (classicConfig, seriesField) => { return { cartesianCumulative: true }; } - if (!seriesField) { + if (!hasSeveralBars) { return {}; } @@ -264,7 +265,7 @@ export const toBarChart = (classicConfig, tileColumns) => { const style = { ...toLegendAndAxisStyle(classicConfig, { x: true, y: true }), - ...toBarStackStyle(classicConfig, seriesField), + ...toBarStackStyle(classicConfig, Boolean(seriesField) || toList(classicConfig.yAxisData).length > 1), ...(classicConfig.horizontalLayout === 'horizontal' && { cartesianHorizontal: true }), ...(classicConfig.showValue === true && { showLabels: true }), ...(classicConfig.showTotals === true && { cartesianShowStackedTotals: true }), From 579ce9875b0420f468cafaa6c41f8d46b088d07e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:26:39 +0000 Subject: [PATCH 15/16] Convert the remaining OpenF1 tiles with the current converter Re-running the converter on the pilot plugins' main dashboards now also converts OpenF1's Driver Details and Meeting Details tables and the Weather chart, which SmartViz could not show before. Every tile the pilot had already converted comes out unchanged; the Lap Times lines stay named after the driver. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- .../OpenF1/v1/defaultContent/driver.dash.json | 71 ++++++++++----- .../v1/defaultContent/meeting.dash.json | 90 +++++++++++++++---- .../v1/defaultContent/session.dash.json | 40 ++++++--- 3 files changed, 153 insertions(+), 48 deletions(-) diff --git a/plugins/OpenF1/v1/defaultContent/driver.dash.json b/plugins/OpenF1/v1/defaultContent/driver.dash.json index fb9f5036..69fab278 100644 --- a/plugins/OpenF1/v1/defaultContent/driver.dash.json +++ b/plugins/OpenF1/v1/defaultContent/driver.dash.json @@ -35,27 +35,58 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Driver]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": true, - "columnOrder": [ - "name", - "name_acronym", - "team_name", - "team_colour", - "country_code", - "headshot_url" - ], - "hiddenColumns": [ - "id", - "link", - "links", - "label", - "sourceId", - "sourceType" + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "name", + "title": "Name", + "renderer": { + "type": "text" + } + }, + { + "field": "name_acronym", + "title": "Name Acronym", + "renderer": { + "type": "text" + } + }, + { + "field": "team_name", + "title": "Team Name", + "renderer": { + "type": "text" + } + }, + { + "field": "team_colour", + "title": "Team Colour", + "renderer": { + "type": "text" + } + }, + { + "field": "country_code", + "title": "Country Code", + "renderer": { + "type": "text" + } + }, + { + "field": "headshot_url", + "title": "Headshot Url", + "renderer": { + "type": "text" + } + } ] + }, + "style": { + "richTableTranspose": true } } } diff --git a/plugins/OpenF1/v1/defaultContent/meeting.dash.json b/plugins/OpenF1/v1/defaultContent/meeting.dash.json index a764396e..41c36214 100644 --- a/plugins/OpenF1/v1/defaultContent/meeting.dash.json +++ b/plugins/OpenF1/v1/defaultContent/meeting.dash.json @@ -31,23 +31,79 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Meeting]}}" }, - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": true, - "columnOrder": [ - "name", - "meeting_official_name", - "circuit_short_name", - "circuit_type", - "country_name", - "location", - "year", - "date_start", - "date_end" - ], - "hiddenColumns": ["id", "link", "links", "label", "sourceId", "sourceType", "meeting_name", "country_code"] + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "name", + "title": "Name", + "renderer": { + "type": "text" + } + }, + { + "field": "meeting_official_name", + "title": "Meeting Official Name", + "renderer": { + "type": "text" + } + }, + { + "field": "circuit_short_name", + "title": "Circuit Short Name", + "renderer": { + "type": "text" + } + }, + { + "field": "circuit_type", + "title": "Circuit Type", + "renderer": { + "type": "text" + } + }, + { + "field": "country_name", + "title": "Country Name", + "renderer": { + "type": "text" + } + }, + { + "field": "location", + "title": "Location", + "renderer": { + "type": "text" + } + }, + { + "field": "year", + "title": "Year", + "renderer": { + "type": "text" + } + }, + { + "field": "date_start", + "title": "Date Start", + "renderer": { + "type": "text" + } + }, + { + "field": "date_end", + "title": "Date End", + "renderer": { + "type": "text" + } + } + ] + }, + "style": { + "richTableTranspose": true } } } diff --git a/plugins/OpenF1/v1/defaultContent/session.dash.json b/plugins/OpenF1/v1/defaultContent/session.dash.json index 76898245..962b7aa2 100644 --- a/plugins/OpenF1/v1/defaultContent/session.dash.json +++ b/plugins/OpenF1/v1/defaultContent/session.dash.json @@ -464,18 +464,36 @@ "workspace": "{{workspaceId}}", "variable": "{{variables.[F1 Session]}}" }, - "visualisation": { - "type": "data-stream-line-graph", - "config": { - "data-stream-line-graph": { - "xAxisColumn": "date", - "yAxisColumn": ["air_temperature", "track_temperature"], - "seriesColumn": "none", - "showLegend": true, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "date", + "type": "temporal", + "title": "Time" + }, + "y": [ + { + "field": "air_temperature", + "type": "quantitative", + "title": "Air Temp (°C)" + }, + { + "field": "track_temperature", + "type": "quantitative", + "title": "Track Temp (°C)" + } + ] + }, + "style": { "legendPosition": "bottom", - "yAxisLabel": "Temperature (°C)", - "showYAxisLabel": true, - "showTrendLine": false + "cartesianShowYAxisTitle": true, + "cartesianYAxisTitle": "Temperature (°C)", + "cartesianYAxisRangeMode": "fit", + "cartesianSymbol": "none", + "cartesianAreaGradient": true } } } From 0617c7a6e8fe279e1252aeed22ea2bf6685d6076 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:35:29 +0000 Subject: [PATCH 16/16] Remove the conversion script from this pull request This pull request now carries only the converted pilot dashboards and the template fixes. The script stays in this branch's history (579ce98) for later conversion phases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DsSxB9yhNwiJp2KU2D8Upi --- scripts/classic-to-smartviz/README.md | 65 --- scripts/classic-to-smartviz/chartMappers.mjs | 470 ------------------ scripts/classic-to-smartviz/columns.mjs | 228 --------- scripts/classic-to-smartviz/convert.mjs | 285 ----------- .../classic-to-smartviz/enhancementRules.mjs | 176 ------- scripts/classic-to-smartviz/jsonEdit.mjs | 141 ------ scripts/classic-to-smartviz/tableMappers.mjs | 226 --------- 7 files changed, 1591 deletions(-) delete mode 100644 scripts/classic-to-smartviz/README.md delete mode 100644 scripts/classic-to-smartviz/chartMappers.mjs delete mode 100644 scripts/classic-to-smartviz/columns.mjs delete mode 100644 scripts/classic-to-smartviz/convert.mjs delete mode 100644 scripts/classic-to-smartviz/enhancementRules.mjs delete mode 100644 scripts/classic-to-smartviz/jsonEdit.mjs delete mode 100644 scripts/classic-to-smartviz/tableMappers.mjs diff --git a/scripts/classic-to-smartviz/README.md b/scripts/classic-to-smartviz/README.md deleted file mode 100644 index e6712c12..00000000 --- a/scripts/classic-to-smartviz/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# classic-to-smartviz - -Converts the classic tiles in a plugin's default dashboards to SmartViz tiles. Each classic `config.visualisation` is replaced with a `config.vizSpec`, in place, so the rest of the file is left untouched and the diff shows only the tiles that changed. - -It works from the repository alone. Column names, types and headers come from the classic config, the tile's group and `metadata`, and the stream's declared `metadata`. No data is fetched. - -## Usage - -```sh -# Report only — what would convert, what is blocked and why -node scripts/classic-to-smartviz/convert.mjs plugins/OpenF1/v1 - -# Convert, bump each changed plugin by a patch, and validate every spec against the schema -node scripts/classic-to-smartviz/convert.mjs --write --bump patch --schema plugins/OpenF1/v1 plugins/NASA/v1 -``` - -| Option | | -| --- | --- | -| `--write` | Write the converted dashboards. Without it the script only reports. | -| `--bump patch\|minor` | With `--write`, bump `metadata.json` in each folder that had a tile converted. | -| `--schema ` | A CommonJS module exporting `vizSpecSchema` from `@squaredup/viz-spec` (in the saas repo). A spec that fails it, or loses a key to it, stays classic. | -| `--rules ` | The enhancement rules to apply on top of the conversion: `all` (the default), `none`, or a comma-separated list. See [Enhancement rules](#enhancement-rules). | -| `--json ` | Also write the report as JSON. | - -`squaredup validate` checks every `vizSpec` against the same schema when the plugin is validated or deployed, so `--schema` is a convenience for checking a large batch before you push. - -## What converts to what - -| Classic | SmartViz | Notes | -| --- | --- | --- | -| Table | `richTable` | The shown columns are `columnOrder` minus `hiddenColumns`, or every visible declared column when there is no `columnOrder`. When the stream does not declare its full column list, the spec names no columns and SmartViz shows every column the data has. Headers come from `columnDisplayNames`, then the column's `displayName`, then the name the platform would infer. State, date and URL columns get the status, date and link renderers; everything else uses the text renderer, which keeps the shape's formatting. A state column that maps its own values to states (say `Win` to success) shows those values instead, from the platform's `[Expanded].rawState` column, as a badge coloured by the map. | -| Status blocks | `statusGrid` | The sublabel becomes a `statusGridSecondaryTemplate`, wrapped in `{{{{raw}}}}` so install-time templating leaves it alone. | -| Line graph | `line` | Classic's default fitted y axis and hidden data points carry over. Several y columns become a list. Area shading is kept only when the lines are not split into series. | -| Bar chart | `bar` | Grouping, stacking, horizontal layout, value labels and simple colour rules carry over. Several y columns become a list. | -| Scalar | `scalar` | A row count becomes `scalarRowCount`. | -| Gauge | `gauge` | | -| Donut | `pie` with a 60% hole and the total in the centre | "Show values as percentage" becomes `pieLabels: "nameAndPercent"`. | - -A column classic leaves on auto (a scalar or gauge value, a donut label or value, a status block label) is left out of the spec, and SmartViz picks it from the data the way classic does. Iframe tiles and tiles that are already SmartViz are left alone. - -## Enhancement rules - -On top of the like-for-like conversion, these rules improve a tile where the repository alone shows it is safe to. None depends on what the data holds. They run only on tiles converted in that run, never on an existing `vizSpec`, and the report lists every rule that fired on each tile. - -| Rule | What it does | When | -| --- | --- | --- | -| `recencyDates` | Shows a date as "3 hours ago", with the exact time in the tooltip | A table date column named like last seen, updated, modified, checked, heartbeat, sync, refreshed or polled | -| `statusColours` | Colours status words: critical / error / failed / down → red, warning / medium / degraded → amber, ok / healthy / success / up → green, low / info → blue, unknown → grey. Other values keep the palette | A donut, or a bar or line chart split by a column named like status, state, health, result, severity, priority, outcome, level or condition, with no colours of its own | -| `percentBars` | Shows a bar filled to the value out of 100, in a neutral colour | A table column with the percent shape, unless its name suggests a signed value (difference, change, delta, growth, trend, result) | -| `percentAxis` | Fixes the y axis to 0–100% | A line chart whose y column has the percent shape and no range of its own | -| `percentGauge` | Runs the gauge from 0 to 100 | A gauge of a percent-shaped column with no range of its own | -| `booleanLabels` | Shows "Yes" / "No" in the table's own text colour, so neither value reads as good or bad | A table column with the boolean shape | - -## What it will not convert - -A tile is reported as blocked, and left classic, when converting it would mean guessing: - -- **A table that hides or renames columns** of a stream that does not declare its full column list. SmartViz can show every column of such a stream, but not hide or rename one it cannot name. -- **A table with more than 50 columns.** A SmartViz table holds at most 50. - -A column the classic config names but the tile's data no longer has (for example, after a group was added) is handled the way classic handles it: dropped from a table, or left for SmartViz to pick on a chart. Each case is noted in the report. - -## Before you raise the pull request - -The script cannot see data, so check each converted dashboard in a deployed plugin, with real or demo data, and compare it with the classic version. diff --git a/scripts/classic-to-smartviz/chartMappers.mjs b/scripts/classic-to-smartviz/chartMappers.mjs deleted file mode 100644 index 53b2181f..00000000 --- a/scripts/classic-to-smartviz/chartMappers.mjs +++ /dev/null @@ -1,470 +0,0 @@ -import { findColumn, findFieldType, findSoleColumnOfType, getColumnTitle, isKnownColumn, isTimeLikeName } from './columns.mjs'; - -/** The classic Y-axis "Count (Built-in)" option. */ -const BUILT_IN_COUNT = 'builtin_count_by_label'; - -const LEGEND_POSITIONS = new Set(['top', 'bottom', 'left', 'right']); - -const REDUCTION_AGGREGATES = { sum: 'sum', mean: 'avg', avg: 'avg', min: 'min', max: 'max', count: 'count' }; - -const isSet = (value) => value !== undefined && value !== null && value !== '' && value !== 'auto' && value !== 'none'; - -const toList = (value) => (Array.isArray(value) ? value : isSet(value) ? [value] : []); - -/** - * A channel for a column, typed from its declared shape or, failing that, from the role the channel plays. Its - * title is the header classic showed, because SmartViz falls back to the raw field name in axis titles. - */ -const toChannel = ({ columns }, field, fallbackType, extra = {}) => ({ - field, - type: findFieldType(columns, field) ?? fallbackType, - title: getColumnTitle(columns, field), - ...extra -}); - -/** A configured column the tile still has, or `undefined` (with a note) when it is unset or gone. */ -const findConfiguredColumn = (tileColumns, configured, label, notes) => { - if (!isSet(configured)) { - return undefined; - } - - if (isKnownColumn(tileColumns, configured)) { - return configured; - } - - notes.push(`${label} column ${configured} no longer exists, so SmartViz picks one as classic did`); - - return undefined; -}; - -/** The x channel of a line or bar chart, or `undefined` to let SmartViz pick the x column itself. */ -const toXChannel = (tileColumns, configured, chartType, notes) => { - const field = findConfiguredColumn(tileColumns, configured, 'x', notes); - - if (!field) { - return undefined; - } - - const declaredType = findFieldType(tileColumns.columns, field); - const title = getColumnTitle(tileColumns.columns, field); - - if (declaredType) { - return { field, type: declaredType, title }; - } - - const guessedType = chartType === 'line' || isTimeLikeName(field) ? 'temporal' : 'nominal'; - notes.push(`x column ${field} has no declared shape; typed as ${guessedType}`); - - return { field, type: guessedType, title }; -}; - -/** - * The y channel: one column, a list of columns, or none when the columns are every numeric column the chart - * would plot anyway. - */ -const toYEncoding = (tileColumns, configured, xField, notes) => { - const yColumns = toList(configured); - - if (yColumns[0] === BUILT_IN_COUNT) { - return xField - ? { y: { field: xField, type: 'quantitative', aggregate: 'count', title: 'Count' } } - : { blockedReason: 'counts rows with no x column set' }; - } - - const fields = yColumns - .filter((name) => name !== BUILT_IN_COUNT) - .map((name) => findConfiguredColumn(tileColumns, name, 'y', notes)) - .filter(Boolean); - - if (fields.length <= 1) { - return fields.length === 1 ? { y: toChannel(tileColumns, fields[0], 'quantitative') } : {}; - } - - const { columns, isComplete } = tileColumns; - const plottedByDefault = columns - .filter((column) => column.isVisible && column.name !== xField && findFieldType(columns, column.name) === 'quantitative') - .map((column) => column.name); - const isEveryNumericColumn = - isComplete && plottedByDefault.length === fields.length && fields.every((name) => plottedByDefault.includes(name)); - - return isEveryNumericColumn ? {} : { y: fields.map((field) => toChannel(tileColumns, field, 'quantitative')) }; -}; - -/** - * Legend and axis-title style shared by line and bar. `autoTitleAxes` lists the axes classic gives a default - * title when the title is switched on with no text. - */ -const toLegendAndAxisStyle = (classicConfig, autoTitleAxes) => { - const xTitle = classicConfig.xAxisLabel; - const yTitle = classicConfig.yAxisLabel; - const showsXTitle = - classicConfig.showXAxisLabel !== false && Boolean(xTitle || (classicConfig.showXAxisLabel === true && autoTitleAxes.x)); - const showsYTitle = - classicConfig.showYAxisLabel !== false && Boolean(yTitle || (classicConfig.showYAxisLabel === true && autoTitleAxes.y)); - - return { - ...(classicConfig.showLegend === false - ? { legendPosition: 'none' } - : LEGEND_POSITIONS.has(classicConfig.legendPosition) && { legendPosition: classicConfig.legendPosition }), - ...(showsXTitle && { cartesianShowXAxisTitle: true, ...(xTitle && { cartesianXAxisTitle: xTitle }) }), - ...(showsYTitle && { cartesianShowYAxisTitle: true, ...(yTitle && { cartesianYAxisTitle: yTitle }) }), - ...(classicConfig.showGrid === false && { cartesianShowHorizontalGridLines: false }) - }; -}; - -/** Classic's line range modes: `auto` (the default) fits the data, `fit` anchors at zero. */ -const toLineRangeStyle = (classicConfig) => { - switch (classicConfig.yAxisRangeMode ?? 'auto') { - case 'auto': - return { cartesianYAxisRangeMode: 'fit' }; - case 'percentage': - return { cartesianYAxisRangeMode: 'percentage' }; - case 'custom': - return { - ...(typeof classicConfig.yAxisRangeFrom === 'number' && { cartesianYAxisMin: classicConfig.yAxisRangeFrom }), - ...(typeof classicConfig.yAxisRangeTo === 'number' && { cartesianYAxisMax: classicConfig.yAxisRangeTo }) - }; - default: - return {}; - } -}; - -const toVisualization = (chartType, encoding, style, notes) => ({ - spec: { chartType, encoding, ...(Object.keys(style).length > 0 && { style }) }, - notes -}); - -const toCartesianEncoding = (x, y, seriesField, { columns }) => ({ - ...(x && { x }), - ...(y && { y }), - ...(seriesField && { series: { field: seriesField, type: 'nominal', title: getColumnTitle(columns, seriesField) } }) -}); - -/** Converts a classic `data-stream-line-graph` config to a line spec. */ -export const toLineChart = (classicConfig, tileColumns) => { - const notes = []; - const x = toXChannel(tileColumns, classicConfig.xAxisColumn, 'line', notes); - const { y, blockedReason } = toYEncoding(tileColumns, classicConfig.yAxisColumn, x?.field, notes); - - if (blockedReason) { - return { blockedReason }; - } - - const seriesField = findConfiguredColumn(tileColumns, classicConfig.seriesColumn, 'series', notes); - - /** A gradient under every line of a chart split into many series hides the lines, so only unsplit charts keep it. */ - const hasArea = classicConfig.shading !== false && !seriesField; - - if (classicConfig.shading !== false && seriesField) { - notes.push('area shading dropped because the lines are split by series'); - } - - const style = { - ...toLegendAndAxisStyle(classicConfig, { x: false, y: true }), - ...toLineRangeStyle(classicConfig), - ...(!classicConfig.dataPoints && { cartesianSymbol: 'none' }), - ...(hasArea && { cartesianAreaGradient: true }), - ...(classicConfig.cumulative === true && { cartesianCumulative: true }), - ...(classicConfig.showTrendLine === true && { cartesianTrendLine: true }), - ...(typeof classicConfig.decimalPlaces === 'number' && { cartesianYAxisDecimalPlaces: classicConfig.decimalPlaces }), - ...(isSet(classicConfig.unitLabelColumn) && { cartesianYAxisUnitColumn: classicConfig.unitLabelColumn }) - }; - - return toVisualization('line', toCartesianEncoding(x, y, seriesField, tileColumns), style, notes); -}; - -const PALETTE_RULE = /^(?:bar|series)Index\s*%\s*(\d+)\s*==\s*(\d+)$/u; -const LABEL_RULE = /^label\s*==\s*["'](.+?)["']$/u; -const SERIES_RULE = /^series\s*==\s*["'](.+?)["']$/u; -const VALUE_RULE = /^value\s*(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)$/u; -const VALUE_OPERATORS = { '>': 'gt', '>=': 'gte', '<': 'lt', '<=': 'lte' }; - -/** Converts one classic colour expression, or returns `undefined` when SmartViz has no equivalent. */ -const toColorRule = ({ color, expression }) => { - const trimmed = expression.trim(); - const series = SERIES_RULE.exec(trimmed)?.[1]; - const label = LABEL_RULE.exec(trimmed)?.[1]; - const value = VALUE_RULE.exec(trimmed); - - if (series) { - return { mappedColor: [series, color] }; - } - - if (label) { - return { colorRule: { label, color } }; - } - - return value ? { colorRule: { [VALUE_OPERATORS[value[1]]]: Number(value[2]), color } } : undefined; -}; - -/** Converts classic custom bar colours to SmartViz colour style, noting any rule it cannot express. */ -const toBarColorStyle = (colorState, notes) => { - const rules = (colorState?.type === 'custom' ? (colorState.customColors ?? []) : []).filter( - (rule) => rule.color && rule.expression - ); - - if (rules.length === 0) { - return {}; - } - - const paletteMatches = rules.map((rule) => PALETTE_RULE.exec(rule.expression.trim())); - - if (paletteMatches.every(Boolean)) { - const colors = paletteMatches - .map((match, index) => ({ remainder: Number(match[2]), color: rules[index].color })) - .sort((a, b) => a.remainder - b.remainder) - .map(({ color }) => color); - - return { colors }; - } - - const converted = rules.map((rule) => ({ rule, result: toColorRule(rule) })); - - converted - .filter(({ result }) => !result) - .forEach(({ rule }) => notes.push(`colour rule not converted: ${rule.expression.trim()} → ${rule.color}`)); - - const mappedColors = Object.fromEntries(converted.map(({ result }) => result?.mappedColor).filter(Boolean)); - const colorRules = converted.map(({ result }) => result?.colorRule).filter(Boolean); - - return { - ...(Object.keys(mappedColors).length > 0 && { mappedColors }), - ...(colorRules.length > 0 && { cartesianColorRules: colorRules }) - }; -}; - -/** Classic stacks the bars of several series, or of several y columns, unless grouping is on. */ -const toBarStackStyle = (classicConfig, hasSeveralBars) => { - if (classicConfig.displayMode === 'percentage') { - return { cartesianStack: 'normalize' }; - } - - if (classicConfig.displayMode === 'cumulative') { - return { cartesianCumulative: true }; - } - - if (!hasSeveralBars) { - return {}; - } - - return { cartesianStack: classicConfig.grouping === true ? 'off' : 'on' }; -}; - -/** Converts a classic `data-stream-bar-chart` config to a bar spec. */ -export const toBarChart = (classicConfig, tileColumns) => { - const notes = []; - const x = toXChannel(tileColumns, classicConfig.xAxisData, 'bar', notes); - const { y, blockedReason } = toYEncoding(tileColumns, classicConfig.yAxisData, x?.field, notes); - - if (blockedReason) { - return { blockedReason }; - } - - const seriesField = findConfiguredColumn(tileColumns, classicConfig.xAxisGroup, 'group', notes); - const range = classicConfig.range; - - const style = { - ...toLegendAndAxisStyle(classicConfig, { x: true, y: true }), - ...toBarStackStyle(classicConfig, Boolean(seriesField) || toList(classicConfig.yAxisData).length > 1), - ...(classicConfig.horizontalLayout === 'horizontal' && { cartesianHorizontal: true }), - ...(classicConfig.showValue === true && { showLabels: true }), - ...(classicConfig.showTotals === true && { cartesianShowStackedTotals: true }), - ...(range?.type === 'custom' && typeof range.minimum === 'number' && { cartesianYAxisMin: range.minimum }), - ...(range?.type === 'custom' && typeof range.maximum === 'number' && { cartesianYAxisMax: range.maximum }), - ...toBarColorStyle(classicConfig.color, notes) - }; - - return toVisualization('bar', toCartesianEncoding(x, y, seriesField, tileColumns), style, notes); -}; - -/** - * Reads a classic scalar or gauge `value`: a column name, or a reduction `{ type, columns }` over all rows. - * A value column the tile no longer has falls back to its only numeric column, as classic's default does. - * A count is flagged `isRowCount`; a gauge has no row count, so it borrows any column the tile is known to have. - * A value left on auto is flagged `isAutomatic`, for SmartViz to find in the data as classic does. - */ -const toValueChannel = (value, tileColumns, notes) => { - if (!isSet(value)) { - return { isAutomatic: true }; - } - - if (typeof value === 'string') { - if (isKnownColumn(tileColumns, value)) { - return { channel: toChannel(tileColumns, value, 'quantitative') }; - } - - const replacement = findSoleColumnOfType(tileColumns, 'quantitative'); - - if (!replacement) { - return { blockedReason: `value column ${value} no longer exists` }; - } - - notes.push(`value column ${value} no longer exists; using ${replacement}`); - - return { channel: toChannel(tileColumns, replacement, 'quantitative') }; - } - - const aggregate = REDUCTION_AGGREGATES[value?.type]; - - if (aggregate === 'count') { - const anyField = value.columns?.[0] ?? tileColumns.columns[0]?.name; - - return { - isRowCount: true, - ...(anyField && { channel: { field: anyField, type: 'quantitative', aggregate: 'count', title: 'Count' } }) - }; - } - - if (aggregate && value.columns?.length === 1) { - return { channel: toChannel(tileColumns, value.columns[0], 'quantitative', { aggregate }) }; - } - - return { blockedReason: `value reduction ${value?.type} has no SmartViz equivalent` }; -}; - -/** Converts a classic `data-stream-scalar` config to a scalar spec. */ -export const toScalar = (classicConfig, tileColumns) => { - const notes = []; - const { channel, isRowCount, isAutomatic, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); - - if (blockedReason) { - return { blockedReason }; - } - - const comparisonField = findConfiguredColumn(tileColumns, classicConfig.comparisonColumn, 'comparison', notes); - const labelPosition = classicConfig.labelPosition; - - if (isAutomatic) { - notes.push('value column left for SmartViz to pick, as classic does'); - } else if (!isRowCount && !findColumn(tileColumns.columns, channel.field)) { - notes.push(`value column ${channel.field} is not declared by the stream; typed as a number`); - } - - const labelText = typeof classicConfig.label === 'string' ? classicConfig.label : ''; - - /** Classic shows no label unless one is set, where SmartViz would show the field name. */ - const labelStyle = labelText - ? { scalarLabelText: labelText, ...(labelPosition && labelPosition !== 'bottom' && { scalarLabelPosition: labelPosition }) } - : { scalarLabelPosition: 'none' }; - const style = { ...labelStyle, ...(isRowCount && { scalarRowCount: true }) }; - - /** A row count reads no column, so it needs no value channel; an automatic value has none to name. */ - const encoding = { - ...(!isAutomatic && { values: isRowCount ? [] : [channel] }), - ...(comparisonField && { comparison: toChannel(tileColumns, comparisonField, 'quantitative') }) - }; - - return toVisualization('scalar', encoding, style, notes); -}; - -/** Converts a classic `data-stream-gauge` config to a gauge spec. */ -export const toGauge = (classicConfig, tileColumns) => { - const notes = []; - const { channel, isAutomatic, blockedReason } = toValueChannel(classicConfig.value, tileColumns, notes); - - if (blockedReason) { - return { blockedReason }; - } - - if (isAutomatic) { - notes.push('value column left for SmartViz to pick, as classic does'); - } else if (!channel) { - return { blockedReason: 'counts rows, and the stream declares no column to count' }; - } - - const minimumField = findConfiguredColumn(tileColumns, classicConfig.minimumColumn, 'minimum', notes); - const maximumField = findConfiguredColumn(tileColumns, classicConfig.maximumColumn, 'maximum', notes); - - const style = { - ...(typeof classicConfig.minimum === 'number' && { gaugeMin: classicConfig.minimum }), - ...(typeof classicConfig.maximum === 'number' && { gaugeMax: classicConfig.maximum }), - ...(classicConfig.label && { gaugeLabelText: String(classicConfig.label) }) - }; - - const encoding = { - ...(channel && { value: { ...channel, type: 'quantitative' } }), - ...(minimumField && { min: toChannel(tileColumns, minimumField, 'quantitative') }), - ...(maximumField && { max: toChannel(tileColumns, maximumField, 'quantitative') }) - }; - - return toVisualization('gauge', encoding, style, notes); -}; - -/** - * The donut's value channel: a count of rows per label for classic's "Count (Built-in)", the configured - * column, or the tile's only numeric column when the configured one is gone. `undefined` leaves the value for - * SmartViz to find in the data, as classic does when it is on auto or no column fits. - */ -const toDonutValue = (classicConfig, tileColumns, labelField, notes) => { - const configured = classicConfig.valueColumn; - - if (!isSet(configured)) { - return undefined; - } - - if (configured === BUILT_IN_COUNT) { - return labelField ? { field: labelField, type: 'quantitative', aggregate: 'count', title: 'Count' } : undefined; - } - - const field = isKnownColumn(tileColumns, configured) ? configured : findSoleColumnOfType(tileColumns, 'quantitative'); - - if (!field) { - notes.push(`value column ${configured} no longer exists, so SmartViz picks one as classic did`); - - return undefined; - } - - if (field !== configured) { - notes.push(`value column ${configured} no longer exists; using ${field}`); - } - - return { field, type: 'quantitative', aggregate: 'sum', title: getColumnTitle(tileColumns.columns, field) }; -}; - -/** Converts a classic `data-stream-donut-chart` config to a donut-style pie spec. */ -export const toDonut = (classicConfig, tileColumns) => { - const notes = []; - - /** Classic's per-label colours, which SmartViz matches the same way: by label, ignoring case. */ - const hasPalette = - classicConfig.palette !== null && - typeof classicConfig.palette === 'object' && - Object.values(classicConfig.palette).length > 0 && - Object.values(classicConfig.palette).every((color) => typeof color === 'string'); - - /** Like classic, a label column the tile no longer has falls back to its only category column. */ - const configuredLabel = isSet(classicConfig.labelColumn) ? classicConfig.labelColumn : undefined; - const labelField = - configuredLabel && - (isKnownColumn(tileColumns, configuredLabel) ? configuredLabel : findSoleColumnOfType(tileColumns, 'nominal')); - - if (configuredLabel && labelField !== configuredLabel) { - notes.push( - labelField - ? `label column ${configuredLabel} no longer exists; using ${labelField}` - : `label column ${configuredLabel} no longer exists, so SmartViz picks one as classic did` - ); - } - - const value = toDonutValue(classicConfig, tileColumns, labelField, notes); - - if (!labelField || !value) { - notes.push('label or value column left for SmartViz to pick, as classic does'); - } - - const style = { - pieInnerRadius: 60, - pieDonutCenterValue: classicConfig.hideCenterValue === true ? 'none' : 'total', - labelFontSize: 16, - ...(classicConfig.legendMode === 'table' && { pieLegendStyle: 'table' }), - ...(classicConfig.showValuesAsPercentage === true && { pieLabels: 'nameAndPercent' }), - ...(hasPalette && { mappedColors: classicConfig.palette }) - }; - - const encoding = { - ...(labelField && { label: { field: labelField, type: 'nominal', title: getColumnTitle(tileColumns.columns, labelField) } }), - ...(value && { value }) - }; - - return toVisualization('pie', encoding, style, notes); -}; diff --git a/scripts/classic-to-smartviz/columns.mjs b/scripts/classic-to-smartviz/columns.mjs deleted file mode 100644 index b8d72683..00000000 --- a/scripts/classic-to-smartviz/columns.mjs +++ /dev/null @@ -1,228 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -const NUMERIC_SHAPES = new Set([ - 'number', - 'integer', - 'decimal', - 'percent', - 'currency', - 'bytes', - 'kilobytes', - 'megabytes', - 'gigabytes', - 'terabytes', - 'milliseconds', - 'seconds', - 'minutes', - 'hours', - 'days', - 'duration' -]); - -const TIME_LIKE_NAME = /time|date|timestamp|created|updated|started|ended|^ts$|_at$|At$|day|hour|month|week/u; - -const AGGREGATE_TITLES = { count: 'Count', sum: 'Sum', mean: 'Average', avg: 'Average', min: 'Minimum', max: 'Maximum' }; - -/** - * Splits a column name into the header text the platform shows when a column declares no display name: - * the last path segment, split on case changes, underscores, hyphens and spaces, each word capitalised. - */ -export const toInferredTitle = (name) => { - const segments = name.split('.'); - const last = segments[segments.length - 1]; - const leaf = Number.isFinite(Number(last)) && segments.length > 1 ? `${segments[segments.length - 2]}[${last}]` : last; - - return (leaf.charAt(0).toUpperCase() + leaf.slice(1)) - .split(/(?=[A-Z][a-z])|_|-|(?=(?<=[a-z])[A-Z])|\s/gu) - .filter((part) => part !== '') - .map((part) => (/^[^a-z]*$/u.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())) - .join(' '); -}; - -const toShape = (metadataEntry) => { - const [name, config] = Array.isArray(metadataEntry.shape) ? metadataEntry.shape : [metadataEntry.shape, undefined]; - - return { shapeName: typeof name === 'string' && name !== '' ? name : undefined, shapeConfig: config }; -}; - -const toColumn = (metadataEntry) => ({ - name: metadataEntry.name, - displayName: metadataEntry.displayName || undefined, - role: metadataEntry.role, - isVisible: metadataEntry.visible !== false, - isComputed: metadataEntry.computed === true, - ...toShape(metadataEntry) -}); - -/** Keeps the first entry for each column name, because a later bare `{ name }` entry only re-asserts the column. */ -const toColumnSet = (metadata) => { - const named = metadata.filter((entry) => typeof entry.name === 'string'); - const columns = named.filter((entry, index) => named.findIndex((other) => other.name === entry.name) === index).map(toColumn); - - return { columns, isComplete: !metadata.some((entry) => entry.pattern !== undefined) }; -}; - -const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); - -/** - * Reads the declared columns of every data stream in a plugin version folder, from `dataStreams/*.json` (low-code - * plugins) or `data_streams.json` and its `rowTypes` (packaged plugins). A stream that declares no columns maps to - * `undefined`. `isComplete` is false when a `pattern` entry lets undeclared columns through. - */ -export const readStreamColumns = (pluginDirectory) => { - const lowCodeDirectory = path.join(pluginDirectory, 'dataStreams'); - const lowCodeStreams = fs.existsSync(lowCodeDirectory) - ? fs - .readdirSync(lowCodeDirectory) - .filter((file) => file.endsWith('.json')) - .map((file) => readJson(path.join(lowCodeDirectory, file))) - .map((stream) => [stream.name, Array.isArray(stream.metadata) ? toColumnSet(stream.metadata) : undefined]) - : []; - - const packagedFile = path.join(pluginDirectory, 'data_streams.json'); - const packaged = fs.existsSync(packagedFile) ? readJson(packagedFile) : undefined; - const rowTypeMetadataByName = new Map((packaged?.rowTypes ?? []).map((rowType) => [rowType.name, rowType.metadata])); - const packagedStreams = (packaged?.dataStreams ?? []).map(({ definition = {} }) => { - const metadata = definition.metadata ?? rowTypeMetadataByName.get(definition.rowType); - - /** A packaged plugin's handler can return columns its metadata does not declare, so the list is never complete. */ - return [definition.name, Array.isArray(metadata) ? { ...toColumnSet(metadata), isComplete: false } : undefined]; - }); - - return new Map([...lowCodeStreams, ...packagedStreams]); -}; - -const STREAM_REFERENCE = /\{\{\s*dataStreams\.\[?([^}\]]+?)\]?\s*\}\}/u; - -/** Returns the plugin stream name a tile reads, or `undefined` for a built-in stream such as SQL or properties. */ -export const getStreamName = (tileConfig) => { - const dataStream = tileConfig.dataStream ?? {}; - - return STREAM_REFERENCE.exec(dataStream.id ?? '')?.[1] ?? (dataStream.id ? undefined : dataStream.name); -}; - -/** A group's `by` is one `[column, grouper]` pair, a list of them, or empty. */ -const toByPairs = (by) => { - if (!Array.isArray(by) || by.length === 0) { - return []; - } - - return Array.isArray(by[0]) ? by.filter((pair) => typeof pair[0] === 'string') : [by]; -}; - -/** A row count with no column is named `count`; any other aggregate is `_`. */ -const toGroupedColumns = (group, sourceColumns) => { - const byPairs = toByPairs(group.by); - const sourceTitle = (name) => sourceColumns.find((column) => column.name === name)?.displayName ?? toInferredTitle(name); - - const keyColumns = byPairs.map(([name, grouper]) => ({ - name: `${name}_${grouper}`, - displayName: sourceTitle(name), - isVisible: true, - groupKind: /^(by|time)/iu.test(grouper) ? 'temporal' : 'nominal' - })); - const aggregateColumns = (group.aggregate ?? []).flatMap(({ type, names, name }) => { - const columnNames = Array.isArray(names) ? names : name ? [name] : []; - const toAggregateColumn = (columnName) => ({ - name: columnName ? `${columnName}_${type}` : type, - displayName: columnName ? `${sourceTitle(columnName)} (${AGGREGATE_TITLES[type] ?? type})` : (AGGREGATE_TITLES[type] ?? type), - isVisible: true, - groupKind: 'quantitative' - }); - - return columnNames.length > 0 ? columnNames.map(toAggregateColumn) : [toAggregateColumn(undefined)]; - }); - - return [...keyColumns, ...aggregateColumns]; -}; - -/** Applies a tile's `dataStream.metadata` over the stream's columns: matching names are re-shaped, new names added. */ -const overlayColumns = (streamColumns, tileMetadata) => { - const tileColumns = toColumnSet(tileMetadata).columns; - const overlaid = streamColumns.map((column) => { - const override = tileMetadata.find((entry) => entry.name === column.name); - - if (!override) { - return column; - } - - return { - ...column, - ...(override.shape !== undefined && toShape(override)), - ...(override.displayName && { displayName: override.displayName }), - ...(override.role && { role: override.role }), - ...(override.visible !== undefined && { isVisible: override.visible !== false }) - }; - }); - const added = tileColumns.filter((column) => !findColumn(streamColumns, column.name)); - - return [...overlaid, ...added]; -}; - -/** - * Resolves the columns a tile's data will have: the post-group names when the tile groups, otherwise the - * stream's declared columns with the tile's own `dataStream.metadata` applied over them. `isComplete` is true - * only when every column the data can have is known. - */ -export const getTileColumns = (tileConfig, streamColumnsByName) => { - const dataStream = tileConfig.dataStream ?? {}; - const streamColumns = streamColumnsByName.get(getStreamName(tileConfig)); - const tileMetadata = Array.isArray(dataStream.metadata) ? dataStream.metadata : []; - - if (dataStream.group?.by?.length || dataStream.group?.aggregate?.length) { - return { columns: toGroupedColumns(dataStream.group, streamColumns?.columns ?? []), isComplete: true, source: 'group' }; - } - - if (!streamColumns) { - return { columns: overlayColumns([], tileMetadata), isComplete: false, source: tileMetadata.length ? 'tile metadata' : 'none' }; - } - - return { columns: overlayColumns(streamColumns.columns, tileMetadata), isComplete: streamColumns.isComplete, source: 'stream metadata' }; -}; - -/** Finds a column by name, or `undefined` when the column was not declared. */ -export const findColumn = (columns, name) => columns.find((column) => column.name === name); - -/** The header a column shows: an explicit override, its declared display name, or the platform's inferred name. */ -export const getColumnTitle = (columns, name, overrides = {}) => - overrides[name] || findColumn(columns, name)?.displayName || toInferredTitle(name); - -/** - * The SmartViz field type of a declared column, or `undefined` when the column or its shape is unknown. - * Callers fall back to the type the channel's role implies. - */ -export const findFieldType = (columns, name) => { - const column = findColumn(columns, name); - - if (column?.groupKind) { - return column.groupKind; - } - - if (!column?.shapeName) { - return undefined; - } - - if (column.shapeName === 'date') { - return 'temporal'; - } - - return NUMERIC_SHAPES.has(column.shapeName) ? 'quantitative' : 'nominal'; -}; - -/** - * True unless the tile's full column list is known and the name is not on it. Columns the platform expands - * from object values at run time (`name[Expanded].…`) are never declared, so they count as known. - */ -export const isKnownColumn = ({ columns, isComplete }, name) => - !isComplete || Boolean(findColumn(columns, name)) || name.includes('[Expanded]'); - -/** The tile's only visible column of a field type, or `undefined` when there are none or several. */ -export const findSoleColumnOfType = ({ columns }, type) => { - const matches = columns.filter((column) => column.isVisible && findFieldType(columns, column.name) === type); - - return matches.length === 1 ? matches[0].name : undefined; -}; - -/** Guesses whether an x-axis column holds time values from its name, for columns with no declared shape. */ -export const isTimeLikeName = (name) => TIME_LIKE_NAME.test(name); diff --git a/scripts/classic-to-smartviz/convert.mjs b/scripts/classic-to-smartviz/convert.mjs deleted file mode 100644 index 7543abf3..00000000 --- a/scripts/classic-to-smartviz/convert.mjs +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import { createRequire } from 'node:module'; -import path from 'node:path'; -import { parseArgs } from 'node:util'; -import { toBarChart, toDonut, toGauge, toLineChart, toScalar } from './chartMappers.mjs'; -import { getTileColumns, isKnownColumn, readStreamColumns } from './columns.mjs'; -import { replaceMembers } from './jsonEdit.mjs'; -import { applyEnhancementRules, RULE_NAMES } from './enhancementRules.mjs'; -import { toRichTable, toStatusGrid } from './tableMappers.mjs'; - -const USAGE = `Converts classic data-stream tiles in plugin default dashboards to SmartViz (vizSpec) tiles. - -Usage: node scripts/classic-to-smartviz/convert.mjs [options] ... - -Options: - --write Write the converted dashboards (default: report only) - --bump With --write, bump metadata.json by patch or minor in each folder that changed - --schema CommonJS build of @squaredup/viz-spec (or a bundle exporting vizSpecSchema) to validate - every spec against; a spec that fails is left classic - --rules Enhancement rules to apply on top of the conversion: all (the default), none, or a - comma-separated list of ${RULE_NAMES.join(', ')} - --json Also write the report as JSON`; - -const MAPPERS = { - 'data-stream-table': toRichTable, - 'data-stream-blocks': toStatusGrid, - 'data-stream-line-graph': toLineChart, - 'data-stream-bar-chart': toBarChart, - 'data-stream-scalar': toScalar, - 'data-stream-gauge': toGauge, - 'data-stream-donut-chart': toDonut -}; - -const STYLE_FIELD_KEYS = ['richTableRowLinkField', 'statusGridOrderByField', 'cartesianYAxisUnitColumn', 'cartesianY2AxisField']; - -const findDashboardFiles = (directory) => - fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const entryPath = path.join(directory, entry.name); - - if (entry.isDirectory()) { - return findDashboardFiles(entryPath); - } - - return entry.name.endsWith('.dash.json') ? [entryPath] : []; - }); - -const findDefaultContentDirectory = (pluginDirectory) => - ['defaultContent', 'DefaultContent'].map((name) => path.join(pluginDirectory, name)).find((candidate) => fs.existsSync(candidate)); - -/** Every column name a spec refers to: encoding channels, table columns and their link labels, and style fields. */ -const getSpecFields = (visualization) => { - const channels = Object.values(visualization.encoding ?? {}).flatMap((entry) => (Array.isArray(entry) ? entry : [entry])); - const encodingFields = channels.flatMap((channel) => [channel?.field, channel?.renderer?.labelField]); - const styleFields = STYLE_FIELD_KEYS.map((key) => visualization.style?.[key]); - - return [...new Set([...encodingFields, ...styleFields].filter((field) => typeof field === 'string'))]; -}; - -const getKeyPaths = (value, prefix = '') => - value && typeof value === 'object' && !Array.isArray(value) - ? Object.entries(value).flatMap(([key, child]) => [`${prefix}${key}`, ...getKeyPaths(child, `${prefix}${key}.`)]) - : Array.isArray(value) - ? value.flatMap((item, index) => getKeyPaths(item, `${prefix}${index}.`)) - : []; - -/** - * Schema-checks a spec, returning the problems as text. Zod drops keys it does not know without failing, - * so a key missing from the parsed result counts as a problem too. - */ -const getSchemaProblems = (vizSpecSchema, spec) => { - const result = vizSpecSchema.safeParse(spec); - - if (!result.success) { - return result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`); - } - - const keptPaths = new Set(getKeyPaths(result.data)); - - return getKeyPaths(spec) - .filter((keyPath) => !keptPaths.has(keyPath)) - .map((keyPath) => `${keyPath}: dropped by the schema`); -}; - -const toIndentUnit = (text) => /\n([ \t]+)"/u.exec(text)?.[1] ?? ' '; - -/** Converts one tile, returning its report entry and, when it converts, the spec to write. */ -const convertTile = (tileConfig, streamColumnsByName, { vizSpecSchema, ruleNames }) => { - const title = tileConfig.title || '(untitled)'; - - if (tileConfig.vizSpec) { - return { title, status: 'skipped', reason: 'already SmartViz' }; - } - - const classicType = tileConfig.visualisation?.type; - const mapper = MAPPERS[classicType]; - - if (!mapper) { - return { title, status: 'skipped', reason: `no converter for ${classicType ?? 'a tile with no visualisation'}` }; - } - - const tileColumns = getTileColumns(tileConfig, streamColumnsByName); - const { spec: visualization, notes = [], blockedReason } = mapper(tileConfig.visualisation.config?.[classicType] ?? {}, tileColumns); - - if (blockedReason) { - return { title, classicType, status: 'blocked', reason: blockedReason }; - } - - const enhanced = applyEnhancementRules(visualization, { tileColumns, tileConfig }, ruleNames); - const spec = { version: '1.0', visualization: enhanced.spec }; - const schemaProblems = vizSpecSchema ? getSchemaProblems(vizSpecSchema, spec) : []; - - if (schemaProblems.length > 0) { - return { title, classicType, status: 'blocked', reason: `invalid spec: ${schemaProblems.join('; ')}` }; - } - - const unknownFields = getSpecFields(enhanced.spec).filter((field) => !isKnownColumn(tileColumns, field)); - - if (unknownFields.length > 0) { - return { title, classicType, status: 'blocked', reason: `refers to columns the tile does not have: ${unknownFields.join(', ')}` }; - } - - return { - title, - classicType, - status: 'converted', - chartType: visualization.chartType, - columnSource: tileColumns.source, - notes, - rulesApplied: enhanced.applied, - spec - }; -}; - -const convertDashboard = (filePath, streamColumnsByName, { vizSpecSchema, ruleNames, write }) => { - const text = fs.readFileSync(filePath, 'utf8'); - const contents = JSON.parse(text).dashboard?.contents ?? []; - - const tiles = contents - .map((item, index) => ({ index, config: item?.config })) - .filter(({ config }) => config?._type === 'tile/data-stream') - .map(({ index, config }) => ({ index, ...convertTile(config, streamColumnsByName, { vizSpecSchema, ruleNames }) })); - - const replacements = tiles - .filter((tile) => tile.status === 'converted') - .map((tile) => ({ - memberPath: ['dashboard', 'contents', tile.index, 'config', 'visualisation'], - key: 'vizSpec', - value: tile.spec, - indentUnit: toIndentUnit(text) - })); - - if (write && replacements.length > 0) { - fs.writeFileSync(filePath, replaceMembers(text, replacements)); - } - - return { file: filePath, tiles: tiles.map(({ spec, index, ...tile }) => tile) }; -}; - -const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/u; - -/** Bumps the `version` in a plugin folder's metadata.json in place, returning the old and new versions. */ -const bumpVersion = (pluginDirectory, level) => { - const metadataPath = path.join(pluginDirectory, 'metadata.json'); - const text = fs.readFileSync(metadataPath, 'utf8'); - const { version } = JSON.parse(text); - const [, major, minor, patch] = SEMVER.exec(version) ?? []; - - if (major === undefined) { - throw new Error(`${metadataPath}: version ${version} is not x.y.z`); - } - - const nextVersion = level === 'minor' ? `${major}.${Number(minor) + 1}.0` : `${major}.${minor}.${Number(patch) + 1}`; - fs.writeFileSync(metadataPath, text.replace(`"version": "${version}"`, `"version": "${nextVersion}"`)); - - return { from: version, to: nextVersion }; -}; - -const STATUS_MARKS = { converted: '✔', blocked: '✖', skipped: '·' }; - -const printReport = (plugins) => { - for (const plugin of plugins) { - console.log(`\n${plugin.folder}${plugin.version ? ` (${plugin.version.from} → ${plugin.version.to})` : ''}`); - - for (const dashboard of plugin.dashboards) { - console.log(` ${path.basename(dashboard.file)}`); - - for (const tile of dashboard.tiles) { - const detail = tile.status === 'converted' ? `${tile.classicType} → ${tile.chartType}` : tile.reason; - console.log(` ${STATUS_MARKS[tile.status]} ${tile.title}: ${detail}`); - (tile.notes ?? []).forEach((note) => console.log(` note: ${note}`)); - (tile.rulesApplied ?? []).forEach(({ rule, note }) => console.log(` rule ${rule}: ${note}`)); - } - } - } - - const tiles = plugins.flatMap((plugin) => plugin.dashboards.flatMap((dashboard) => dashboard.tiles)); - const countOf = (status) => tiles.filter((tile) => tile.status === status).length; - console.log(`\n${countOf('converted')} converted, ${countOf('blocked')} blocked, ${countOf('skipped')} skipped`); - - const ruleCounts = Object.groupBy(tiles.flatMap((tile) => tile.rulesApplied ?? []), ({ rule }) => rule); - Object.entries(ruleCounts).forEach(([rule, hits]) => console.log(` ${rule}: ${hits.length} tile(s)`)); -}; - -/** Reads `--rules`: `all`, `none`, or a comma-separated list of rule names. */ -const toRuleNames = (option) => { - if (option === 'all') { - return RULE_NAMES; - } - - if (option === 'none') { - return []; - } - - const names = option.split(',').map((name) => name.trim()).filter(Boolean); - const unknown = names.filter((name) => !RULE_NAMES.includes(name)); - - if (unknown.length > 0) { - throw new Error(`Unknown rule(s): ${unknown.join(', ')}. Rules: ${RULE_NAMES.join(', ')}`); - } - - return names; -}; - -const loadVizSpecSchema = (schemaPath) => { - if (!schemaPath) { - return undefined; - } - - const { vizSpecSchema } = createRequire(import.meta.url)(path.resolve(schemaPath)); - - if (!vizSpecSchema?.safeParse) { - throw new Error(`${schemaPath} does not export vizSpecSchema`); - } - - return vizSpecSchema; -}; - -const main = () => { - const { values: options, positionals: folders } = parseArgs({ - allowPositionals: true, - options: { - write: { type: 'boolean', default: false }, - bump: { type: 'string' }, - schema: { type: 'string' }, - json: { type: 'string' }, - rules: { type: 'string', default: 'all' }, - help: { type: 'boolean', default: false } - } - }); - - if (options.help || folders.length === 0) { - console.log(USAGE); - process.exit(options.help ? 0 : 1); - } - - if (options.bump && !['patch', 'minor'].includes(options.bump)) { - throw new Error('--bump must be patch or minor'); - } - - const vizSpecSchema = loadVizSpecSchema(options.schema); - const ruleNames = toRuleNames(options.rules); - - const plugins = folders.map((folder) => { - const defaultContentDirectory = findDefaultContentDirectory(folder); - const streamColumnsByName = readStreamColumns(folder); - const dashboards = defaultContentDirectory - ? findDashboardFiles(defaultContentDirectory).map((file) => - convertDashboard(file, streamColumnsByName, { vizSpecSchema, ruleNames, write: options.write }) - ) - : []; - const hasConversions = dashboards.some((dashboard) => dashboard.tiles.some((tile) => tile.status === 'converted')); - const version = options.write && options.bump && hasConversions ? bumpVersion(folder, options.bump) : undefined; - - return { folder, version, dashboards }; - }); - - printReport(plugins); - - if (options.json) { - fs.writeFileSync(options.json, `${JSON.stringify(plugins, null, 4)}\n`); - } -}; - -main(); diff --git a/scripts/classic-to-smartviz/enhancementRules.mjs b/scripts/classic-to-smartviz/enhancementRules.mjs deleted file mode 100644 index c3ea942e..00000000 --- a/scripts/classic-to-smartviz/enhancementRules.mjs +++ /dev/null @@ -1,176 +0,0 @@ -import { findColumn } from './columns.mjs'; - -const RECENCY_NAME = /last|seen|updated|modified|checked|heartbeat|sync|refreshed|polled/iu; -const STATUS_NAME = /status|state|health|result|severity|priority|outcome|level|condition/iu; -const SIGNED_NAME = /difference|change|delta|growth|trend|result/iu; - -const ERROR = '#DE0038'; -const WARNING = '#F59E0B'; -const SUCCESS = '#2BB660'; -const INFO = '#3B82F6'; -const UNKNOWN = '#94A3B8'; - -/** Health colours for the words status, severity and priority columns commonly hold. Matched whole-value, ignoring case. */ -const STATUS_COLORS = { - critical: ERROR, - error: ERROR, - failed: ERROR, - failure: ERROR, - down: ERROR, - offline: ERROR, - unhealthy: ERROR, - high: ERROR, - warning: WARNING, - warn: WARNING, - medium: WARNING, - moderate: WARNING, - degraded: WARNING, - partial: WARNING, - ok: SUCCESS, - healthy: SUCCESS, - success: SUCCESS, - succeeded: SUCCESS, - passed: SUCCESS, - up: SUCCESS, - online: SUCCESS, - resolved: SUCCESS, - low: INFO, - info: INFO, - informational: INFO, - unknown: UNKNOWN, - unmonitored: UNKNOWN, - 'not monitored': UNKNOWN, - paused: UNKNOWN -}; - -const getShapeName = ({ tileColumns }, field) => findColumn(tileColumns.columns, field)?.shapeName; - -const getLabel = ({ tileColumns }, field) => `${field} ${findColumn(tileColumns.columns, field)?.displayName ?? ''}`; - -const withStyle = (spec, style) => ({ ...spec, style: { ...spec.style, ...style } }); - -const hasColorStyle = (style = {}) => - Boolean(style.colors?.length || Object.keys(style.mappedColors ?? {}).length || style.cartesianColorRules?.length); - -/** Rewrites the renderer of each richTable column `pick` selects, returning the spec and the fields it changed. */ -const updateTableColumns = (spec, pick, toRenderer) => { - /** An automatic table names no columns, so it has none to change. */ - if (spec.chartType !== 'richTable' || !spec.encoding.columns) { - return { spec, fields: [] }; - } - - const fields = spec.encoding.columns.filter(pick).map((column) => column.field); - const columns = spec.encoding.columns.map((column) => - fields.includes(column.field) ? { ...column, renderer: toRenderer(column.renderer) } : column - ); - - return { spec: { ...spec, encoding: { ...spec.encoding, columns } }, fields }; -}; - -/** - * Optional improvements applied on top of the like-for-like conversion. Each takes the converted spec and the - * tile's context, and returns the spec (changed or not) with a note when it changed something. Every rule works - * from column shapes, names and the converted spec alone, so none of them depends on what the data holds. - */ -export const ENHANCEMENT_RULES = { - /** Dates of a "last seen / updated / synced" kind show as "3 hours ago", with the exact time in the tooltip. */ - recencyDates: (spec, context) => { - const { spec: updated, fields } = updateTableColumns( - spec, - (column) => column.renderer.type === 'date' && RECENCY_NAME.test(getLabel(context, column.field)), - (renderer) => ({ ...renderer, relative: true }) - ); - - return { spec: updated, note: fields.length > 0 ? `relative time for ${fields.join(', ')}` : undefined }; - }, - - /** - * Donuts and bar or line charts split by a status-like column get health colours for the status words they - * hold. Values outside the vocabulary keep the palette, and a tile with colours of its own is left alone. - */ - statusColours: (spec, context) => { - const { chartType, encoding, style } = spec; - const candidates = { - pie: [encoding.label?.field], - bar: [encoding.series?.field, encoding.x?.field], - line: [encoding.series?.field] - }[chartType]; - const statusField = candidates?.find((field) => field && STATUS_NAME.test(getLabel(context, field))); - - if (!statusField || hasColorStyle(style)) { - return { spec }; - } - - return { spec: withStyle(spec, { mappedColors: STATUS_COLORS }), note: `status colours for ${statusField}` }; - }, - - /** - * Percent columns in tables show as a bar filled to the value out of 100, in the bar's neutral default colour - * rather than the always-healthy fill of `progress`. Columns whose name suggests a signed value (a difference - * or change) are left as text, because a bar cannot show a negative. - */ - percentBars: (spec, context) => { - const { spec: updated, fields } = updateTableColumns( - spec, - (column) => - column.renderer.type === 'text' && - getShapeName(context, column.field) === 'percent' && - !SIGNED_NAME.test(getLabel(context, column.field)), - () => ({ type: 'bar', max: 100 }) - ); - - return { spec: updated, note: fields.length > 0 ? `bars out of 100 for ${fields.join(', ')}` : undefined }; - }, - - /** A line chart of a percentage gets a fixed 0–100 axis, unless the tile set its own range. */ - percentAxis: (spec, context) => { - const { chartType, encoding, style = {} } = spec; - const hasOwnRange = style.cartesianYAxisMin !== undefined || style.cartesianYAxisMax !== undefined; - - const yChannels = [encoding.y ?? []].flat(); - const isPercent = yChannels.length > 0 && yChannels.every((channel) => getShapeName(context, channel.field) === 'percent'); - - if (chartType !== 'line' || hasOwnRange || !isPercent) { - return { spec }; - } - - return { spec: withStyle(spec, { cartesianYAxisRangeMode: 'percentage' }), note: 'y axis fixed to 0–100%' }; - }, - - /** A gauge of a percentage with no range of its own runs from 0 to 100. */ - percentGauge: (spec, context) => { - const { chartType, encoding, style = {} } = spec; - const hasOwnRange = - style.gaugeMin !== undefined || style.gaugeMax !== undefined || encoding.min !== undefined || encoding.max !== undefined; - - if (chartType !== 'gauge' || !encoding.value || hasOwnRange || getShapeName(context, encoding.value.field) !== 'percent') { - return { spec }; - } - - return { spec: withStyle(spec, { gaugeMin: 0, gaugeMax: 100 }), note: 'gauge range 0–100%' }; - }, - - /** Boolean columns read "Yes" / "No" in the table's own text colour, so neither value implies good or bad. */ - booleanLabels: (spec, context) => { - const { spec: updated, fields } = updateTableColumns( - spec, - (column) => column.renderer.type === 'text' && getShapeName(context, column.field) === 'boolean', - () => ({ type: 'boolean', trueLabel: 'Yes', falseLabel: 'No', trueColor: 'inherit', falseColor: 'inherit' }) - ); - - return { spec: updated, note: fields.length > 0 ? `Yes / No for ${fields.join(', ')}` : undefined }; - } -}; - -export const RULE_NAMES = Object.keys(ENHANCEMENT_RULES); - -/** Applies the named rules in order, returning the final spec and the notes of the rules that changed it. */ -export const applyEnhancementRules = (spec, context, ruleNames) => - ruleNames.reduce( - (result, name) => { - const { spec: next, note } = ENHANCEMENT_RULES[name](result.spec, context); - - return { spec: next, applied: note ? [...result.applied, { rule: name, note }] : result.applied }; - }, - { spec, applied: [] } - ); diff --git a/scripts/classic-to-smartviz/jsonEdit.mjs b/scripts/classic-to-smartviz/jsonEdit.mjs deleted file mode 100644 index 0b8e134a..00000000 --- a/scripts/classic-to-smartviz/jsonEdit.mjs +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Parses JSON text into a tree that records where each value, and each object member's key, starts and ends, - * so a member can be replaced without reformatting the rest of the file. - */ -const parseTree = (text) => { - let position = 0; - - const skipWhitespace = () => { - while (/\s/u.test(text[position] ?? '')) { - position++; - } - }; - - const fail = (expected) => { - throw new Error(`Expected ${expected} at offset ${position}`); - }; - - const parseString = () => { - const start = position; - position++; - - while (text[position] !== '"') { - if (position >= text.length) { - fail('closing quote'); - } - - position += text[position] === '\\' ? 2 : 1; - } - - position++; - - return JSON.parse(text.slice(start, position)); - }; - - const parseValue = () => { - skipWhitespace(); - const start = position; - const character = text[position]; - - if (character === '{') { - position++; - const members = []; - skipWhitespace(); - - while (text[position] !== '}') { - skipWhitespace(); - const keyStart = position; - const key = parseString(); - skipWhitespace(); - text[position] === ':' ? position++ : fail(':'); - members.push({ key, keyStart, value: parseValue() }); - skipWhitespace(); - - if (text[position] === ',') { - position++; - skipWhitespace(); - } - } - - position++; - - return { kind: 'object', start, end: position, members }; - } - - if (character === '[') { - position++; - const items = []; - skipWhitespace(); - - while (text[position] !== ']') { - items.push(parseValue()); - skipWhitespace(); - - if (text[position] === ',') { - position++; - } - - skipWhitespace(); - } - - position++; - - return { kind: 'array', start, end: position, items }; - } - - if (character === '"') { - parseString(); - - return { kind: 'scalar', start, end: position }; - } - - const literal = /^(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/u.exec(text.slice(position)); - - if (!literal) { - fail('a JSON value'); - } - - position += literal[0].length; - - return { kind: 'scalar', start, end: position }; - }; - - return parseValue(); -}; - -const getChild = (node, step) => - typeof step === 'number' ? node?.items?.[step] : node?.members?.find((member) => member.key === step)?.value; - -const getIndent = (text, offset) => { - const lineStart = text.lastIndexOf('\n', offset - 1) + 1; - - return /^[ \t]*/u.exec(text.slice(lineStart))[0]; -}; - -/** - * Replaces the member at `memberPath` (keys and array indexes, ending in the member's key) with a new key and - * value, in place, indenting the new value to match the line the member starts on. Everything else in the - * text is left byte for byte. Replacements are applied from the end of the file backwards, so their offsets - * stay valid. - */ -export const replaceMembers = (text, replacements) => { - const tree = parseTree(text); - - const edits = replacements.map(({ memberPath, key, value, indentUnit = ' ' }) => { - const parent = memberPath.slice(0, -1).reduce(getChild, tree); - const member = parent?.members?.find((candidate) => candidate.key === memberPath[memberPath.length - 1]); - - if (!member) { - throw new Error(`No member at ${memberPath.join('.')}`); - } - - const indent = getIndent(text, member.keyStart); - const serialized = JSON.stringify(value, null, indentUnit).replace(/\n/gu, `\n${indent}`); - - return { start: member.keyStart, end: member.value.end, replacement: `${JSON.stringify(key)}: ${serialized}` }; - }); - - return edits - .sort((a, b) => b.start - a.start) - .reduce((current, edit) => current.slice(0, edit.start) + edit.replacement + current.slice(edit.end), text); -}; diff --git a/scripts/classic-to-smartviz/tableMappers.mjs b/scripts/classic-to-smartviz/tableMappers.mjs deleted file mode 100644 index feccf80f..00000000 --- a/scripts/classic-to-smartviz/tableMappers.mjs +++ /dev/null @@ -1,226 +0,0 @@ -import { findColumn, getColumnTitle, isKnownColumn } from './columns.mjs'; - -const MAX_TABLE_COLUMNS = 50; - -const isSet = (value) => value !== undefined && value !== null && value !== '' && value !== 'auto' && value !== 'none'; - -/** Wraps a Handlebars template so the platform's install-time templating leaves it for SmartViz to resolve. */ -export const toRawTemplate = (template) => `{{{{raw}}}}${template}{{{{/raw}}}}`; - -/** - * A Handlebars reference to a column. A name with dots or brackets (`status[Expanded].rawState`) would read as a - * path, so it is looked up by its whole name instead. - */ -const toTemplateReference = (name) => - /^[A-Za-z_$][\w$]*$/u.test(name) ? `{{${name}}}` : `{{lookup this ${JSON.stringify(name)}}}`; - -const COLUMN_REFERENCE = /^\{\{\s*column\.([^}\s]+)\s*\}\}$/u; - -const STATE_COLORS = { success: '#2BB660', info: '#3B82F6', warning: '#F59E0B', error: '#DE0038', unknown: '#94A3B8' }; - -/** - * A state column that maps its own values to states (`Win` → success) keeps those values in a column the - * platform adds, `[Expanded].rawState`, which classic tables showed as " (raw)". Shown as a badge - * coloured by the map, the cell reads "Win" in green rather than "Success". - */ -const toMappedStateColumn = (column) => { - /** The platform adds the raw copy only for columns read from the response, not for computed ones. */ - const stateMap = column?.shapeName === 'state' && !column.isComputed ? column.shapeConfig?.map : undefined; - - if (!stateMap || typeof stateMap !== 'object' || Object.keys(stateMap).length === 0) { - return undefined; - } - - const colors = Object.fromEntries( - Object.entries(stateMap).flatMap(([state, values]) => - STATE_COLORS[state] && Array.isArray(values) ? values.map((value) => [String(value), STATE_COLORS[state]]) : [] - ) - ); - - return { field: `${column.name}[Expanded].rawState`, renderer: { type: 'badge', colors } }; -}; - -/** - * Picks the richTable renderer that shows a column the way the classic table did. Text, date and status - * renderers show the shape-formatted value, so units and decimals carry over unchanged. - */ -const toRenderer = (column) => { - switch (column?.shapeName) { - case 'state': - return { type: 'status' }; - case 'date': - return { type: 'date' }; - case 'url': { - const labelField = COLUMN_REFERENCE.exec(String(column.shapeConfig?.label ?? ''))?.[1]; - - return labelField ? { type: 'link', labelField } : { type: 'link' }; - } - default: - return { type: 'text' }; - } -}; - -/** - * The columns a classic table shows, in order. `columnOrder` is the author's list of shown columns, as the - * in-product SmartViz migration reads it; `hiddenColumns` always wins. Like classic, names the data no longer - * has are dropped, and with none left (or no `columnOrder`) every visible column shows. When the stream does - * not declare its full column list, `isAutomatic` leaves the columns for SmartViz to find in the data. - */ -const getShownColumnNames = (classicConfig, tileColumns, notes) => { - const hiddenColumns = new Set(classicConfig.hiddenColumns ?? []); - const columnOrder = (Array.isArray(classicConfig.columnOrder) ? classicConfig.columnOrder : []).filter( - (name) => !hiddenColumns.has(name) - ); - const knownNames = columnOrder.filter((name) => isKnownColumn(tileColumns, name)); - const staleNames = columnOrder.filter((name) => !knownNames.includes(name)); - - if (staleNames.length > 0) { - notes.push(`dropped column(s) the tile no longer has: ${staleNames.join(', ')}`); - } - - if (knownNames.length > 0) { - return { names: knownNames }; - } - - if (!tileColumns.isComplete) { - /** An automatic table shows every column the data has, so it cannot hide or rename any. */ - const isCustomised = hiddenColumns.size > 0 || Object.keys(classicConfig.columnDisplayNames ?? {}).length > 0; - - return isCustomised - ? { blockedReason: 'hides or renames columns of a stream that does not declare its full column list' } - : { isAutomatic: true }; - } - - return { - names: tileColumns.columns - .filter((column) => column.isVisible && !hiddenColumns.has(column.name)) - .map((column) => column.name) - }; -}; - -const getRowLinkField = (classicConfig) => { - const rowLink = classicConfig.rowLinkColumnName; - const field = typeof rowLink === 'string' ? rowLink : rowLink?.value; - - return field && field !== 'None' ? field : undefined; -}; - -/** Converts a classic `data-stream-table` config to a richTable spec. */ -export const toRichTable = (classicConfig, tileColumns) => { - const notes = []; - const { names, isAutomatic, blockedReason } = getShownColumnNames(classicConfig, tileColumns, notes); - - if (blockedReason) { - return { blockedReason }; - } - - const rowLinkField = getRowLinkField(classicConfig); - const style = { - ...(classicConfig.transpose === true && { richTableTranspose: true }), - ...(rowLinkField && isKnownColumn(tileColumns, rowLinkField) && { richTableRowLinkField: rowLinkField }) - }; - const withStyle = (encoding) => ({ chartType: 'richTable', encoding, ...(Object.keys(style).length > 0 && { style }) }); - - if (isAutomatic) { - return { spec: withStyle({}), notes: [...notes, 'shows every column the data has, as classic does'] }; - } - - if (names.length === 0) { - return { blockedReason: 'no columns to show' }; - } - - if (names.length > MAX_TABLE_COLUMNS) { - return { blockedReason: `shows ${names.length} columns; a SmartViz table holds at most ${MAX_TABLE_COLUMNS}` }; - } - - const { columns } = tileColumns; - const displayNameOverrides = classicConfig.columnDisplayNames ?? {}; - const undeclaredNames = names.filter((name) => !findColumn(columns, name)); - - if (undeclaredNames.length > 0) { - notes.push(`header and renderer inferred for undeclared column(s): ${undeclaredNames.join(', ')}`); - } - - return { - spec: withStyle({ - columns: names.map((name) => ({ - field: name, - title: getColumnTitle(columns, name, displayNameOverrides), - renderer: toRenderer(findColumn(columns, name)), - ...toMappedStateColumn(findColumn(columns, name)) - })) - }), - notes - }; -}; - -const toNominalChannel = (field) => ({ field, type: 'nominal' }); - -/** - * A configured column if the tile still has it, otherwise the first column `fallback` finds. Classic blocks - * resolve a missing state or link column the same way. - */ -const resolveColumn = (tileColumns, configured, fallback, label, notes) => { - if (isSet(configured) && isKnownColumn(tileColumns, configured)) { - return configured; - } - - if (isSet(configured)) { - notes.push(`${label} column ${configured} no longer exists`); - } - - return tileColumns.columns.find(fallback)?.name; -}; - -/** Converts a classic `data-stream-blocks` config to a statusGrid spec. */ -export const toStatusGrid = (classicConfig, tileColumns) => { - const notes = []; - - const labelField = resolveColumn(tileColumns, classicConfig.labelColumn, (column) => column.role === 'label', 'label', notes); - - if (!labelField) { - notes.push('label column left for SmartViz to pick, as classic does'); - } - - const statusField = resolveColumn(tileColumns, classicConfig.stateColumn, (column) => column.shapeName === 'state', 'state', notes); - const linkField = resolveColumn( - tileColumns, - classicConfig.linkColumn, - (column) => column.role === 'link' || column.shapeName === 'url', - 'link', - notes - ); - const groupField = resolveColumn(tileColumns, classicConfig.groupBy, () => false, 'group', notes); - const orderField = resolveColumn(tileColumns, classicConfig.orderBy, () => false, 'order', notes); - const sublabelField = resolveColumn(tileColumns, classicConfig.sublabel, () => false, 'sublabel', notes); - const blockHeight = Number(classicConfig.blockHeight); - - if (!statusField) { - notes.push('no state column found; blocks show with no status'); - } - - const style = { - ...(typeof classicConfig.columns === 'number' && { statusGridColumnsPerRow: classicConfig.columns }), - ...(sublabelField && { statusGridSecondaryTemplate: toRawTemplate(toTemplateReference(sublabelField)) }), - ...(classicConfig.wrapLabels === true && { statusGridLabelWrap: true }), - ...(orderField && { statusGridOrderByField: orderField }), - ...(blockHeight >= 50 && blockHeight <= 310 && { statusGridMaxBlockHeight: Math.round(blockHeight) }) - }; - - return { - spec: { - chartType: 'statusGrid', - encoding: { - ...(labelField && { label: toNominalChannel(labelField) }), - ...(statusField && { status: toNominalChannel(statusField) }), - ...(groupField && { group: toNominalChannel(groupField) }), - ...(linkField && { link: toNominalChannel(linkField) }) - }, - ...(Object.keys(style).length > 0 && { style }) - }, - notes: [ - ...notes, - ...(!labelField || findColumn(tileColumns.columns, labelField) ? [] : [`label column ${labelField} is not declared by the stream`]) - ] - }; -};