diff --git a/examples/plots/index.js b/examples/plots/index.js index a9b3a02..e67d410 100644 --- a/examples/plots/index.js +++ b/examples/plots/index.js @@ -29,6 +29,7 @@ export * from "./percentageBarX.js"; export * from "./percentageBarY.js"; export * from "./percentageFacet.js"; export * from "./rawData.js"; +export * from "./rawDataMultiChart.js"; export * from "./sortXbyY.js"; export * from "./sortYbyX.js"; export * from "./text.js"; diff --git a/examples/plots/rawDataMultiChart.js b/examples/plots/rawDataMultiChart.js new file mode 100644 index 0000000..5e53010 --- /dev/null +++ b/examples/plots/rawDataMultiChart.js @@ -0,0 +1,27 @@ +import { renderPlot } from "../util/renderPlotClient.js"; +// This code is both displayed in the browser and executed +const codeString = ` +const rawData = [ + {col1: "a", col2: 5, col3: "Sales", mark: "barY"}, + {col1: "b", col2: 2, col3: "Sales", mark: "barY"}, + {col1: "c", col2: 3, col3: "Sales", mark: "barY"}, + {col1: "a", col2: 10, col3: "Clicks", mark: "line"}, + {col1: "b", col2: 5, col3: "Clicks", mark: "line"}, + {col1: "c", col2: 5, col3: "Clicks", mark: "line"}, + // TODO: would be nice if the mark generated a different color by default + {col1: "a", col2: 10, col3: "Clicks-dot", mark: "dot"}, + {col1: "b", col2: 5, col3: "Clicks-dot", mark: "dot"}, + {col1: "c", col2: 5, col3: "Clicks-dot", mark: "dot"}, +] + const types = {col1: "string", col2: "number", col3: "string", mark: "string"} +duckplot + .rawData(rawData, types) + .x("col1") + .y("col2") + .color("col3") + .mark("dot") + .markColumn("mark") +`; + +export const rawDataMultiChart = (options) => + renderPlot("stocks.csv", codeString, options); diff --git a/src/data/query.ts b/src/data/query.ts index 540baac..54720e7 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -237,11 +237,9 @@ export function getAggregateInfo( const subquery = aggregate !== false ? ` - SELECT ${groupBy.join(", ")}${ - aggregateSelection ? `, ${aggregateSelection}` : "" - } + SELECT ${[...groupBy, aggregateSelection].filter(Boolean).join(", ")} FROM ${tableName} - GROUP BY ${groupBy.join(", ")}` + ${groupBy.length ? `GROUP BY ${groupBy.join(", ")}` : ""}` : `SELECT *${ percent ? `, ROW_NUMBER() OVER () AS original_order` : "" } FROM ${tableName}`; @@ -270,19 +268,17 @@ export function getAggregateInfo( // Use the subquery to aggregate the values const queryString = ` - WITH aggregated AS (${subquery}) - SELECT ${groupBy.join(", ")}${ - aggregateColumn ? `, ${aggregateColumn}` : "" - } + WITH aggregated AS (${subquery}) + SELECT ${[...groupBy, aggregateColumn].filter(Boolean).join(", ")} FROM aggregated - ${ - percent && aggregate === false - ? ` ORDER BY original_order` - : orderBy - ? ` ORDER BY ${orderBy}` - : "" - } - `; + ${ + percent && aggregate === false + ? `ORDER BY original_order` + : orderBy + ? `ORDER BY ${orderBy}` + : "" + } +`; return { queryString, diff --git a/src/helpers.ts b/src/helpers.ts index c4bd1c4..f503058 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -144,8 +144,8 @@ export function processRawData(instance: DuckPlot): Data { const rawData = instance.rawData(); if (!rawData || !rawData.types) return []; - // Helper function to determine if a column is a string and defined - const isStringCol = (col?: ColumnType): boolean => + // Helper function to determine if a column defined + const colIsDefined = (col?: ColumnType): boolean => col !== "" && col !== undefined && typeof col === "string"; // Define column mappings for data, types, and labels @@ -158,13 +158,14 @@ export function processRawData(instance: DuckPlot): Data { { key: "fx", column: instance.fx().column }, { key: "r", column: instance.r().column }, { key: "text", column: instance.text().column }, + { key: "markColumn", column: instance.markColumn() }, ]; // Map over raw data to extract chart data based on defined columns const dataArray: Data = rawData.map((d) => Object.fromEntries( columnMappings - .filter(({ column }) => isStringCol(column)) + .filter(({ column }) => colIsDefined(column)) .map(({ key, column }) => [key, d[column as string]]) ) ); @@ -172,7 +173,7 @@ export function processRawData(instance: DuckPlot): Data { // Extract types based on the defined columns const dataTypes = Object.fromEntries( columnMappings - .filter(({ column }) => isStringCol(column)) + .filter(({ column }) => colIsDefined(column)) .map(({ key, column }) => [key, rawData?.types?.[column as string]]) ); @@ -227,4 +228,6 @@ export const checkForConfigErrors = (instance: DuckPlot) => { throw new Error("Multiple x columns only supported for barX type"); if (multipleY && instance.mark().type === "barX") throw new Error("Multiple y columns not supported for barX type"); + if (instance.markColumn() && !instance.rawData()) + throw new Error("MarkColumn is only supported with rawData"); }; diff --git a/src/index.ts b/src/index.ts index 23d260e..cc04cac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,7 @@ export class DuckPlot { private _newDataProps: boolean = true; private _data: Data = []; private _rawData: Data = []; + private _markColumn: string | undefined = undefined; private _config: Config = {}; private _query: string = ""; private _description: string = ""; // TODO: add tests @@ -236,6 +237,16 @@ export class DuckPlot { return this._rawData; } + // Mark column- only used with rawData, and the column holds the mark for each + // row (e.g., "line", "areaY", etc.) + markColumn(): string | undefined; + markColumn(column: string): this; + markColumn(column?: string): DuckPlot | string | undefined { + if (!column) return this._markColumn; + this._markColumn = column; + return this; + } + // Prepare data for rendering async prepareData(): Promise { // If no new data properties, return the data diff --git a/src/legend/legendCategorical.ts b/src/legend/legendCategorical.ts index 5b2d9b9..5b69f21 100644 --- a/src/legend/legendCategorical.ts +++ b/src/legend/legendCategorical.ts @@ -1,4 +1,5 @@ import type { DuckPlot } from ".."; +import { ChartType } from "../types"; export interface Category { name: string; @@ -13,6 +14,15 @@ export async function legendCategorical( instance.plotObject?.scale("color")?.domain ?? [] )?.map((d) => `${d}`); + // Get the symbols for each category + const symbols = categories.map((category) => { + if (!instance.markColumn()) return instance.mark().type; + const data = instance.data(); + // == as this has been stringified above + const symbol = data.find((d) => d.series == category)?.markColumn; + return symbol; + }); + function isActive(category: string): boolean { return ( instance.visibleSeries.length === 0 || @@ -57,13 +67,9 @@ export async function legendCategorical( isActive(category) ? "" : " dp-inactive" }`; - const square = document.createElement("div"); - square.style.backgroundColor = colors[i % colors.length]; - square.style.width = "12px"; - square.style.height = "12px"; - square.style.borderRadius = "5px"; - square.style.border = "1px solid rgba(0,0,0, .16)"; - categoryDiv.appendChild(square); + const symbolType = symbols[i]; + const symbol = drawSymbol(symbolType, colors[i % colors.length]); + categoryDiv.appendChild(symbol); const textNode = document.createTextNode(category); categoryDiv.appendChild(textNode); @@ -212,3 +218,24 @@ function showPopover(container: HTMLDivElement, height: number): void { popover.style.overflowY = `auto`; } } +function drawSymbol(symbolType: ChartType, color: string): HTMLElement { + const symbol = document.createElement("div"); + symbol.style.backgroundColor = color; + symbol.style.width = "12px"; + switch (symbolType) { + case "dot": + symbol.style.height = "12px"; + symbol.style.borderRadius = "12px"; + symbol.style.border = "1px solid rgba(0,0,0, .16)"; + return symbol; + case "line": + symbol.style.height = "0px"; // Use border for the line thickness + symbol.style.borderTop = "2px solid rgba(0,0,0, .16)"; // Define line thickness + return symbol; + default: + symbol.style.height = "12px"; + symbol.style.borderRadius = "2px"; + symbol.style.border = "1px solid rgba(0,0,0, .16)"; + return symbol; + } +} diff --git a/src/options/getAllMarkOptions.ts b/src/options/getAllMarkOptions.ts index 2a7e4bf..6785342 100644 --- a/src/options/getAllMarkOptions.ts +++ b/src/options/getAllMarkOptions.ts @@ -27,7 +27,6 @@ export function getAllMarkOptions(instance: DuckPlot) { const currentColumns = instance.filteredData?.types ? Object.keys(instance.filteredData?.types) : []; - const primaryMarkOptions = getPrimaryMarkOptions(instance); // Add the primary mark if x and y are defined OR if an aggregate has been // specified. Not a great rule, but works for showing aggregate marks with @@ -45,15 +44,29 @@ export function getAllMarkOptions(instance: DuckPlot) { instance.config().aggregate !== false; const hasColumnsOrAggregate = (hasX && hasY) || ((hasX || hasY) && hasAggregate); - + // TODO: do we need to update showMark logic for multiple marks? const showPrimaryMark = (isValidTickChart || hasColumnsOrAggregate) && instance.mark().type; - const primaryMark = showPrimaryMark + // Special case where the rawData has a mark column, render a different mark + // for each subset of the data + const markColumnMarks: ChartType[] = Array.from( + new Set(instance.filteredData.map((d) => d.markColumn).filter((d) => d)) + ); + const marks: ChartType[] = + markColumnMarks.length > 0 && instance.markColumn() !== undefined + ? markColumnMarks + : [instance.mark().type!]; + + const primaryMarks = showPrimaryMark ? [ - Plot[instance.mark().type!]( - instance.filteredData, - primaryMarkOptions as MarkOptions + ...marks.map((mark: ChartType) => + Plot[mark!]( + instance.filteredData?.filter((d) => { + return markColumnMarks.length > 0 ? d.markColumn === mark : true; + }), + getPrimaryMarkOptions(instance, mark) as MarkOptions + ) ), ] : []; @@ -76,7 +89,7 @@ export function getAllMarkOptions(instance: DuckPlot) { return [ ...(commonPlotMarks || []), - ...(primaryMark || []), + ...(primaryMarks || []), ...(fyMarks || []), ...tipMark, ]; diff --git a/src/options/getPrimaryMarkOptions.ts b/src/options/getPrimaryMarkOptions.ts index 31dac22..7cc946f 100644 --- a/src/options/getPrimaryMarkOptions.ts +++ b/src/options/getPrimaryMarkOptions.ts @@ -2,12 +2,16 @@ import { MarkOptions } from "@observablehq/plot"; import { DuckPlot } from ".."; import { isColor } from "./getPlotOptions"; import { defaultColors } from "../helpers"; +import { ChartType } from "../types"; // Get options for a specific mark (e.g., the line or area marks) -export function getPrimaryMarkOptions(instance: DuckPlot) { +export function getPrimaryMarkOptions( + instance: DuckPlot, + markType?: ChartType +) { // Grab the types from the data const { types } = instance.data(); - const type = instance.mark().type; + const type = markType ?? instance.mark().type; // pass in a markType for mulitple marks const data = instance.filteredData ?? instance.data(); const currentColumns = Object.keys(data.types || {}); const color = isColor(instance.color()?.column) diff --git a/src/options/getTipMark.ts b/src/options/getTipMark.ts index 17a31a7..d80b3e9 100644 --- a/src/options/getTipMark.ts +++ b/src/options/getTipMark.ts @@ -1,4 +1,4 @@ -import { MarkOptions, TipOptions } from "@observablehq/plot"; +import { TipOptions } from "@observablehq/plot"; import { DuckPlot } from ".."; import { borderOptions } from "../helpers"; import * as Plot from "@observablehq/plot";