diff --git a/README.md b/README.md index 2692fc9..b932d22 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,24 @@ This plugin for Obsidian adds three new bases views: Scatter Charts, Line Charts ## Usage First you need a [Base](https://help.obsidian.md/bases), then from there you can (with the plugin installed and enabled) create thee new bases views: Scatter Charts, Line Charts, and Bar Charts. -Next you need to select the properties or formulas used for the X and Y values. Currently the plugin only supports a 1-to-1 mapping of notes to data points. -On the X axis, [`Number`, `Date`, and `String`](https://help.obsidian.md/bases/functions) are supported. On the Y axis the plugin only supports [`Number`](https://help.obsidian.md/bases/functions). -Data points can be grouped into multiple colors via a `Group by` sort. +Next you need to select the property or formula used for the X axis, in the view settings. +On the X axis, [`Number`, `Date`, and `String`](https://help.obsidian.md/bases/functions) are supported. +Then you can select which properties to display on the Y Axis using the Base's `Properties` menu (top right). +It is recommended that you disable the default activated file name. +On the Y axis the plugin only supports values of type [`Number`](https://help.obsidian.md/bases/functions). + +### Grouping and Multiple Charts + +The plugin supports grouping data points by color and spliting them into multiple charts. + +The view settings (in the Base's view menu, top left) include a `Multi chart mode` dropdown. +The avaiable options are `Separate by group` and `Separate by property`. + +Separating by group will use a `Group by` sort (Base's `Sort` menu, top right) to arrange the notes into multiple charts. +Here, every group gets it's own chart. Within one chart, data points are colored by their Y axis property. + +Separating by property will display a separate chart for each selected Y axis property. +Within a chart, data points are colored using a `Group by` sort (Base's `Sort` menu, top right). ### Scatter Charts diff --git a/exampleVault/aapl/185.md b/exampleVault/aapl/185.md index 8254b24..14f35e4 100644 --- a/exampleVault/aapl/185.md +++ b/exampleVault/aapl/185.md @@ -6,4 +6,4 @@ Low: 72.321426 Close: 73.227142 Adj Close: 62.759319 Volume: 82086200 ---- \ No newline at end of file +--- diff --git a/packages/obsidian/src/ChartData.ts b/packages/obsidian/src/ChartData.ts new file mode 100644 index 0000000..9ca765a --- /dev/null +++ b/packages/obsidian/src/ChartData.ts @@ -0,0 +1,146 @@ +import type { BasesPropertyId } from 'obsidian'; +import type { ChartView } from 'packages/obsidian/src/ChartView'; +import { OBSIDIAN_DEFAULT_SINGLE_COLOR, OBSIDIAN_COLOR_PALETTE } from 'packages/obsidian/src/utils/utils'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ProcessedData = { + x: number | Date | string; + y: number; + /** + * This is always the value by which we color the data point. + * In group separated mode, this is the display name of the property. + * In property separated mode, this is the group by value. + */ + groupIndex: number; + /** + * This is always the chart to sort the data point into. + * In group separated mode, this is the group by value. + * In property separated mode, this is the property id. + */ + chartIndex: number; + file: string; +}; + +export abstract class AbstractDataWrapper { + readonly view: ChartView; + readonly data: ProcessedData[]; + readonly groupBySet: string[]; + + constructor(view: ChartView, data: ProcessedData[], groupBySet: string[]) { + this.view = view; + this.data = data; + this.groupBySet = groupBySet; + } + + abstract getChartIdentifiers(): ChartId[]; + abstract getGroupIdentifiers(): GroupId[]; + + abstract getChartName(chartIndex: number): string; + abstract getGroupName(groupIndex: number): string; + + getChartGroupIdentifier(): (d: ProcessedData) => string { + if (this.hasMultipleGroups()) { + return d => this.getColorFromGroupIndex(d.groupIndex); + } + + return OBSIDIAN_DEFAULT_SINGLE_COLOR; + } + + getColorFromGroupIndex(groupIndex: number): string { + return OBSIDIAN_COLOR_PALETTE[groupIndex % OBSIDIAN_COLOR_PALETTE.length]; + } + + hasMultipleGroups(): boolean { + return this.getGroupIdentifiers().length > 1; + } + + hasMultipleCharts(): boolean { + return this.getChartIdentifiers().length > 1; + } + + getFlat(chartIndex: number, sorted: boolean = false): ProcessedData[] { + const data = this.data.filter(d => d.chartIndex === chartIndex); + + if (sorted) { + return sortDataByGroup(data); + } + + return data; + } + + getStacked(chartIndex: number): ProcessedData[] { + const xMap = new Map(); + const stackedData: ProcessedData[] = []; + + for (const entry of this.data) { + if (entry.chartIndex !== chartIndex) { + continue; + } + + const prevY = xMap.get(entry.x) ?? 0; + const newY = prevY + entry.y; + + stackedData.push({ + ...entry, + y: newY, + }); + + xMap.set(entry.x, newY); + } + + return stackedData; + } +} + +export class GroupSeparatedData extends AbstractDataWrapper { + getChartIdentifiers(): string[] { + return this.groupBySet; + } + + getGroupIdentifiers(): BasesPropertyId[] { + return this.view.data.properties; + } + + getChartName(chartIndex: number): string { + return this.getChartIdentifiers()[chartIndex] ?? `Chart ${chartIndex + 1}`; + } + + getGroupName(groupIndex: number): string { + const groupId = this.getGroupIdentifiers()[groupIndex]; + return this.view.config.getDisplayName(groupId) ?? `Group ${groupIndex + 1}`; + } +} + +export class PropertySeparatedData extends AbstractDataWrapper { + getChartIdentifiers(): BasesPropertyId[] { + return this.view.data.properties; + } + + getGroupIdentifiers(): string[] { + return this.groupBySet; + } + + getChartName(chartIndex: number): string { + const chartId = this.getChartIdentifiers()[chartIndex]; + return this.view.config.getDisplayName(chartId) ?? `Chart ${chartIndex + 1}`; + } + + getGroupName(groupIndex: number): string { + return this.getGroupIdentifiers()[groupIndex] ?? `Group ${groupIndex + 1}`; + } +} + +export type DataWrapper = GroupSeparatedData | PropertySeparatedData; + +export function emptyDataWrapper(view: ChartView): DataWrapper { + return new GroupSeparatedData(view, [], []); +} + +export function sortDataByGroup(data: ProcessedData[]): ProcessedData[] { + return data.sort((a, b) => { + if (a.groupIndex != null && b.groupIndex != null) { + return a.groupIndex - b.groupIndex; + } + return 0; + }); +} diff --git a/packages/obsidian/src/ChartView.ts b/packages/obsidian/src/ChartView.ts index 1778d6c..6ff70d5 100644 --- a/packages/obsidian/src/ChartView.ts +++ b/packages/obsidian/src/ChartView.ts @@ -1,10 +1,12 @@ -import type { BasesEntry, QueryController, Value } from 'obsidian'; +import type { BasesEntry, QueryController } from 'obsidian'; import type { BasesPropertyId, ViewOption } from 'obsidian'; -import { BasesView, DateValue, Events, NumberValue, StringValue } from 'obsidian'; +import { BasesView, Events } from 'obsidian'; +import type { DataWrapper, ProcessedData } from 'packages/obsidian/src/ChartData'; +import { emptyDataWrapper, GroupSeparatedData, PropertySeparatedData } from 'packages/obsidian/src/ChartData'; import BarPlot from 'packages/obsidian/src/charts/BarPlot.svelte'; import LinePlot from 'packages/obsidian/src/charts/LinePlot.svelte'; import ScatterPlot from 'packages/obsidian/src/charts/ScatterPlot.svelte'; -import { OBSIDIAN_COLOR_PALETTE, OBSIDIAN_DEFAULT_SINGLE_COLOR } from 'packages/obsidian/src/utils/utils'; +import { parseValueAsNumber, parseValueAsX } from 'packages/obsidian/src/utils/utils'; import { mount, unmount } from 'svelte'; export const SCATTER_CHART_VIEW_TYPE = 'chart-scatter'; @@ -20,202 +22,11 @@ export const CHART_SETTINGS = { MULTI_CHART: 'multi-chart-mode', } as const; -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type ProcessedData = { - x: number | Date | string; - y: number; - /** - * This is always the value by which we color the data point. - * In group separated mode, this is the display name of the property. - * In property separated mode, this is the group by value. - */ - groupIndex: number; - /** - * This is always the chart to sort the data point into. - * In group separated mode, this is the group by value. - * In property separated mode, this is the property id. - */ - chartIndex: number; - file: string; -}; - -export interface ProcessedGroupEntry { - key: string; - entries: ProcessedData[]; -} - -export type ProcessedGroupedData = - | { - grouped: true; - groups: ProcessedGroupEntry[]; - } - | { - grouped: false; - entries: ProcessedData[]; - }; - export enum MultiChartMode { GROUP = 'Separate by group', PROPERTY = 'Separate by property', } -function sortDataByGroup(data: ProcessedData[]): ProcessedData[] { - return data.sort((a, b) => { - if (a.groupIndex != null && b.groupIndex != null) { - return a.groupIndex - b.groupIndex; - } - return 0; - }); -} - -export abstract class AbstractDataWrapper { - readonly view: ChartView; - readonly data: ProcessedData[]; - readonly groupBySet: string[]; - - constructor(view: ChartView, data: ProcessedData[], groupBySet: string[]) { - this.view = view; - this.data = data; - this.groupBySet = groupBySet; - } - - abstract getChartIdentifiers(): ChartId[]; - abstract getGroupIdentifiers(): GroupId[]; - - abstract getChartName(chartIndex: number): string; - abstract getGroupName(groupIndex: number): string; - - getChartGroupIdentifier(): (d: ProcessedData) => string { - if (this.hasMultipleGroups()) { - return d => this.getColorFromGroupIndex(d.groupIndex); - } - - return OBSIDIAN_DEFAULT_SINGLE_COLOR; - } - - getColorFromGroupIndex(groupIndex: number): string { - return OBSIDIAN_COLOR_PALETTE[groupIndex % OBSIDIAN_COLOR_PALETTE.length]; - } - - hasMultipleGroups(): boolean { - return this.getGroupIdentifiers().length > 1; - } - - hasMultipleCharts(): boolean { - return this.getChartIdentifiers().length > 1; - } - - getFlat(chartIndex: number, sorted: boolean = false): ProcessedData[] { - const data = this.data.filter(d => d.chartIndex === chartIndex); - - if (sorted) { - return sortDataByGroup(data); - } - - return data; - } - - getStacked(chartIndex: number): ProcessedData[] { - const xMap = new Map(); - const stackedData: ProcessedData[] = []; - - for (const entry of this.data) { - if (entry.chartIndex !== chartIndex) { - continue; - } - - const prevY = xMap.get(entry.x) ?? 0; - const newY = prevY + entry.y; - - stackedData.push({ - ...entry, - y: newY, - }); - - xMap.set(entry.x, newY); - } - - return stackedData; - } -} - -export class GroupSeparatedData extends AbstractDataWrapper { - getChartIdentifiers(): string[] { - return this.groupBySet; - } - - getGroupIdentifiers(): BasesPropertyId[] { - return this.view.data.properties; - } - - getChartName(chartIndex: number): string { - return this.getChartIdentifiers()[chartIndex] ?? `Chart ${chartIndex + 1}`; - } - - getGroupName(groupIndex: number): string { - const groupId = this.getGroupIdentifiers()[groupIndex]; - return this.view.config.getDisplayName(groupId) ?? `Group ${groupIndex + 1}`; - } -} - -export class PropertySeparatedData extends AbstractDataWrapper { - getChartIdentifiers(): BasesPropertyId[] { - return this.view.data.properties; - } - - getGroupIdentifiers(): string[] { - return this.groupBySet; - } - - getChartName(chartIndex: number): string { - const chartId = this.getChartIdentifiers()[chartIndex]; - return this.view.config.getDisplayName(chartId) ?? `Chart ${chartIndex + 1}`; - } - - getGroupName(groupIndex: number): string { - return this.getGroupIdentifiers()[groupIndex] ?? `Group ${groupIndex + 1}`; - } -} - -export type DataWrapper = GroupSeparatedData | PropertySeparatedData; - -export function emptyDataWrapper(view: ChartView): DataWrapper { - return new GroupSeparatedData(view, [], []); -} - -export function parseValueAsNumber(value: Value | null): number | null { - if (!value) { - return null; - } - - if (value instanceof NumberValue) { - return value.data; - } - if (value instanceof StringValue) { - const parsed = parseFloat(value.data); - return isNaN(parsed) ? null : parsed; - } - return null; -} - -export function parseValueAsX(value: Value | null): number | Date | string | null { - if (!value) { - return null; - } - - if (value instanceof NumberValue) { - return value.data; - } - if (value instanceof StringValue) { - const parsed = parseFloat(value.data); - return isNaN(parsed) ? value.data : parsed; - } - if (value instanceof DateValue) { - return new Date(value.toString()); - } - return null; -} - export class ChartView extends BasesView { readonly type: ChartViewType; readonly scrollEl: HTMLElement; diff --git a/packages/obsidian/src/charts/BarPlot.svelte b/packages/obsidian/src/charts/BarPlot.svelte index 2aaeb85..f3f43d9 100644 --- a/packages/obsidian/src/charts/BarPlot.svelte +++ b/packages/obsidian/src/charts/BarPlot.svelte @@ -2,7 +2,7 @@ import { AxisX, AxisY, BarY, GridY, Plot, Text } from 'svelteplot'; import { CHART_SETTINGS, ChartView } from '../ChartView'; import { onMount } from 'svelte'; - import { OBSIDIAN_COLOR_PALETTE, toCompactString } from '../utils/utils'; + import { toCompactString } from '../utils/utils'; import PlotGrid from './PlotGrid.svelte'; interface Props { diff --git a/packages/obsidian/src/charts/LinePlot.svelte b/packages/obsidian/src/charts/LinePlot.svelte index 00104d7..e08b02a 100644 --- a/packages/obsidian/src/charts/LinePlot.svelte +++ b/packages/obsidian/src/charts/LinePlot.svelte @@ -1,7 +1,7 @@