Skip to content

Commit

Permalink
Support multiple marks for rawData plots (#68)
Browse files Browse the repository at this point in the history
* First pass at rawData mark column

* Move tip to its own function

* Explicitly stack tip, remove unused tip code

* Explicitly set name of mark column

* Allow mark to be passed into getPrimaryMarkOptions

* Add different symbol marks to categorical legends

* Bring examples back in

* Fix y axis only error

* Fix markColumn type definition

* Update border radius and multi chart example
  • Loading branch information
mkfreeman authored Dec 5, 2024
1 parent 94b6632 commit 96f8b37
Show file tree
Hide file tree
Showing 9 changed files with 119 additions and 37 deletions.
1 change: 1 addition & 0 deletions examples/plots/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
27 changes: 27 additions & 0 deletions examples/plots/rawDataMultiChart.js
Original file line number Diff line number Diff line change
@@ -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);
28 changes: 12 additions & 16 deletions src/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -158,21 +158,22 @@ 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]])
)
);

// 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]])
);

Expand Down Expand Up @@ -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");
};
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Data> {
// If no new data properties, return the data
Expand Down
41 changes: 34 additions & 7 deletions src/legend/legendCategorical.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DuckPlot } from "..";
import { ChartType } from "../types";

export interface Category {
name: string;
Expand All @@ -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 ||
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
27 changes: 20 additions & 7 deletions src/options/getAllMarkOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
),
]
: [];
Expand All @@ -76,7 +89,7 @@ export function getAllMarkOptions(instance: DuckPlot) {

return [
...(commonPlotMarks || []),
...(primaryMark || []),
...(primaryMarks || []),
...(fyMarks || []),
...tipMark,
];
Expand Down
8 changes: 6 additions & 2 deletions src/options/getPrimaryMarkOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/options/getTipMark.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down

0 comments on commit 96f8b37

Please sign in to comment.