diff --git a/package-lock.json b/package-lock.json index 3e3be79c2..eaabd42e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5301,27 +5301,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@lit-labs/ssr-dom-shim": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz", - "integrity": "sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==" - }, - "node_modules/@lit/context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.0.tgz", - "integrity": "sha512-fCyv4dsH05wCNm3AKbB+PdYbXGJd/XT8OOwo4hVmD4COq5wOWJlQreGAMDvmHZ7osqxuu06Y4nmP6ooXpN7ErA==", - "dependencies": { - "@lit/reactive-element": "^1.6.2 || ^2.0.0" - } - }, - "node_modules/@lit/reactive-element": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.1.tgz", - "integrity": "sha512-eu50SQXHRthFwWJMp0oAFg95Rvm6MTPjxSXWuvAu7It90WVFLFpNBoIno7XOXSDvVgTrtKnUV4OLJqys2Svn4g==", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.1.2" - } - }, "node_modules/@malloydata/db-bigquery": { "resolved": "packages/malloy-db-bigquery", "link": true @@ -10124,7 +10103,8 @@ "node_modules/@types/luxon": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.4.0.tgz", - "integrity": "sha512-oCavjEjRXuR6URJEtQm0eBdfsBiEcGBZbq21of8iGkeKxU1+1xgKuFPClaBZl2KB8ZZBSWlgk61tH6Mf+nvZVw==" + "integrity": "sha512-oCavjEjRXuR6URJEtQm0eBdfsBiEcGBZbq21of8iGkeKxU1+1xgKuFPClaBZl2KB8ZZBSWlgk61tH6Mf+nvZVw==", + "dev": true }, "node_modules/@types/mdx": { "version": "2.0.9", @@ -10294,11 +10274,6 @@ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" }, - "node_modules/@types/trusted-types": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.5.tgz", - "integrity": "sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA==" - }, "node_modules/@types/tunnel": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz", @@ -20198,34 +20173,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lit": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/lit/-/lit-3.0.2.tgz", - "integrity": "sha512-ZoVUPGgXOQocP4OvxehEOBmC4rWB4cRYDPaz7aFmH8DFytsCi/NeACbr4C6vNPGDEC07BrhUos7uVNayDKLQ2Q==", - "dependencies": { - "@lit/reactive-element": "^2.0.0", - "lit-element": "^4.0.0", - "lit-html": "^3.0.0" - } - }, - "node_modules/lit-element": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.1.tgz", - "integrity": "sha512-OxRMJem4HKZt0320HplLkBPoi4KHiEHoPHKd8Lzf07ZQVAOKIjZ32yPLRKRDEolFU1RgrQBfSHQMoxKZ72V3Kw==", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.1.2", - "@lit/reactive-element": "^2.0.0", - "lit-html": "^3.0.0" - } - }, - "node_modules/lit-html": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.0.2.tgz", - "integrity": "sha512-Q1A5lHza3bnmxoWJn6yS6vQZQdExl4fghk8W1G+jnAEdoFNYo5oeBBb/Ol7zSEdKd3TR7+r0zsJQyuWEVguiyQ==", - "dependencies": { - "@types/trusted-types": "^2.0.2" - } - }, "node_modules/load-json-file": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-6.2.0.tgz", @@ -28431,11 +28378,9 @@ "version": "0.0.137", "license": "MIT", "dependencies": { - "@lit/context": "^1.1.0", "@malloydata/malloy": "^0.0.137", "@types/luxon": "^2.4.0", "component-register": "^0.8.3", - "lit": "^3.0.2", "lodash": "^4.17.20", "luxon": "^2.4.0", "solid-element": "^1.8.0", @@ -28454,6 +28399,7 @@ "@storybook/html-vite": "^7.5.0", "@storybook/testing-library": "^0.2.2", "@storybook/types": "^7.5.3", + "@types/luxon": "^2.4.0", "esbuild": "0.19.11", "storybook": "^7.5.0", "vite": "^5.1.5", diff --git a/packages/malloy-render/.storybook/main.ts b/packages/malloy-render/.storybook/main.ts index 396f0a97d..38c2d71f9 100644 --- a/packages/malloy-render/.storybook/main.ts +++ b/packages/malloy-render/.storybook/main.ts @@ -53,6 +53,7 @@ const config: StorybookConfig = { define: { 'process.env': {}, }, + assetsInclude: ['/sb-preview/runtime.js'], }; const finalConfig = mergeConfig(config, configOverride); return finalConfig; diff --git a/packages/malloy-render/package.json b/packages/malloy-render/package.json index d8ff26f60..07b8eafcd 100644 --- a/packages/malloy-render/package.json +++ b/packages/malloy-render/package.json @@ -33,11 +33,9 @@ "build-types": "tsc --build --declaration --emitDeclarationOnly" }, "dependencies": { - "@lit/context": "^1.1.0", "@malloydata/malloy": "^0.0.137", "@types/luxon": "^2.4.0", "component-register": "^0.8.3", - "lit": "^3.0.2", "lodash": "^4.17.20", "luxon": "^2.4.0", "solid-element": "^1.8.0", @@ -56,6 +54,7 @@ "@storybook/html-vite": "^7.5.0", "@storybook/testing-library": "^0.2.2", "@storybook/types": "^7.5.3", + "@types/luxon": "^2.4.0", "esbuild": "0.19.11", "storybook": "^7.5.0", "vite": "^5.1.5", diff --git a/packages/malloy-render/src/component/bar-chart.ts b/packages/malloy-render/src/component/bar-chart.ts deleted file mode 100644 index ec63ef4e4..000000000 --- a/packages/malloy-render/src/component/bar-chart.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files - * (the "Software"), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, - * publish, distribute, sublicense, and/or sell copies of the Software, - * and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -import {LitElement, html} from 'lit'; -import {customElement, property} from 'lit/decorators.js'; -import './vega-chart'; -import {DataArray, ExploreField} from '@malloydata/malloy'; -import {RenderResultMetadata} from './render-result-metadata'; -import {getChartSettings} from './chart-settings'; -import {baseSpec} from './vega-lite-base-spec'; -import {valueIsNumber, valueIsString} from './util'; - -@customElement('malloy-bar-chart') -export class BarChart extends LitElement { - @property({attribute: false}) - data!: DataArray; - - @property({attribute: false}) - metadata!: RenderResultMetadata; - - override render() { - const field = this.data.field as ExploreField; - const keys = field.allFields; - const {tag} = field.tagParse(); - const isSpark = tag.text('size') === 'spark'; - const chartSettings = getChartSettings(field, this.metadata); - - const records: Partial<{barDim: string; sizeMeasure: number}>[] = []; - for (const rec of this.data) { - const record: Partial<{barDim: string; sizeMeasure: number}> = {}; - const xValue = rec.cell(chartSettings.xField.name).value; - const yValue = rec.cell(chartSettings.yField.name).value; - if (valueIsString(chartSettings.xField, xValue)) { - record.barDim = xValue; - } - if (valueIsNumber(chartSettings.yField, yValue)) { - record.sizeMeasure = yValue; - } - records.push(record); - } - - const spec = baseSpec(); - spec.data = { - values: records, - }; - - spec.layer = [ - { - mark: { - type: 'bar', - width: {'band': 0.8}, - cornerRadiusEnd: 0, - }, - encoding: { - x: { - field: 'barDim', - type: 'nominal', - sort: null, // Uses the sort order of underlying data - axis: { - labelAngle: chartSettings.xAxis.labelAngle, - maxExtent: chartSettings.xAxis.height, - labelLimit: chartSettings.xAxis.labelSize, - title: keys[0].name, - }, - scale: {}, - }, - y: { - field: 'sizeMeasure', - type: 'quantitative', - scale: { - domain: chartSettings.yScale.domain, - }, - axis: { - maxExtent: chartSettings.yAxis.width, - labelLimit: chartSettings.yAxis.width + 10, - tickCount: chartSettings.yAxis.tickCount, - title: keys[1].name, - }, - }, - // TODO: fill color from theme - fill: {value: '#53B2C8'}, - }, - }, - ]; - - if (isSpark) { - spec.padding = 0; - spec.layer[0].encoding.y.axis = null; - } else { - spec.padding = chartSettings.padding; - } - - return html`
- -
`; - } -} diff --git a/packages/malloy-render/src/component/bar-chart/generate-bar_chart-spec.ts b/packages/malloy-render/src/component/bar-chart/generate-bar_chart-spec.ts new file mode 100644 index 000000000..d26a75be4 --- /dev/null +++ b/packages/malloy-render/src/component/bar-chart/generate-bar_chart-spec.ts @@ -0,0 +1,83 @@ +import {Explore, Tag} from '@malloydata/malloy'; +import {Mark, PlotSpec, createEmptySpec} from '../plot/plot-spec'; +import {getFieldPathBetweenFields, walkFields} from '../plot/util'; + +export function generateBarChartSpec( + explore: Explore, + tagOverride?: Tag +): PlotSpec { + const tag = tagOverride ?? explore.tagParse().tag; + const chart = tag.tag('bar_chart') ?? tag.tag('bar'); + if (!chart) { + throw new Error( + 'Tried to render a bar_chart, but no bar_chart tag was found' + ); + } + + const spec = createEmptySpec(); + + // Parse top level tags + if (chart.text('x')) { + spec.x.fields.push(chart.text('x')!); + } + if (chart.text('y')) { + spec.y.fields.push(chart.text('y')!); + } + + // Parse embedded tags + const embeddedX: string[] = []; + const embeddedY: string[] = []; + walkFields(explore, field => { + const {tag} = field.tagParse(); + if (tag.has('x')) { + embeddedX.push(getFieldPathBetweenFields(explore, field)); + } + if (tag.has('y')) { + embeddedY.push(getFieldPathBetweenFields(explore, field)); + } + }); + + // Add all x's found + embeddedX.forEach(path => { + spec.x.fields.push(path); + }); + + // For now, only add first y. Will handle multiple y's later + if (embeddedY.at(0)) { + spec.y.fields.push(embeddedY.at(0)!); + } + + // If still no x or y, attempt to pick the best choice + if (spec.x.fields.length === 0) { + // Pick first string field for x. (what about dates? others? basically non numbers?) + const stringFields = explore.allFields.filter( + f => f.isAtomicField() && f.isString() + ); + if (stringFields.length > 0) + spec.x.fields.push(getFieldPathBetweenFields(explore, stringFields[0])); + } + if (spec.y.fields.length === 0) { + // Pick first numeric field for y + const numberField = explore.allFields.find( + f => f.isAtomicField() && f.isNumber() + ); + if (numberField) + spec.y.fields.push(getFieldPathBetweenFields(explore, numberField)); + } + + // Create bar mark + const barMark: Mark = { + id: 'bar', + type: 'bar_y', + x: null, // inherit from x channel + y: null, // inherit from y channel + }; + spec.marks.push(barMark); + + // Determine scale types for channels + // TODO: Make this derived from the fields chosen + spec.x.type = 'nominal'; + spec.y.type = 'quantitative'; + + return spec; +} diff --git a/packages/malloy-render/src/component/chart-settings.ts b/packages/malloy-render/src/component/chart-settings.ts index 7467dc69e..e3647b5f8 100644 --- a/packages/malloy-render/src/component/chart-settings.ts +++ b/packages/malloy-render/src/component/chart-settings.ts @@ -21,23 +21,27 @@ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import {ExploreField, Field} from '@malloydata/malloy'; +import {Explore, ExploreField, Field} from '@malloydata/malloy'; import {scale, locale} from 'vega'; import {getFieldKey, getTextWidth} from './util'; -import {RenderResultMetadata} from './render-result-metadata'; +import {RenderResultMetadata} from './types'; export type ChartSettings = { plotWidth: number; plotHeight: number; xAxis: { labelAngle: number; + labelAlign?: string; + labelBaseline?: string; labelSize: number; height: number; titleSize: number; + hidden: boolean; }; yAxis: { width: number; tickCount?: number; + hidden: boolean; }; yScale: { domain: number[]; @@ -69,7 +73,7 @@ const CHART_SIZES = { const ROW_HEIGHT = 28; export function getChartSettings( - field: ExploreField, + field: Explore | ExploreField, metadata: RenderResultMetadata ): ChartSettings { // TODO: improve logic for field extraction @@ -94,6 +98,8 @@ export function getChartSettings( let xAxisHeight = 0; let yAxisWidth = 0; let labelAngle = -90; + let labelAlign: string | undefined = 'right'; + let labelBaseline = 'middle'; let labelSize = 0; let xTitleSize = 0; const hasXAxis = presetSize !== 'spark'; @@ -152,6 +158,8 @@ export function getChartSettings( if (xSpacePerLabel > xAxisHeight) { labelAngle = 0; labelSize = xSpacePerLabel; + labelAlign = undefined; + labelBaseline = 'top'; } } @@ -160,28 +168,36 @@ export function getChartSettings( const roundedUpRowHeight = Math.ceil(totalSize / ROW_HEIGHT) * ROW_HEIGHT; xTitleSize += roundedUpRowHeight - totalSize; + const isSpark = tag.text('size') === 'spark'; + return { plotWidth: chartWidth, plotHeight: chartHeight, xAxis: { labelAngle, + labelAlign, + labelBaseline, labelSize, height: xAxisHeight, titleSize: xTitleSize, + hidden: isSpark, }, yAxis: { width: yAxisWidth, tickCount: yTickCount, + hidden: isSpark, }, yScale: { domain: yDomain, }, - padding: { - top: topPadding, - left: yAxisWidth, - bottom: xAxisHeight + xTitleSize, - right: 0, - }, + padding: isSpark + ? {top: 0, left: 0, bottom: 0, right: 0} + : { + top: topPadding + 1, + left: yAxisWidth, + bottom: xAxisHeight + xTitleSize, + right: 0, + }, xField, yField, get totalWidth() { diff --git a/packages/malloy-render/src/component/chart.tsx b/packages/malloy-render/src/component/chart.tsx new file mode 100644 index 000000000..8dba38246 --- /dev/null +++ b/packages/malloy-render/src/component/chart.tsx @@ -0,0 +1,22 @@ +import {Explore, ExploreField, QueryData} from '@malloydata/malloy'; +import {VegaChart} from './vega/vega-chart'; +import {RenderResultMetadata} from './types'; + +export function Chart(props: { + field: Explore | ExploreField; + data: QueryData; + metadata: RenderResultMetadata; +}) { + const {field, data} = props; + const chartProps = props.metadata.field(field).vegaChartProps!; + const vgSpec = structuredClone(chartProps.spec); + vgSpec.data[0].values = data; + return ( + + ); +} diff --git a/packages/malloy-render/src/component/custom-elements.d.ts b/packages/malloy-render/src/component/custom-elements.d.ts deleted file mode 100644 index 84b1d0fe5..000000000 --- a/packages/malloy-render/src/component/custom-elements.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import 'solid-js'; -import {DataArray} from '@malloydata/malloy'; -import {RenderResultMetadata} from '../render-result-metadata'; - -// TODO: This is temporary until we move charting into Solid components -declare module 'solid-js' { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - interface IntrinsicElements { - 'malloy-bar-chart': { - data: DataArray; - metadata: RenderResultMetadata; - }; - } - } -} diff --git a/packages/malloy-render/src/component/plot/plot-spec.ts b/packages/malloy-render/src/component/plot/plot-spec.ts new file mode 100644 index 000000000..32636b174 --- /dev/null +++ b/packages/malloy-render/src/component/plot/plot-spec.ts @@ -0,0 +1,48 @@ +type ScaleType = 'quantitative' | 'nominal'; + +export type Channel = { + fields: string[]; + type: ScaleType | null; +}; + +export type Mark = { + id: string; + type: string; // TODO: narrow for different mark types + x: string | null; + y: string | null; +}; + +export type PlotSpec = { + x: Channel; + y: Channel; + color: Channel; + fx: Channel; + fy: Channel; + marks: Mark[]; +}; + +export function createEmptySpec(): PlotSpec { + return { + x: { + fields: [], + type: null, + }, + y: { + fields: [], + type: null, + }, + color: { + fields: [], + type: null, + }, + fx: { + fields: [], + type: null, + }, + fy: { + fields: [], + type: null, + }, + marks: [], + }; +} diff --git a/packages/malloy-render/src/component/plot/plot-to-vega.ts b/packages/malloy-render/src/component/plot/plot-to-vega.ts new file mode 100644 index 000000000..deb8cbff5 --- /dev/null +++ b/packages/malloy-render/src/component/plot/plot-to-vega.ts @@ -0,0 +1,169 @@ +import {Explore, ExploreField} from '@malloydata/malloy'; +import {getChartSettings} from '../chart-settings'; +import {PlotSpec} from './plot-spec'; +import {RenderResultMetadata, VegaChartProps, VegaSpec} from '../types'; + +const grayMedium = '#727883'; +const gridGray = '#E5E7EB'; + +export function plotToVega( + plotSpec: PlotSpec, + options: { + field: Explore | ExploreField; + metadata: RenderResultMetadata; + } +): VegaChartProps { + const chartSettings = getChartSettings(options.field, options.metadata); + const vegaSpec: VegaSpec = { + '$schema': 'https://vega.github.io/schema/vega/v5.json', + width: chartSettings.plotWidth, + height: chartSettings.plotHeight, + config: { + axisY: { + gridColor: gridGray, + tickColor: gridGray, + domain: false, + + labelFont: 'Inter, sans-serif', + labelFontSize: 10, + labelFontWeight: 'normal', + labelColor: grayMedium, + labelPadding: 5, + titleColor: grayMedium, + titleFont: 'Inter, sans-serif', + titleFontSize: 12, + titleFontWeight: 'bold', + titlePadding: 10, + labelOverlap: false, + }, + axisX: { + gridColor: gridGray, + tickColor: gridGray, + tickSize: 0, + domain: false, + labelFont: 'Inter, sans-serif', + labelFontSize: 10, + labelFontWeight: 'normal', + labelPadding: 5, + labelColor: grayMedium, + titleColor: grayMedium, + titleFont: 'Inter, sans-serif', + titleFontSize: 12, + titleFontWeight: 'bold', + titlePadding: 10, + }, + view: { + strokeWidth: 0, + }, + }, + data: [ + { + name: 'table', + values: [], + }, + ], + marks: [], + scales: [], + legends: [], + axes: [], + autosize: { + type: 'none', + resize: true, + contains: 'content', + }, + padding: chartSettings.padding, + }; + + // use spec.x/y.fields to look up all data for x axis + const xScale: VegaSpec = { + name: 'xscale', + type: plotSpec.x.type === 'nominal' ? 'band' : 'linear', + domain: {data: 'table', field: plotSpec.x.fields.at(0)}, + range: 'width', + }; + + if (xScale.type === 'band') { + xScale.paddingInner = 0.1; + xScale.paddingOuter = 0.05; + xScale.round = true; + } + + const yScale: VegaSpec = { + name: 'yscale', + type: plotSpec.y.type === 'nominal' ? 'band' : 'linear', + domain: chartSettings.yScale.domain ?? { + data: 'table', + fields: plotSpec.y.fields, + }, + range: 'height', + }; + if (yScale.type === 'linear') { + yScale.nice = true; + } + + vegaSpec.scales.push(xScale); + vegaSpec.scales.push(yScale); + + for (const plotMark of plotSpec.marks) { + const vegaMark: VegaSpec = {}; + + // Set up mark data pipeline with any transformations + const markData = { + name: plotMark.id, + source: 'table', + transform: [], + }; + vegaSpec.data.push(markData); + + if (plotMark.type === 'bar_y') { + vegaMark.type = 'rect'; + vegaMark.from = {data: plotMark.id}; + const xField = plotMark.x ?? plotSpec.x.fields.at(0); + const yField = plotMark.y ?? plotSpec.y.fields.at(0); + vegaMark.encode = { + enter: { + x: {scale: 'xscale', field: xField, band: 0.1}, + width: {scale: 'xscale', band: 0.8}, + y: {scale: 'yscale', field: yField}, + y2: {'scale': 'yscale', 'value': 0}, + fill: {value: '#53B2C8'}, + }, + }; + + vegaSpec.marks.push(vegaMark); + } + } + + if (!chartSettings.xAxis.hidden) + vegaSpec.axes.push({ + orient: 'bottom', + scale: 'xscale', + title: plotSpec.x.fields.join(', '), + labelAngle: chartSettings.xAxis.labelAngle, + labelLimit: chartSettings.xAxis.labelSize, + labelAlign: chartSettings.xAxis.labelAlign, + labelBaseline: chartSettings.xAxis.labelBaseline, + maxExtent: chartSettings.xAxis.height, + }); + + if (!chartSettings.yAxis.hidden) + vegaSpec.axes.push({ + orient: 'left', + scale: 'yscale', + grid: true, + maxExtent: chartSettings.yAxis.width, + labelLimit: chartSettings.yAxis.width + 10, + tickCount: chartSettings.yAxis.tickCount ?? {signal: 'ceil(height/40)'}, + title: [...new Set(yScale.domain.fields)] + .filter(s => typeof s === 'string') + .join(', '), + }); + + return { + spec: vegaSpec, + plotWidth: chartSettings.plotWidth, + plotHeight: chartSettings.plotHeight, + totalWidth: chartSettings.totalWidth, + totalHeight: chartSettings.totalHeight, + }; +} diff --git a/packages/malloy-render/src/component/plot/util.ts b/packages/malloy-render/src/component/plot/util.ts new file mode 100644 index 000000000..08117df2d --- /dev/null +++ b/packages/malloy-render/src/component/plot/util.ts @@ -0,0 +1,44 @@ +import {Explore, Field} from '@malloydata/malloy'; + +export function walkFields(e: Explore, cb: (f: Field) => void) { + e.allFields.forEach(f => { + cb(f); + if (f.isExplore()) { + walkFields(f, cb); + } + }); +} + +export function getFieldPathArrayFromRoot(f: Field | Explore) { + const paths = f.isExplore() && !f.isExploreField() ? [] : [f.name]; + let parent = f.parentExplore; + while (parent?.isExploreField()) { + paths.unshift(parent.name); + parent = parent.parentExplore; + } + return paths; +} + +export function getFieldPathFromRoot(f: Field | Explore) { + return getFieldPathArrayFromRoot(f).join('.'); +} + +export function getFieldPathBetweenFields( + parentField: Field | Explore, + childField: Field | Explore +): string { + const parentPath = getFieldPathArrayFromRoot(parentField); + const childPath = getFieldPathArrayFromRoot(childField); + const startIndex = parentPath.length; + + let i = 0; + while (parentPath[i]) { + if (parentPath[i] !== childPath[i]) + throw new Error( + 'Tried to get path from parent field to child field, but parent field is not a parent of child field.' + ); + i++; + } + + return childPath.slice(startIndex).join('.'); +} diff --git a/packages/malloy-render/src/component/render-result-metadata.ts b/packages/malloy-render/src/component/render-result-metadata.ts index 1209d2084..048da4dce 100644 --- a/packages/malloy-render/src/component/render-result-metadata.ts +++ b/packages/malloy-render/src/component/render-result-metadata.ts @@ -23,95 +23,142 @@ import { DataArray, - DataRecord, + DataColumn, Explore, + ExploreField, Field, + QueryData, + QueryDataRow, Result, + Tag, } from '@malloydata/malloy'; import {getFieldKey, valueIsNumber, valueIsString} from './util'; +import {generateBarChartSpec} from './bar-chart/generate-bar_chart-spec'; +import {plotToVega} from './plot/plot-to-vega'; +import {hasAny} from './tag-utils'; +import {RenderResultMetadata} from './types'; -export interface FieldRenderMetadata { - field: Field; - min: number | null; - max: number | null; - minString: string | null; - maxString: string | null; - values: Set; - maxRecordCt: number | null; -} - -export interface RenderResultMetadata { - fields: Record; +function createDataCache() { + const dataCache = new WeakMap(); + return { + get: (cell: DataColumn) => { + if (!dataCache.has(cell) && cell.isArray()) { + const data: QueryDataRow[] = []; + for (const row of cell) { + data.push(row.toObject()); + } + dataCache.set(cell, data); + } + return dataCache.get(cell)!; + }, + }; } export function getResultMetadata(result: Result) { - const fieldKeyMap: WeakMap = new WeakMap(); - const getCachedFieldKey = (f: Field) => { + const fieldKeyMap: WeakMap = new WeakMap(); + const getCachedFieldKey = (f: Field | Explore) => { if (fieldKeyMap.has(f)) return fieldKeyMap.get(f)!; const fieldKey = getFieldKey(f); fieldKeyMap.set(f, fieldKey); return fieldKey; }; - + const dataCache = createDataCache(); const metadata: RenderResultMetadata = { fields: {}, + fieldKeyMap, + getFieldKey: getCachedFieldKey, + field: (f: Field | Explore) => metadata.fields[getCachedFieldKey(f)], + getData: dataCache.get, }; - function initFieldMeta(e: Explore) { - for (const f of e.allFields) { - const fieldKey = getCachedFieldKey(f); - metadata.fields[fieldKey] = { - field: f, - min: null, - max: null, - minString: null, - maxString: null, - values: new Set(), - maxRecordCt: null, - }; - if (f.isExploreField()) { - initFieldMeta(f); - } + const rootField = result.data.field; + const fieldKey = metadata.getFieldKey(rootField); + metadata.fields[fieldKey] = { + field: rootField, + min: null, + max: null, + minString: null, + maxString: null, + values: new Set(), + maxRecordCt: null, + }; + + initFieldMeta(result.data.field, metadata); + populateFieldMeta(result.data, metadata); + + Object.values(metadata.fields).forEach(m => { + const f = m.field; + // If explore, do some additional post-processing like determining chart settings + if (f.isExploreField()) populateExploreMeta(f, f.tagParse().tag, metadata); + else if (f.isExplore()) + populateExploreMeta(f, result.tagParse().tag, metadata); + }); + + return metadata; +} + +function initFieldMeta(e: Explore, metadata: RenderResultMetadata) { + for (const f of e.allFields) { + const fieldKey = metadata.getFieldKey(f); + metadata.fields[fieldKey] = { + field: f, + min: null, + max: null, + minString: null, + maxString: null, + values: new Set(), + maxRecordCt: null, + }; + if (f.isExploreField()) { + initFieldMeta(f, metadata); } } +} - const populateFieldMeta = ( - data: DataArray, - metadata: RenderResultMetadata, - cb?: (row: DataRecord) => void - ) => { - for (const row of data) { - cb?.(row); - for (const f of data.field.allFields) { - const value = f.isAtomicField() ? row.cell(f).value : undefined; - const fieldKey = getFieldKey(f); - const fieldMeta = metadata.fields[fieldKey]; - if (valueIsNumber(f, value)) { - const n = value; - fieldMeta.min = Math.min(fieldMeta.min ?? n, n); - fieldMeta.max = Math.max(fieldMeta.max ?? n, n); - } else if (valueIsString(f, value)) { - const s = value; - fieldMeta.values.add(s); - if (!fieldMeta.minString || fieldMeta.minString.length > s.length) - fieldMeta.minString = s; - if (!fieldMeta.maxString || fieldMeta.maxString.length < s.length) - fieldMeta.maxString = s; - } else if (f.isExploreField()) { - const data = row.cell(f) as DataArray; - let recordCt = 0; - populateFieldMeta(data, metadata, () => recordCt++); - fieldMeta.maxRecordCt = Math.max( - fieldMeta.maxRecordCt ?? recordCt, - recordCt - ); - } +const populateFieldMeta = (data: DataArray, metadata: RenderResultMetadata) => { + let currExploreRecordCt = 0; + for (const row of data) { + currExploreRecordCt++; + for (const f of data.field.allFields) { + const value = f.isAtomicField() ? row.cell(f).value : undefined; + const fieldMeta = metadata.field(f); + if (valueIsNumber(f, value)) { + const n = value; + fieldMeta.min = Math.min(fieldMeta.min ?? n, n); + fieldMeta.max = Math.max(fieldMeta.max ?? n, n); + } else if (valueIsString(f, value)) { + const s = value; + fieldMeta.values.add(s); + if (!fieldMeta.minString || fieldMeta.minString.length > s.length) + fieldMeta.minString = s; + if (!fieldMeta.maxString || fieldMeta.maxString.length < s.length) + fieldMeta.maxString = s; + } else if (f.isExploreField()) { + const data = row.cell(f) as DataArray; + populateFieldMeta(data, metadata); } } - }; - - initFieldMeta(result.data.field); - populateFieldMeta(result.data, metadata); + } + // root explore + const rootField = data.field; + const fieldMeta = metadata.field(rootField); + fieldMeta.maxRecordCt = Math.max( + fieldMeta.maxRecordCt ?? currExploreRecordCt, + currExploreRecordCt + ); +}; - return metadata; +function populateExploreMeta( + f: Explore | ExploreField, + tag: Tag, + metadata: RenderResultMetadata +) { + const fieldMeta = metadata.field(f); + if (hasAny(tag, 'bar', 'bar_chart')) { + const plotSpec = generateBarChartSpec(f, tag); + fieldMeta.vegaChartProps = plotToVega(plotSpec, { + field: f, + metadata, + }); + } } diff --git a/packages/malloy-render/src/component/render.tsx b/packages/malloy-render/src/component/render.tsx index 6976399db..307e00b01 100644 --- a/packages/malloy-render/src/component/render.tsx +++ b/packages/malloy-render/src/component/render.tsx @@ -1,10 +1,11 @@ import {ModelDef, QueryResult, Result, Tag} from '@malloydata/malloy'; -import {createEffect, createMemo} from 'solid-js'; +import {Match, Switch, createEffect, createMemo} from 'solid-js'; import {getResultMetadata} from './render-result-metadata'; import {ResultContext} from './result-context'; +import {Chart} from './chart'; import MalloyTable from './table/table'; -import './bar-chart'; import './render.css'; +import {shouldRenderAs} from './util'; export type MalloyRenderProps = { result?: Result; @@ -46,9 +47,23 @@ export function MalloyRender(props: MalloyRenderProps, {element}) { } }); + const renderAs = () => { + const tag = tags().resultTag; + const rootField = result().resultExplore; + return shouldRenderAs(rootField, tag); + }; + return ( - + }> + + + + ); } diff --git a/packages/malloy-render/src/component/result-context.ts b/packages/malloy-render/src/component/result-context.ts index 5a99c4cc1..3654f0813 100644 --- a/packages/malloy-render/src/component/result-context.ts +++ b/packages/malloy-render/src/component/result-context.ts @@ -1,5 +1,5 @@ import {createContext, useContext} from 'solid-js'; -import {RenderResultMetadata} from './render-result-metadata'; +import {RenderResultMetadata} from './types'; export const ResultContext = createContext(); export const useResultContext = () => { diff --git a/packages/malloy-render/src/component/table/table-layout.ts b/packages/malloy-render/src/component/table/table-layout.ts index e4dba3f84..fc32d45c2 100644 --- a/packages/malloy-render/src/component/table/table-layout.ts +++ b/packages/malloy-render/src/component/table/table-layout.ts @@ -22,13 +22,10 @@ */ import {Field} from '@malloydata/malloy'; -import { - FieldRenderMetadata, - RenderResultMetadata, -} from '../render-result-metadata'; import {clamp, getFieldKey, getTextWidth} from '../util'; import {renderNumericField} from '../render-numeric-field'; -import {ChartSettings, getChartSettings} from '../chart-settings'; +import {hasAny} from '../tag-utils'; +import {FieldRenderMetadata, RenderResultMetadata} from '../types'; const MIN_COLUMN_WIDTH = 32; const MAX_COLUMN_WIDTH = 384; @@ -40,7 +37,6 @@ type LayoutEntry = { metadata: FieldRenderMetadata; width: number; height: number | null; - chartSettings: ChartSettings | null; }; export type TableLayout = Record; @@ -52,17 +48,15 @@ export function getTableLayout(metadata: RenderResultMetadata): TableLayout { const field = fieldMeta.field; const layoutEntry: LayoutEntry = { metadata: fieldMeta, - width: getColumnWidth(field, metadata), + width: !field.isExplore() ? getColumnWidth(field, metadata) : 0, height: null, - chartSettings: null, }; const {tag} = field.tagParse(); - if (tag.has('bar') && field.isExploreField()) { - layoutEntry.chartSettings = getChartSettings(field, metadata); - layoutEntry.width = layoutEntry.chartSettings.totalWidth; - layoutEntry.height = layoutEntry.chartSettings.totalHeight; - } else if (field.isAtomicField()) { + if (hasAny(tag, 'bar', 'bar_chart') && field.isExploreField()) { + layoutEntry.width = fieldMeta.vegaChartProps!.totalWidth; + layoutEntry.height = fieldMeta.vegaChartProps!.totalHeight; + } else if (!field.isExplore() && field.isAtomicField()) { layoutEntry.height = ROW_HEIGHT; } diff --git a/packages/malloy-render/src/component/table/table.tsx b/packages/malloy-render/src/component/table/table.tsx index ffe97d3c3..b2de3c433 100644 --- a/packages/malloy-render/src/component/table/table.tsx +++ b/packages/malloy-render/src/component/table/table.tsx @@ -7,8 +7,15 @@ import { Show, Switch, Match, + JSXElement, } from 'solid-js'; -import {AtomicField, DataArray, DataRecord, Field} from '@malloydata/malloy'; +import { + AtomicField, + DataArray, + DataRecord, + ExploreField, + Field, +} from '@malloydata/malloy'; import { getFieldKey, isFirstChild, @@ -22,10 +29,11 @@ import {getTableLayout} from './table-layout'; import {useResultContext} from '../result-context'; import {TableContext, useTableContext} from './table-context'; import './table.css'; +import {Chart} from '../chart'; const Cell = (props: { field: Field; - value: string | number | Element; + value: JSXElement; hideStartGutter: boolean; hideEndGutter: boolean; isHeader?: boolean; @@ -104,7 +112,7 @@ const HeaderField = (props: {field: Field}) => { const TableField = (props: {field: Field; row: DataRecord}) => { const tableCtx = useTableContext()!; const renderAs = shouldRenderAs(props.field); - let renderValue: string | number | Element = ''; + let renderValue: JSXElement = ''; if (tableCtx.pinnedHeader) renderValue = ''; else if (renderAs === 'cell') { const resultCellValue = props.row.cell(props.field).value; @@ -119,14 +127,15 @@ const TableField = (props: {field: Field; row: DataRecord}) => { } else if (valueIsString(props.field, resultCellValue)) { renderValue = resultCellValue; } - } else if (renderAs === 'bar-chart') { + } else if (renderAs === 'chart') { const metadata = useResultContext(); renderValue = ( - - ) as Element; + /> + ); } return ( diff --git a/packages/malloy-render/src/component/tag-utils.ts b/packages/malloy-render/src/component/tag-utils.ts new file mode 100644 index 000000000..e061ab270 --- /dev/null +++ b/packages/malloy-render/src/component/tag-utils.ts @@ -0,0 +1,7 @@ +import {Tag} from '@malloydata/malloy/src'; + +export function hasAny(tag: Tag, ...paths: Array): boolean { + return paths.some(path => + Array.isArray(path) ? tag.has(...path) : tag.has(path) + ); +} diff --git a/packages/malloy-render/src/component/types.ts b/packages/malloy-render/src/component/types.ts new file mode 100644 index 000000000..aa57df750 --- /dev/null +++ b/packages/malloy-render/src/component/types.ts @@ -0,0 +1,30 @@ +import {DataColumn, Explore, Field, QueryData} from '@malloydata/malloy'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Vega does not have good TS support +export type VegaSpec = any; +export type VegaChartProps = { + spec: VegaSpec; + plotWidth: number; + plotHeight: number; + totalWidth: number; + totalHeight: number; +}; + +export interface FieldRenderMetadata { + field: Field | Explore; + min: number | null; + max: number | null; + minString: string | null; + maxString: string | null; + values: Set; + maxRecordCt: number | null; + vegaChartProps?: VegaChartProps; +} + +export interface RenderResultMetadata { + fields: Record; + fieldKeyMap: WeakMap; + getFieldKey: (f: Field | Explore) => string; + field: (f: Field | Explore) => FieldRenderMetadata; + getData: (cell: DataColumn) => QueryData; +} diff --git a/packages/malloy-render/src/component/util.ts b/packages/malloy-render/src/component/util.ts index 3cbac20da..ef227c20d 100644 --- a/packages/malloy-render/src/component/util.ts +++ b/packages/malloy-render/src/component/util.ts @@ -20,7 +20,8 @@ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import {Explore, Field} from '@malloydata/malloy'; +import {Explore, Field, Tag} from '@malloydata/malloy'; +import {hasAny} from './tag-utils'; function getLocationInParent(f: Field | Explore) { const parent = f.parentExplore; @@ -61,13 +62,13 @@ export function clamp(s: number, e: number, v: number) { return Math.max(s, Math.min(e, v)); } -export function shouldRenderAs(f: Field) { - if (f.isAtomicField()) return 'cell'; - const {tag} = f.tagParse(); - if (tag.has('bar')) return 'bar-chart'; +export function shouldRenderAs(f: Field | Explore, tagOverride?: Tag) { + if (!f.isExplore() && f.isAtomicField()) return 'cell'; + const tag = tagOverride ?? f.tagParse().tag; + if (hasAny(tag, 'bar_chart')) return 'chart'; else return 'table'; } -export function getFieldKey(f: Field) { +export function getFieldKey(f: Field | Explore) { return JSON.stringify(f.fieldPath); } diff --git a/packages/malloy-render/src/component/vega-chart.ts b/packages/malloy-render/src/component/vega-chart.ts deleted file mode 100644 index 19ac52681..000000000 --- a/packages/malloy-render/src/component/vega-chart.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files - * (the "Software"), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, - * publish, distribute, sublicense, and/or sell copies of the Software, - * and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import {LitElement, html, TemplateResult, css, PropertyValues} from 'lit'; -import {customElement, property} from 'lit/decorators.js'; -import {View, parse} from 'vega'; -import {compile} from 'vega-lite'; -import {VegaJSON, asVegaLiteSpec, asVegaSpec} from './vega-types'; - -@customElement('malloy-vega-chart') -export class VegaChart extends LitElement { - static override styles = css` - #vis > svg { - display: block; - } - `; - @property({attribute: false}) - spec!: VegaJSON; - - @property({type: String}) - type: 'vega' | 'vega-lite' = 'vega'; - - @property({type: Number}) - width?: number; - - @property({type: Number}) - height?: number; - - private el: HTMLElement | null = null; - private view: View | null = null; - - setupView() { - if (this.view) this.view.finalize(); - const vegaspec = - this.type === 'vega-lite' - ? compile(asVegaLiteSpec(this.spec)).spec - : asVegaSpec(this.spec); - - this.view = new View(parse(vegaspec)) - .initialize(this.el!) - .renderer('svg') - .hover(); - if (this.width) this.view.width(this.width); - if (this.height) this.view.height(this.height); - - this.view.run(); - } - - firstUpdated() { - this.el = this.shadowRoot!.getElementById('vis')!; - this.setupView(); - } - - protected willUpdate(changedProperties: PropertyValues) { - if (changedProperties.has('spec') || changedProperties.has('type')) { - if (this.el) this.setupView(); - } else { - if ( - (changedProperties.has('width') || changedProperties.has('height')) && - this.view - ) { - if (this.width) this.view.width(this.width); - if (this.height) this.view.height(this.height); - this.view.run(); - } - } - } - - render(): TemplateResult { - return html`
`; - } -} diff --git a/packages/malloy-render/src/component/vega-lite-base-spec.ts b/packages/malloy-render/src/component/vega-lite-base-spec.ts deleted file mode 100644 index e0058bac5..000000000 --- a/packages/malloy-render/src/component/vega-lite-base-spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files - * (the "Software"), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, - * publish, distribute, sublicense, and/or sell copies of the Software, - * and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -import {VegaJSON} from './vega-types'; - -const grayMedium = '#727883'; -const gridGray = '#E5E7EB'; - -export const baseSpec = (): VegaJSON => ({ - $schema: 'https://vega.github.io/schema/vega-lite/v5.json', - config: { - axisY: { - gridColor: gridGray, - tickColor: gridGray, - domain: false, - labelFont: 'Inter, sans-serif', - labelFontSize: 10, - labelFontWeight: 'normal', - labelColor: grayMedium, - labelPadding: 5, - titleColor: grayMedium, - titleFont: 'Inter, sans-serif', - titleFontSize: 12, - titleFontWeight: 'bold', - titlePadding: 10, - labelOverlap: false, - }, - axisX: { - gridColor: gridGray, - tickColor: gridGray, - tickSize: 0, - domain: false, - labelFont: 'Inter, sans-serif', - labelFontSize: 10, - labelFontWeight: 'normal', - labelPadding: 5, - labelColor: grayMedium, - titleColor: grayMedium, - titleFont: 'Inter, sans-serif', - titleFontSize: 12, - titleFontWeight: 'bold', - titlePadding: 10, - }, - view: { - strokeWidth: 0, - }, - }, - params: [], - padding: 0, - autosize: { - type: 'none', - resize: true, - contains: 'content', - }, - // for vega-lite, if width/height is not specificed in spec it will try to autosize. Set values to prevent - width: 1, - height: 1, - data: { - values: [], - }, - layer: [], -}); diff --git a/packages/malloy-render/src/component/vega/vega-chart.tsx b/packages/malloy-render/src/component/vega/vega-chart.tsx new file mode 100644 index 000000000..9cf41db55 --- /dev/null +++ b/packages/malloy-render/src/component/vega/vega-chart.tsx @@ -0,0 +1,38 @@ +import {createEffect} from 'solid-js'; +import {VegaJSON, asVegaLiteSpec, asVegaSpec} from '../vega-types'; +import {View, parse} from 'vega'; +import {compile} from 'vega-lite'; + +type VegaChartProps = { + spec: VegaJSON; + type: 'vega' | 'vega-lite'; + width?: number; + height?: number; +}; + +export function VegaChart(props: VegaChartProps) { + let el!: HTMLDivElement; + + let view: View | null = null; + + createEffect(() => { + if (view) view.finalize(); + const vegaspec = + props.type === 'vega-lite' + ? compile(asVegaLiteSpec(props.spec)).spec + : asVegaSpec(props.spec); + + view = new View(parse(vegaspec)).initialize(el).renderer('svg').hover(); + view.run(); + }); + + createEffect(() => { + if (view) { + if (props.width) view.width(props.width); + if (props.height) view.height(props.height); + view.run(); + } + }); + + return
; +} diff --git a/packages/malloy-render/src/stories/bars.stories.ts b/packages/malloy-render/src/stories/bars.stories.ts index 83dfedfa3..19be6ac13 100644 --- a/packages/malloy-render/src/stories/bars.stories.ts +++ b/packages/malloy-render/src/stories/bars.stories.ts @@ -49,3 +49,24 @@ export const SparksNested = { view: 'sparks_nested', }, }; + +export const TestOld = { + args: { + source: 'products', + view: 'test', + }, +}; + +export const Test = { + args: { + source: 'products', + view: 'topSellingBrandsTest', + }, +}; + +export const NestedTest = { + args: { + source: 'products', + view: 'nested_test', + }, +}; diff --git a/packages/malloy-render/src/stories/static/bars.malloy b/packages/malloy-render/src/stories/static/bars.malloy index 97787af62..e7c34c53a 100644 --- a/packages/malloy-render/src/stories/static/bars.malloy +++ b/packages/malloy-render/src/stories/static/bars.malloy @@ -3,13 +3,25 @@ source: products is duckdb.table("data/products.parquet") extend { measure: avg_margin is avg(retail_price - cost) dimension: product is name - # bar + # bar_chart view: topSellingBrands is { group_by: brand aggregate: `Sales $` is retail_price.avg()*500 limit: 10 } + # bar_chart + view: topSellingBrandsTest is { + group_by: brand + aggregate: `Sales $` is retail_price.avg()*500 + limit: 10 + } + + view: test is { + nest: topSellingBrands + } + + view: sparks is { group_by: category # currency @@ -36,7 +48,7 @@ source: products is duckdb.table("data/products.parquet") extend { limit: 5 # currency aggregate: `Avg Retail` is retail_price.avg() - # bar size="spark" + # bar_chart size="spark" nest: `Trailing 12mo Sales` is trailing_12_sales_trend } } @@ -76,22 +88,60 @@ source: products is duckdb.table("data/products.parquet") extend { `2xl` is topSellingBrands } + view: nested_test is { + group_by: category + aggregate: avg_retail is retail_price.avg() + nest: + # bar_chart size=lg + # size.height=220 size.width=300 + nested_column_1 is { + group_by: brand + aggregate: avg_retail is retail_price.avg() + limit: 10 + } + # bar_chart size=lg + # size.height=220 size.width=300 + nested_column_2 is { + group_by: brand + aggregate: avg_retail is retail_price.avg() + limit: 10 + } + nested_column_3 is { + group_by: brand + aggregate: avg_retail is retail_price.avg() + limit: 10 + } + limit: 2 + } + view: nested is { group_by: category aggregate: avg_retail is retail_price.avg() nest: - # bar size=lg + # bar_chart size=lg # size.height=220 size.width=300 nested_column_1 is { group_by: brand aggregate: avg_retail is retail_price.avg() limit: 10 } + # bar_chart size=lg + # size.height=220 size.width=300 + nested_column_2 is { + group_by: brand + aggregate: avg_retail is retail_price.avg() + limit: 10 + } + nested_column_3 is { + group_by: brand + aggregate: avg_retail is retail_price.avg() + limit: 10 + } another_nested is { group_by: department aggregate: avg_retail is retail_price.avg() - # bar + # bar_chart nest: deeply_nested is { group_by: `sku` diff --git a/test/jest.setup.js b/test/jest.setup.js index 0bd5e3e92..493f1d2fa 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -1,5 +1,7 @@ -const {JSDOM} = require('jsdom'); -const {window} = new JSDOM(``); +const {JSDOM, VirtualConsole} = require('jsdom'); +const {window} = new JSDOM(``, { + virtualConsole: new VirtualConsole().sendTo(console, {omitJSDOMErrors: true}), +}); global.document = window.document; global.HTMLElement = window.HTMLElement; global.customElements = window.customElements;